fix(db): fill item_receipts.confirmed_by in office paths; resilient per-statement v2.6 upgrade (root cause of 500 on receipt)
This commit is contained in:
+24
-20
@@ -344,42 +344,46 @@ BASE_UNITS: list[dict] = [
|
|||||||
|
|
||||||
|
|
||||||
async def ensure_v26(engine: AsyncEngine) -> None:
|
async def ensure_v26(engine: AsyncEngine) -> None:
|
||||||
"""Idempotent v2.6 upgrade: inventory domain + users + arrival flow + v2.5 deltas."""
|
"""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:
|
try:
|
||||||
async with engine.begin() as conn:
|
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:
|
for ddl in _V26_DDL:
|
||||||
await conn.execute(text(ddl))
|
await run_statement(ddl)
|
||||||
# Seed the 8 fixed topic categories (ON CONFLICT keeps existing rows).
|
# Seed the 8 fixed topic categories (ON CONFLICT keeps existing rows).
|
||||||
for cat in TOPIC_CATEGORIES:
|
for cat in TOPIC_CATEGORIES:
|
||||||
await conn.execute(
|
await run_statement(
|
||||||
text(
|
"INSERT INTO material_categories (slug, name) VALUES (:slug, :name) ON CONFLICT (slug) DO NOTHING",
|
||||||
"INSERT INTO material_categories (slug, name) VALUES (:slug, :name) "
|
|
||||||
"ON CONFLICT (slug) DO NOTHING"
|
|
||||||
),
|
|
||||||
{"slug": cat["slug"], "name": cat["name"]},
|
{"slug": cat["slug"], "name": cat["name"]},
|
||||||
)
|
)
|
||||||
# Seed base units (idempotent) so materials/line items can be created.
|
# Seed base units (idempotent) so materials/line items can be created.
|
||||||
for u in BASE_UNITS:
|
for u in BASE_UNITS:
|
||||||
await conn.execute(
|
await run_statement(
|
||||||
text(
|
|
||||||
"INSERT INTO units (code, name_fa, dimension, si_factor, is_base) "
|
"INSERT INTO units (code, name_fa, dimension, si_factor, is_base) "
|
||||||
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) "
|
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
||||||
"ON CONFLICT (code) DO NOTHING"
|
|
||||||
),
|
|
||||||
u,
|
u,
|
||||||
)
|
)
|
||||||
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
|
# 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 run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||||
await conn.execute(text("""
|
await run_statement("""
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
CREATE TRIGGER trg_supplier_materials_derive_category
|
||||||
AFTER INSERT ON supplier_materials
|
AFTER INSERT ON supplier_materials
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
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 run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||||
await conn.execute(text("""
|
await run_statement("""
|
||||||
CREATE TRIGGER trg_item_decisions_sync_price
|
CREATE TRIGGER trg_item_decisions_sync_price
|
||||||
AFTER INSERT OR UPDATE OF status ON item_decisions
|
AFTER INSERT OR UPDATE OF status ON item_decisions
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
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?")
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.db.engine import get_db
|
|||||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||||
from app.services.expand_service import receipt_full
|
from app.services.expand_service import receipt_full
|
||||||
|
from app.services.quote_service import _fallback_confirmer_id
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -52,6 +53,7 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
|||||||
raise ConflictError("رسید این خط آیتم قبلاً ثبت شده است")
|
raise ConflictError("رسید این خط آیتم قبلاً ثبت شده است")
|
||||||
receipt = ItemReceipt(
|
receipt = ItemReceipt(
|
||||||
item_decision_id=decision.id,
|
item_decision_id=decision.id,
|
||||||
|
confirmed_by=await _fallback_confirmer_id(db),
|
||||||
received_at=payload.received_at,
|
received_at=payload.received_at,
|
||||||
received_qty=payload.received_qty,
|
received_qty=payload.received_qty,
|
||||||
on_time=payload.on_time,
|
on_time=payload.on_time,
|
||||||
|
|||||||
@@ -535,6 +535,18 @@ async def _decision_for_line_item(db: AsyncSession, line_item_id: UUID) -> ItemD
|
|||||||
return decision
|
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:
|
async def arrive_quote(db: AsyncSession, quote_id: UUID, actor: str | None = None) -> Quote:
|
||||||
"""Full-arrival shortcut: receipt for every non-rejected line item."""
|
"""Full-arrival shortcut: receipt for every non-rejected line item."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
@@ -551,7 +563,13 @@ async def arrive_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
|
|||||||
continue
|
continue
|
||||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||||
if existing is None:
|
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.status = "arrived"
|
||||||
quote.arrived_date = utcnow()
|
quote.arrived_date = utcnow()
|
||||||
@@ -587,6 +605,7 @@ async def arrive_items(db: AsyncSession, quote_id: UUID, payload: ArriveItemsIn,
|
|||||||
db.add(
|
db.add(
|
||||||
ItemReceipt(
|
ItemReceipt(
|
||||||
item_decision_id=decision.id,
|
item_decision_id=decision.id,
|
||||||
|
confirmed_by=await _fallback_confirmer_id(db),
|
||||||
received_at=item.received_at or utcnow(),
|
received_at=item.received_at or utcnow(),
|
||||||
received_qty=item.received_qty,
|
received_qty=item.received_qty,
|
||||||
on_time=item.on_time,
|
on_time=item.on_time,
|
||||||
|
|||||||
Reference in New Issue
Block a user