from __future__ import annotations from dataclasses import dataclass from datetime import datetime from decimal import Decimal, ROUND_FLOOR from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.models.master_data import Bom, BomItem, Item from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot from app.services.operations import to_decimal INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15") @dataclass class ProductionBatchLedgerSnapshot: production_ledger_id: int material_lot_no: str product_item_id: int material_item_id: int gross_weight_per_piece_kg: Decimal net_weight_per_piece_kg: Decimal total_issued_weight_kg: Decimal finished_inbound_qty: Decimal surplus_inbound_weight_kg: Decimal scrap_inbound_weight_kg: Decimal writeoff_weight_kg: Decimal outside_weight_kg: Decimal reference_finished_qty: Decimal used_material_weight_kg: Decimal theoretical_scrap_weight_kg: Decimal theoretical_surplus_weight_kg: Decimal def _round_weight(value: Decimal) -> Decimal: return to_decimal(value, "0.001") def _non_negative(value: Decimal) -> Decimal: return value if value > 0 else Decimal("0") def _append_txn( db: Session, *, ledger: ProductionBatchLedger, txn_type: str, qty_delta: Decimal = Decimal("0"), weight_delta_kg: Decimal = Decimal("0"), source_doc_type: str | None = None, source_doc_id: int | None = None, source_line_id: int | None = None, biz_time: datetime | None = None, operator_user_id: int | None = None, remark: str | None = None, ) -> ProductionBatchLedgerTxn: txn = ProductionBatchLedgerTxn( production_ledger_id=ledger.id, txn_type=txn_type, qty_delta=qty_delta, weight_delta_kg=weight_delta_kg, outside_weight_after_kg=to_decimal(ledger.outside_weight_kg), source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time or datetime.now(), operator_user_id=operator_user_id, remark=remark, ) db.add(txn) db.flush() return txn def _get_bom_weight_profile(db: Session, product_item_id: int, material_item_id: int) -> tuple[Decimal, Decimal]: row = db.execute( select( BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"), BomItem.net_weight_per_piece_kg.label("net_weight_per_piece_kg"), ) .join(Bom, Bom.id == BomItem.bom_id) .where( Bom.product_item_id == product_item_id, BomItem.material_item_id == material_item_id, Bom.status == "ACTIVE", ) .order_by(Bom.is_default.desc(), BomItem.seq_no, BomItem.id) .limit(1) ).mappings().first() gross = to_decimal(row["gross_weight_per_piece_kg"] if row else 0) net = to_decimal(row["net_weight_per_piece_kg"] if row else 0) if gross <= 0: product = db.get(Item, product_item_id) gross = to_decimal(product.unit_weight_kg if product else 0) if net <= 0 or net > gross: net = gross return gross, net def calculate_production_batch_ledger_snapshot( db: Session, production_ledger_id: int, *, smart_reporting_enabled: bool, selected_finished_qty: Decimal | None = None, ) -> ProductionBatchLedgerSnapshot: _ = smart_reporting_enabled ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") gross, net = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id) total_issued = to_decimal(ledger.total_issued_weight_kg) finished_qty = to_decimal(ledger.finished_inbound_qty) basis_qty = finished_qty + to_decimal(selected_finished_qty) reference_finished_qty = Decimal("0") if gross > 0: reference_finished_qty = (total_issued / gross).to_integral_value(rounding=ROUND_FLOOR) used_weight = _round_weight(basis_qty * gross) theoretical_scrap = _round_weight(_non_negative(basis_qty * _non_negative(gross - net) - to_decimal(ledger.scrap_inbound_weight_kg))) theoretical_surplus = _round_weight(_non_negative(total_issued - used_weight - to_decimal(ledger.surplus_inbound_weight_kg))) return ProductionBatchLedgerSnapshot( production_ledger_id=ledger.id, material_lot_no=ledger.material_lot_no, product_item_id=ledger.product_item_id, material_item_id=ledger.material_item_id, gross_weight_per_piece_kg=gross, net_weight_per_piece_kg=net, total_issued_weight_kg=total_issued, finished_inbound_qty=finished_qty, surplus_inbound_weight_kg=to_decimal(ledger.surplus_inbound_weight_kg), scrap_inbound_weight_kg=to_decimal(ledger.scrap_inbound_weight_kg), writeoff_weight_kg=to_decimal(ledger.writeoff_weight_kg), outside_weight_kg=to_decimal(ledger.outside_weight_kg), reference_finished_qty=reference_finished_qty, used_material_weight_kg=used_weight, theoretical_scrap_weight_kg=theoretical_scrap, theoretical_surplus_weight_kg=theoretical_surplus, ) def post_finished_inbound_to_ledger( db: Session, *, production_ledger_id: int, finished_qty: Decimal, operator_user_id: int | None, source_doc_type: str | None, source_doc_id: int | None, source_line_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: qty = to_decimal(finished_qty) if qty < 0: raise HTTPException(status_code=400, detail="成品入库数量不能小于0") ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") gross, _ = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id) used_weight = _round_weight(qty * gross) ledger.finished_inbound_qty = to_decimal(ledger.finished_inbound_qty) + qty ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - used_weight)) db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="成品入库", qty_delta=qty, weight_delta_kg=-used_weight, source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time, operator_user_id=operator_user_id, remark=remark or "成品入库联动生产台账", ) return ledger def post_surplus_inbound_to_ledger( db: Session, *, production_ledger_id: int, surplus_weight_kg: Decimal, operator_user_id: int | None, source_doc_type: str | None, source_doc_id: int | None, source_line_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: weight = _round_weight(to_decimal(surplus_weight_kg)) if weight < 0: raise HTTPException(status_code=400, detail="余料入库重量不能小于0") ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") ledger.surplus_inbound_weight_kg = _round_weight(to_decimal(ledger.surplus_inbound_weight_kg) + weight) ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight)) db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="生产余料入库", weight_delta_kg=-weight, source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time, operator_user_id=operator_user_id, remark=remark or "生产余料入库联动生产台账", ) return ledger def post_scrap_inbound_to_ledger( db: Session, *, production_ledger_id: int, scrap_weight_kg: Decimal, operator_user_id: int | None, source_doc_type: str | None, source_doc_id: int | None, source_line_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: weight = _round_weight(to_decimal(scrap_weight_kg)) if weight < 0: raise HTTPException(status_code=400, detail="废料入库重量不能小于0") ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") ledger.scrap_inbound_weight_kg = _round_weight(to_decimal(ledger.scrap_inbound_weight_kg) + weight) ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight)) db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="生产废料入库", weight_delta_kg=-weight, source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time, operator_user_id=operator_user_id, remark=remark or "生产废料入库联动生产台账", ) return ledger def _get_production_ledger( db: Session, *, material_lot_id: int, product_item_id: int, ) -> ProductionBatchLedger | None: return db.scalar( select(ProductionBatchLedger) .where( ProductionBatchLedger.material_lot_id == material_lot_id, ProductionBatchLedger.product_item_id == product_item_id, ) .with_for_update() ) def record_production_issue_to_ledger( db: Session, *, material_lot_id: int, product_item_id: int, issued_weight_kg: Decimal, operator_user_id: int | None, source_doc_type: str | None, source_doc_id: int | None, source_line_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: issued_weight = _round_weight(to_decimal(issued_weight_kg)) if issued_weight <= 0: raise HTTPException(status_code=400, detail="生产出库重量必须大于0") lot = db.get(StockLot, material_lot_id) if not lot: raise HTTPException(status_code=404, detail="材料库存批次号不存在") product = db.get(Item, product_item_id) if not product: raise HTTPException(status_code=404, detail="产品不存在") now = biz_time or datetime.now() ledger = _get_production_ledger(db, material_lot_id=material_lot_id, product_item_id=product_item_id) if ledger and str(ledger.status or "") == "锁单": ledger.status = "在生产" ledger.miniapp_selectable = 1 ledger.reopened_at = now db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="重新开工", source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=now, operator_user_id=operator_user_id, remark="锁单后再次生产出库,生产台账重新进入在生产状态", ) if not ledger: ledger = ProductionBatchLedger( material_lot_id=material_lot_id, material_lot_no=lot.lot_no, material_item_id=lot.item_id, product_item_id=product_item_id, status="在生产", miniapp_selectable=1, total_issued_weight_kg=Decimal("0"), outside_weight_kg=Decimal("0"), finished_inbound_qty=Decimal("0"), surplus_inbound_weight_kg=Decimal("0"), scrap_inbound_weight_kg=Decimal("0"), writeoff_weight_kg=Decimal("0"), first_issue_time=now, last_issue_time=now, remark=remark, ) db.add(ledger) db.flush() ledger.status = "在生产" ledger.miniapp_selectable = 1 ledger.total_issued_weight_kg = _round_weight(to_decimal(ledger.total_issued_weight_kg) + issued_weight) ledger.outside_weight_kg = _round_weight(to_decimal(ledger.outside_weight_kg) + issued_weight) ledger.last_issue_time = now if not ledger.first_issue_time: ledger.first_issue_time = now db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="生产出库", weight_delta_kg=issued_weight, source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=now, operator_user_id=operator_user_id, remark=remark or "生产出库累计到生产台账", ) return ledger def lock_production_batch_ledger( db: Session, *, production_ledger_id: int, operator_user_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") now = biz_time or datetime.now() remaining = _round_weight(to_decimal(ledger.outside_weight_kg)) if remaining > 0: ledger.writeoff_weight_kg = _round_weight(to_decimal(ledger.writeoff_weight_kg) + remaining) ledger.outside_weight_kg = Decimal("0") db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="结单核销", weight_delta_kg=-remaining, biz_time=now, operator_user_id=operator_user_id, remark=remark or "该批材料结单时确认库外剩余重量已无现场存量", ) ledger.status = "锁单" ledger.miniapp_selectable = 0 ledger.locked_at = now ledger.lock_count = int(ledger.lock_count or 0) + 1 db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="该批材料结单", biz_time=now, operator_user_id=operator_user_id, remark=remark or "该批材料已结单,小程序不再显示该材料库存批次号", ) return ledger