from datetime import date from decimal import Decimal from typing import Annotated, Literal from pydantic import BaseModel, Field, field_validator from app.domain import REOPEN_TARGET_STAGES, Stage from app.schemas.common import AppModel, UTCDateTime from app.schemas.user import UserRef Money = Annotated[Decimal, Field(ge=0, max_digits=12, decimal_places=2)] # ── Inputs ─────────────────────────────────────────────────────────────────── class NcrCreateIn(BaseModel): job_number: str = Field(min_length=1, max_length=100) department_id: int deviation_category_id: int disposition_authority_id: int deviation_detail: str = Field(min_length=5, max_length=20000) class InitialDispositionIn(BaseModel): qc_authority: str | None = Field(default=None, max_length=255) work_order: str | None = Field(default=None, max_length=100) disposition_notes: str | None = Field(default=None, max_length=100000) secondary_review_needed: bool # "Notify These People" — required when secondary_review_needed is true. secondary_authority_ids: list[int] = [] class SecondaryDispositionIn(BaseModel): qc_authority: str | None = Field(default=None, max_length=255) work_order: str | None = Field(default=None, max_length=100) disposition_notes: str | None = Field(default=None, max_length=100000) # False = save updates and keep in my queue; True = release to Operations. release: bool = False class InspectionIn(BaseModel): qc_approval: Literal["yes", "no"] | None = None inspection_notes: str | None = Field(default=None, max_length=20000) # False = save and revisit later; True = advance to Costing. qc_closed: bool = False class CapaIn(BaseModel): """Corrective & Preventive Action section (API Q1 §5.9.1.2 / §6.4.2). Only fields present in the request body are updated (exclude_unset), so partial saves from different roles never clobber each other's entries. """ root_cause: str | None = Field(default=None, max_length=20000) root_cause_category_id: int | None = None corrective_action_required: bool | None = None corrective_action_justification: str | None = Field(default=None, max_length=2000) corrective_action_plan: str | None = Field(default=None, max_length=20000) corrective_action_owner_id: int | None = None corrective_action_due_date: date | None = None effectiveness_result: Literal["effective", "not_effective"] | None = None effectiveness_notes: str | None = Field(default=None, max_length=20000) is_recurring: bool | None = None # Replaces the full set of linked prior NCRs when present. related_ncr_ids: list[int] | None = None class CostingIn(BaseModel): labor_cost: Money material_cost: Money service_cost: Money other_cost: Money class ReopenIn(BaseModel): to_stage: Stage reason: str = Field(min_length=5, max_length=2000) @field_validator("to_stage") @classmethod def _valid_target(cls, v: Stage) -> Stage: if v not in REOPEN_TARGET_STAGES: raise ValueError("Reopen target must be a prior (non-closed) stage.") return v # ── Outputs ────────────────────────────────────────────────────────────────── class AttachmentOut(AppModel): id: int original_filename: str content_type: str size_bytes: int is_image: bool uploaded_at: UTCDateTime uploaded_by: UserRef class TransitionOut(AppModel): id: int from_stage: str | None to_stage: str action: str acted_at: UTCDateTime acted_by: UserRef note: str | None class JobInfoOut(AppModel): part_id: str | None part_description: str | None customer_name: str | None work_order_status: str | None source: str class NcrLinkOut(BaseModel): """Light projection of a linked NCR (recurring-issue tracking).""" id: int ncr_number: str job_number: str stage: str stage_label: str class NcrListItem(BaseModel): id: int ncr_number: str job_number: str department: str deviation_category: str requester: str disposition_authority: str stage: str stage_label: str days_in_stage: int created_at: UTCDateTime class NcrListOut(BaseModel): items: list[NcrListItem] total: int page: int page_size: int class NcrDetailOut(BaseModel): id: int ncr_number: str job_number: str created_at: UTCDateTime stage: str stage_label: str stage_entered_at: UTCDateTime days_in_stage: int department: str department_id: int deviation_category: str deviation_category_id: int deviation_detail: str requester: UserRef disposition_authority: UserRef qc_authority: str | None work_order: str | None disposition_notes: str | None secondary_review_needed: bool | None secondary_authorities: list[UserRef] operations_complete: bool operations_completed_at: UTCDateTime | None operations_completed_by: UserRef | None qc_approval: str | None inspection_notes: str | None qc_closed: bool qc_closed_at: UTCDateTime | None qc_closed_by: UserRef | None root_cause: str | None root_cause_category: str | None root_cause_category_id: int | None corrective_action_required: bool | None corrective_action_justification: str | None corrective_action_plan: str | None corrective_action_owner: UserRef | None corrective_action_due_date: date | None corrective_action_opened_at: UTCDateTime | None effectiveness_result: str | None effectiveness_notes: str | None effectiveness_verified_at: UTCDateTime | None effectiveness_verified_by: UserRef | None is_recurring: bool related_ncrs: list[NcrLinkOut] referenced_by: list[NcrLinkOut] labor_cost: Decimal | None material_cost: Decimal | None service_cost: Decimal | None other_cost: Decimal | None total_cost: Decimal | None costing_completed_at: UTCDateTime | None costing_completed_by: UserRef | None closed_at: UTCDateTime | None closed_by: UserRef | None job_info: JobInfoOut | None attachments: list[AttachmentOut] transitions: list[TransitionOut] # Actions the *current* user may take right now (informs the UI; the API # re-enforces every one of these server-side). available_actions: list[str] class NcrMutationOut(BaseModel): ncr: NcrDetailOut warnings: list[str] = [] class AuditEntryOut(AppModel): id: int created_at: UTCDateTime user: UserRef action: str field_name: str | None old_value: str | None new_value: str | None detail: str | None class AuditListOut(BaseModel): items: list[AuditEntryOut] total: int