Files
whqreza 1f16e44874 feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly

Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)

Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
2026-09-15 19:23:50 +03:30

344 lines
11 KiB
Python

"""Pydantic v2 request schemas — quotation & procurement domain."""
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
QuoteStatus = Literal["offered", "accepted", "arrived", "rejected", "expired"]
TermsheetStatus = Literal["offered", "accepted", "rejected"]
DiscountType = Literal["none", "percent", "fixed"]
DecisionStatus = Literal["pending", "chosen", "rejected"]
# ---------------------------------------------------------------------------
# Suppliers / projects
# ---------------------------------------------------------------------------
class SupplierCreate(APIModel):
name: str = Field(min_length=1)
category_id: UUID # v2.6: 1 supplier → exactly 1 category (required)
location: str | None = None
phone: str | None = None
contact_person: str | None = None
tax_id: str | None = None
class SupplierUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
category_id: UUID | None = None
location: str | None = None
phone: str | None = None
contact_person: str | None = None
tax_id: str | None = None
class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id)."""
material_id: UUID
class ProjectCreate(APIModel):
name: str = Field(min_length=1)
description: str | None = None
start_date: date | None = None
class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
description: str | None = None
start_date: date | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None
# ---------------------------------------------------------------------------
# Quotes
# ---------------------------------------------------------------------------
class QuoteLineItemIn(APIModel):
temp_id: str = Field(min_length=1)
material_id: UUID
brand_material_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
notes: str | None = None
project_ids: list[UUID] | None = None # پروژه‌های تخصیص‌یافته به این قلم
class ScheduleStepIn(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date
amount: Decimal
note: str | None = None
class TermsheetIn(APIModel):
temp_id: str = Field(min_length=1)
label: str = Field(min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(default_factory=list)
class ItemTermsheetLinkIn(APIModel):
line_item_temp_id: str = Field(min_length=1)
termsheet_temp_id: str = Field(min_length=1)
unit_price: Decimal = Field(gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class QuoteCreate(APIModel):
supplier_id: UUID
project_id: UUID | None = None
project_ids: list[UUID] = Field(default_factory=list) # چند پروژه برای یک پیشنهاد
quote_date: datetime | None = None
expiry_date: date | None = None
is_official: bool = True
notes: str | None = None
# اقلام/شرایط پرداخت/قیمت‌ها می‌توانند خالی باشند — کاربر ممکن است بخواهد
# پیشنهادی ناقص را ذخیره کند (task: عدم اجبار تکمیل فیلدها)
line_items: list[QuoteLineItemIn] = Field(default_factory=list)
termsheets: list[TermsheetIn] = Field(default_factory=list)
item_termsheets: list[ItemTermsheetLinkIn] = Field(default_factory=list)
class QuoteChildrenReplaceIn(APIModel):
"""Full replacement of a quote's line items + termsheets (edit modal)."""
line_items: list[QuoteLineItemIn] = Field(default_factory=list)
termsheets: list[TermsheetIn] = Field(default_factory=list)
item_termsheets: list[ItemTermsheetLinkIn] = Field(default_factory=list)
class QuoteUpdate(APIModel):
supplier_id: UUID | None = None
project_id: UUID | None = None
project_ids: list[UUID] | None = None
quote_date: datetime | None = None
expiry_date: date | None = None
is_official: bool | None = None
notes: str | None = None
status: QuoteStatus | None = None
class AcceptTermsheetIn(APIModel):
id: UUID | None = None
label: str | None = Field(default=None, min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ItemDecisionIn(APIModel):
line_item_id: UUID
status: Literal["chosen", "rejected"]
accepted_termsheet_index: int | None = Field(default=None, ge=0)
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
class ItemProjectsIn(APIModel):
line_item_id: UUID
project_ids: list[UUID] = Field(default_factory=list)
class AcceptQuoteIn(APIModel):
accepted_termsheets: list[AcceptTermsheetIn] = Field(min_length=1)
item_decisions: list[ItemDecisionIn] = Field(min_length=1)
item_projects: list[ItemProjectsIn] = Field(default_factory=list) # تخصیص پروژه به هر قلم
class ArriveItemIn(APIModel):
line_item_id: UUID
received_qty: Decimal = Field(gt=0)
received_at: datetime | None = None
on_time: bool | None = None
quality_note: str | None = None
class ArriveItemsIn(APIModel):
items: list[ArriveItemIn] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# v2.6 — PM arrival confirmation (item_decisions.project_id + allocations)
# ---------------------------------------------------------------------------
class ReceiptAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class PMReceiptCreate(APIModel):
"""PM confirms a delivery: receipt + warehouse split (free count).
Allocations must sum exactly to received_qty and every warehouse must
belong to item_decisions.project_id.
"""
item_decision_id: UUID
received_qty: Decimal = Field(gt=0)
allocations: list[ReceiptAllocationIn] = Field(min_length=1)
received_at: datetime | None = None
on_time: bool | None = None
quality_note: str | None = None
class UserCreate(APIModel):
"""Define a new user from the Organization panel (admin / procurement manager).
casdoor_id is optional: real Casdoor users auto-provision on their first
login; a manual entry gets a synthetic casdoor_id so the row can exist
before the person ever signs in.
"""
first_name: str = Field(min_length=1)
last_name: str = ""
phone: str | None = None
casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserProjectUpdate(APIModel):
"""Assign the user to one project (multi-project aware).
project_id given → that project gets this PM (previous PM replaced,
the user's other projects kept); None → clears every assignment.
"""
project_id: UUID | None = None
class RejectQuoteIn(APIModel):
reason: str | None = None
class NoteEventIn(APIModel):
event_type: Literal["note"] = "note"
payload: dict = Field(default_factory=dict)
actor: str | None = None
# ---------------------------------------------------------------------------
# Line items / specs
# ---------------------------------------------------------------------------
class LineItemCreate(APIModel):
material_id: UUID
brand_material_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
notes: str | None = None
class LineItemUpdate(APIModel):
material_id: UUID | None = None
brand_material_id: UUID | None = None
unit_id: int | None = None
quantity: Decimal | None = Field(default=None, gt=0)
notes: str | None = None
class SpecIn(APIModel):
attribute_definition_id: UUID
value: int | float | str | bool | None = None
unit_id_at_time: int | None = None
class SpecsPutIn(APIModel):
specs: list[SpecIn] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Termsheets / schedule steps
# ---------------------------------------------------------------------------
class TermsheetCreate(APIModel):
label: str = Field(min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class TermsheetUpdate(APIModel):
label: str | None = Field(default=None, min_length=1)
discount_type: DiscountType | None = None
discount_value: Decimal | None = None
class TermsheetStatusUpdate(APIModel):
status: Literal["accepted", "rejected"]
class ScheduleStepCreate(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date
amount: Decimal
note: str | None = None
class ScheduleStepUpdate(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date | None = None
amount: Decimal | None = None
note: str | None = None
# ---------------------------------------------------------------------------
# Item termsheets / decisions / receipts
# ---------------------------------------------------------------------------
class ItemTermsheetCreate(APIModel):
termsheet_id: UUID
unit_price: Decimal = Field(gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class ItemTermsheetUpdate(APIModel):
unit_price: Decimal | None = Field(default=None, gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class DecisionPut(APIModel):
chosen_item_termsheet_id: UUID | None = None
status: Literal["chosen", "rejected"]
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
class DecisionStatusUpdate(APIModel):
status: Literal["chosen", "rejected"]
class DecisionPatch(APIModel):
chosen_item_termsheet_id: UUID | None = None
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
class ReceiptCreate(APIModel):
received_at: datetime | None = None
received_qty: Decimal = Field(gt=0)
on_time: bool | None = None
quality_note: str | None = None
class ReceiptUpdate(APIModel):
received_at: datetime | None = None
received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None
quality_note: str | None = None