- Add ProjectTransfer database model and migration - Implement project transfer service with immediate stock deduction on creation - Support 100% full delivery receipt logic across destination warehouses - Enforce canonical_specs_hash matching for cross-project stock merge - Add RBAC guards and status lock on received transfers (409 Conflict) - Register API router for transfers with on-the-way integration
88 lines
4.0 KiB
Python
88 lines
4.0 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import (
|
|
DateTime,
|
|
ForeignKey,
|
|
Numeric,
|
|
Text,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db.base import Base
|
|
from app.models.catalog import _timestamptz, _uuid_pk
|
|
|
|
|
|
class ProjectTransfer(Base):
|
|
"""project_transfers — Inter-project (inter-site) material transfer ledger.
|
|
|
|
Records the movement of materials from a source project warehouse to a
|
|
destination project. Stock is deducted from the source warehouse immediately
|
|
upon creation (status = 'in_transit') via out_transaction_id.
|
|
When the destination project PM confirms receipt in 'Arrivals', stock is
|
|
added to the destination warehouse (status = 'received').
|
|
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
|
|
Once 'received', the transfer is permanently locked from edit or cancellation.
|
|
"""
|
|
|
|
__tablename__ = "project_transfers"
|
|
__mapper_args__ = {"eager_defaults": True}
|
|
|
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
|
from_project_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
|
)
|
|
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
|
|
)
|
|
to_project_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
|
)
|
|
material_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
|
|
)
|
|
brand_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("brands.id", ondelete="SET NULL")
|
|
)
|
|
unit_id: Mapped[int] = mapped_column(
|
|
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
|
)
|
|
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
|
|
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
|
|
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
|
|
|
|
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
|
|
)
|
|
transfer_date: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
transferred_by: Mapped[str | None] = mapped_column(Text)
|
|
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
received_by: Mapped[str | None] = mapped_column(Text)
|
|
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("warehouses.id", ondelete="SET NULL")
|
|
)
|
|
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
|
|
created_at: Mapped[datetime] = _timestamptz()
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
|
|
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
|
|
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
|
|
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
|
|
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
|
|
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
|
|
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
|
|
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
|
|
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
|
|
)
|