407 lines
14 KiB
Python
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
|