From 068c51909c2a81be9059ae13cae30a3ffc9d0f4d Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Thu, 27 Aug 2026 15:04:25 +0330 Subject: [PATCH] fix(db): fill item_receipts.confirmed_by in office paths; resilient per-statement v2.6 upgrade (root cause of 500 on receipt) --- backend/app/db/init_db.py | 82 ++++++++++++++------------- backend/app/routers/item_receipts.py | 2 + backend/app/services/quote_service.py | 21 ++++++- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 355e445..1b38eaf 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -344,42 +344,46 @@ BASE_UNITS: list[dict] = [ async def ensure_v26(engine: AsyncEngine) -> None: - """Idempotent v2.6 upgrade: inventory domain + users + arrival flow + v2.5 deltas.""" - try: - async with engine.begin() as conn: - for ddl in _V26_DDL: - await conn.execute(text(ddl)) - # Seed the 8 fixed topic categories (ON CONFLICT keeps existing rows). - for cat in TOPIC_CATEGORIES: - await conn.execute( - text( - "INSERT INTO material_categories (slug, name) VALUES (:slug, :name) " - "ON CONFLICT (slug) DO NOTHING" - ), - {"slug": cat["slug"], "name": cat["name"]}, - ) - # Seed base units (idempotent) so materials/line items can be created. - for u in BASE_UNITS: - await conn.execute( - text( - "INSERT INTO units (code, name_fa, dimension, si_factor, is_base) " - "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) " - "ON CONFLICT (code) DO NOTHING" - ), - u, - ) - # v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync). - await conn.execute(text("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")) - await conn.execute(text(""" - CREATE TRIGGER trg_supplier_materials_derive_category - AFTER INSERT ON supplier_materials - FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category() - """)) - await conn.execute(text("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")) - await conn.execute(text(""" - CREATE TRIGGER trg_item_decisions_sync_price - AFTER INSERT OR UPDATE OF status ON item_decisions - FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price() - """)) - except Exception: # pragma: no cover - DB unavailable at startup - logger.exception("Could not ensure v2.6 schema — is the database ready?") + """Idempotent v2.6 upgrade: inventory domain + users + arrival flow + v2.5 deltas. + + Every statement runs in its OWN transaction: one failure (e.g. duplicate + material names blocking uq_material_name on an old volume) can never roll + back the rest of the upgrade — otherwise `item_receipts.confirmed_by` and + friends would stay missing and the arrival flow would crash with 500s. + """ + async def run_statement(ddl: str, params: dict | None = None) -> None: + try: + async with engine.begin() as conn: + await conn.execute(text(ddl), params or {}) + except Exception: # pragma: no cover - pre-existing data conflicts + first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100] + logger.exception("v2.6 statement skipped (continuing): %s", first_line) + + for ddl in _V26_DDL: + await run_statement(ddl) + # Seed the 8 fixed topic categories (ON CONFLICT keeps existing rows). + for cat in TOPIC_CATEGORIES: + await run_statement( + "INSERT INTO material_categories (slug, name) VALUES (:slug, :name) ON CONFLICT (slug) DO NOTHING", + {"slug": cat["slug"], "name": cat["name"]}, + ) + # Seed base units (idempotent) so materials/line items can be created. + for u in BASE_UNITS: + await run_statement( + "INSERT INTO units (code, name_fa, dimension, si_factor, is_base) " + "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING", + u, + ) + # v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync). + await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") + await run_statement(""" + CREATE TRIGGER trg_supplier_materials_derive_category + AFTER INSERT ON supplier_materials + FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category() + """) + await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") + await run_statement(""" + CREATE TRIGGER trg_item_decisions_sync_price + AFTER INSERT OR UPDATE OF status ON item_decisions + FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price() + """) diff --git a/backend/app/routers/item_receipts.py b/backend/app/routers/item_receipts.py index 126da72..f29254e 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -7,6 +7,7 @@ from app.db.engine import get_db from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.services.expand_service import receipt_full +from app.services.quote_service import _fallback_confirmer_id from app.utils.errors import ConflictError, NotFoundError router = APIRouter( @@ -52,6 +53,7 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy raise ConflictError("رسید این خط آیتم قبلاً ثبت شده است") receipt = ItemReceipt( item_decision_id=decision.id, + confirmed_by=await _fallback_confirmer_id(db), received_at=payload.received_at, received_qty=payload.received_qty, on_time=payload.on_time, diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 882b5f3..f428df3 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -535,6 +535,18 @@ async def _decision_for_line_item(db: AsyncSession, line_item_id: UUID) -> ItemD return decision +async def _fallback_confirmer_id(db: AsyncSession) -> UUID: + """item_receipts.confirmed_by is NOT NULL — office (unauthenticated) + receipt paths reuse the dev-user row (same pattern as pm.py).""" + from app.models.procurement import User + dev = await db.scalar(select(User).where(User.casdoor_id == "dev")) + if dev is None: + dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm") + db.add(dev) + await db.flush() + return dev.id + + async def arrive_quote(db: AsyncSession, quote_id: UUID, actor: str | None = None) -> Quote: """Full-arrival shortcut: receipt for every non-rejected line item.""" async with db.begin(): @@ -551,7 +563,13 @@ async def arrive_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non continue existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) if existing is None: - db.add(ItemReceipt(item_decision_id=decision.id, received_qty=li.quantity)) + db.add( + ItemReceipt( + item_decision_id=decision.id, + confirmed_by=await _fallback_confirmer_id(db), + received_qty=li.quantity, + ) + ) quote.status = "arrived" quote.arrived_date = utcnow() @@ -587,6 +605,7 @@ async def arrive_items(db: AsyncSession, quote_id: UUID, payload: ArriveItemsIn, db.add( ItemReceipt( item_decision_id=decision.id, + confirmed_by=await _fallback_confirmer_id(db), received_at=item.received_at or utcnow(), received_qty=item.received_qty, on_time=item.on_time,