ForgeFlow-ERP/backend/app/services/production_batch_ledger.py
2026-06-12 16:00:56 +08:00

407 lines
14 KiB
Python

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