dodil/erp-brick-timesheets

DODIL ERP brick: timesheets

src/brick_timesheets/application/inputs.py

"""The bounded inputs of this brick's use cases (BRICKS.md: an unbounded int or str is a promise the engine cannot keep).
Hours and rates are exact decimals as strings on the wire; the use case answers 422 {gate: bounds} for what a person's week cannot hold."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Optional

from erp_platform.types import text
from pydantic import BaseModel, BeforeValidator, Field, WithJsonSchema

from brick_timesheets.domain.sheet import RATE_MAX

Code = text(64, min_length=1)
Currency = text(3)                   # the use case says why a code is not an ISO 4217 code (after the permission check)
Reason = text(500)
Ref = text(140)
Note = text(500)
Descr = text(200)
Value = text(64, min_length=1)
# A date given as text, parsed by the use case AFTER the permission gate: a caller without the permission is told so (403), not that their date is
# malformed (422). Same for a week ("2026-W47") and for hours (a string like "7.50").
Day = Annotated[str, Field(max_length=32), WithJsonSchema({"type": "string", "format": "date", "description": "a date like 2026-11-20"})]
Week = Annotated[str, Field(max_length=16), WithJsonSchema({"type": "string", "description": "an ISO week like 2026-W47"})]
HoursText = Annotated[str, Field(max_length=20), WithJsonSchema({"type": "string", "description": "hours as an exact decimal like \"7.50\", zero to clear"})]
Instant = Annotated[str, Field(max_length=40), WithJsonSchema({"type": "string", "description": "an RFC 3339 instant like 2026-11-23T10:00:00Z"})]


class EntryIn(BaseModel):
    """The hours of one day on one line."""
    work_date: Day
    hours: HoursText
    note: Optional[Note] = None


Entries = Annotated[list[EntryIn], Field(min_length=1, max_length=7)]


def parse_rate(v: Any) -> Decimal:
    """A cost rate per hour: an exact decimal above zero, at most four places, below 10 million."""
    if isinstance(v, (bool, float)):
        raise ValueError("a rate is an exact number: give it as a string such as \"52.0000\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a rate") from None
    if not d.is_finite():
        raise ValueError("a rate is a finite number")
    if d.as_tuple().exponent < -4:
        raise ValueError("a rate has at most four decimal places")
    if not (Decimal(0) < d < RATE_MAX):
        raise ValueError("a rate is more than zero and below 10,000,000 an hour")
    return d


Rate = Annotated[Decimal, BeforeValidator(parse_rate),
                 WithJsonSchema({"type": "string", "pattern": r"^[0-9]{1,7}(\.[0-9]{1,4})?$", "description": "an exact hourly rate as a string"})]


def day(v: Optional[str]) -> date:
    return date.fromisoformat(str(v).strip())