ForgeFlow-ERP/backend/app/api/routes/production.py
2026-06-12 16:00:56 +08:00

2684 lines
115 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_FLOOR
import re
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, aliased
from app.db.session import get_db
from app.models.document_archive import DocumentArchive
from app.models.master_data import BomItem, Item, Warehouse
from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem
from app.models.operations import (
CompletionReceipt,
CompletionReceiptItem,
Equipment,
InventoryTxn,
OperationReport,
ProcessRouteOperation,
ProductionBatchLedger,
ProductionBatchLedgerTxn,
ReturnDisposition,
ReturnItem,
ScrapRecord,
StockLot,
WorkOrder,
WorkOrderMaterial,
WorkOrderMaterialIssue,
WorkOrderOperation,
)
from app.models.org import Department, Employee
from app.models.sales import SalesOrderItem
from app.schemas.operations import (
CompletionReceiptCreate,
CompletionReceiptItemRead,
CompletionReceiptRead,
MiniAppOperationReportRead,
MiniAppOperationReportSyncRead,
MiniAppOperationReportSyncStateRead,
MiniAppScrapRecordCreate,
OperationReportCreate,
OperationReportRead,
ProductionBatchLedgerRead,
ProductionBatchLedgerInboundCreate,
ProductionBatchLedgerInboundPreviewRead,
ProductionBatchLedgerInboundRead,
ProductionWorkOrderInboundCreate,
ProductionWorkOrderInboundPreviewRead,
ProductionWorkOrderInboundRead,
ScrapRecordCreate,
ScrapRecordRead,
WorkOrderCreate,
WorkOrderLedgerRead,
WorkOrderMaterialIssueRead,
WorkOrderMaterialRead,
WorkOrderOperationRead,
WorkOrderRead,
)
from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.auth import AuthContext, require_authenticated_user
from app.services.auth import next_employee_code
from app.services.document_archives import (
ARCHIVE_STATUS_MISSING,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
FILE_FORMAT_PDF,
generate_document_archive,
production_document_batch_no_from_text,
)
from app.services.operations import (
build_raw_lot_product_work_order_no,
build_yearly_product_work_order_no,
create_inventory_txn,
create_work_order_materials_and_operations,
clamp_non_negative,
get_completion_receipt_items_query,
get_completion_receipts_query,
get_default_bom,
get_default_route,
get_location_or_default,
get_operation_reports_query,
get_scrap_records_query,
get_work_order_materials_query,
get_work_order_material_issues_query,
get_work_order_operations_query,
get_work_orders_query,
issue_selected_stock_lots,
sync_recent_work_order_reference_qty,
sync_work_order_reference_qty,
sync_work_order_status,
to_decimal,
upsert_stock_balance,
)
from app.services.system_permissions import ensure_employee_has_permission
from app.services.system_config import (
get_miniapp_operation_report_last_sync,
lock_miniapp_operation_report_last_sync,
upsert_miniapp_operation_report_last_sync,
)
from app.services.production_batch_ledger import (
calculate_production_batch_ledger_snapshot,
lock_production_batch_ledger,
post_finished_inbound_to_ledger,
post_scrap_inbound_to_ledger,
post_surplus_inbound_to_ledger,
record_production_issue_to_ledger,
)
from app.services.production_work_order_ledger import (
INBOUND_TOLERANCE_MULTIPLIER,
get_work_order_ledger_rows,
get_work_order_limit_snapshot,
validate_inbound_with_tolerance,
)
from app.services.production_work_order_inbound import (
create_production_work_order_inbound,
preview_production_work_order_inbound,
)
from app.services.stocktake import ensure_warehouses_unlocked
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
_MINIAPP_WORK_ORDER_SHORT_CODE_RE = re.compile(r"^JH(\d{4})$")
PRODUCTION_LEDGER_INBOUND_TXN_TYPES = {"成品入库", "生产余料入库", "生产废料入库"}
def _archive_fields_from_result(
result: DocumentArchiveGenerateResult | None,
*,
document_type: str,
business_id: int | None,
) -> dict[str, object]:
if result is None:
return {
"archive_status": "未生成",
"archive_business_id": business_id,
"archive_document_type": document_type if business_id else None,
"archive_error_message": None,
}
return {
"archive_status": result.archive_status,
"archive_business_id": business_id,
"archive_document_type": document_type,
"archive_error_message": result.archive_error_message,
}
def _failed_archive_result(document_type: str, business_id: int, document_no: str, exc: Exception) -> DocumentArchiveGenerateResult:
_ = exc
return DocumentArchiveGenerateResult(
business_id=business_id,
document_type=document_type,
document_no=document_no,
archive_status="归档失败",
archive_version=None,
archive_error_message="归档失败PDF归档服务异常请稍后重新生成",
)
def _production_document_batch_no(now: datetime) -> str:
return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}"
def _with_document_batch_no(remark: str, batch_no: str | None) -> str:
if not batch_no:
return remark
return f"{remark};单据批次号:{batch_no}"
def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]:
if not business_ids:
return {}
latest_version_subquery = (
select(
DocumentArchive.business_id.label("business_id"),
func.max(DocumentArchive.archive_version).label("archive_version"),
)
.where(
DocumentArchive.document_type == document_type,
DocumentArchive.file_format == FILE_FORMAT_PDF,
DocumentArchive.business_id.in_(business_ids),
)
.group_by(DocumentArchive.business_id)
.subquery()
)
latest_id_subquery = (
select(
DocumentArchive.business_id.label("business_id"),
func.max(DocumentArchive.id).label("archive_id"),
)
.join(
latest_version_subquery,
(latest_version_subquery.c.business_id == DocumentArchive.business_id)
& (latest_version_subquery.c.archive_version == DocumentArchive.archive_version),
)
.where(
DocumentArchive.document_type == document_type,
DocumentArchive.file_format == FILE_FORMAT_PDF,
)
.group_by(DocumentArchive.business_id)
.subquery()
)
rows = db.scalars(
select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id)
).all()
return {int(row.business_id): row for row in rows}
def _code_token(value: str | None, fallback: str, max_length: int = 16) -> str:
token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
return (token or fallback)[-max_length:]
def _next_doc_no(db: Session, model, column_name: str, prefix: str) -> str:
column = getattr(model, column_name)
count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def _next_optional_sequence_doc_no(db: Session, model, column_name: str, prefix: str, max_length: int = 80) -> str:
normalized_prefix = " ".join(str(prefix or "").split()).strip("- ") or "未命名"
sequence_prefix = normalized_prefix[: max(max_length - 4, 1)]
column = getattr(model, column_name)
if not db.scalar(select(model.id).where(column == sequence_prefix).limit(1)):
return sequence_prefix
count = db.scalar(select(func.count(model.id)).where(column.like(f"{sequence_prefix}-%"))) or 0
return f"{sequence_prefix}-{count + 1:03d}"
def _next_yearly_sequence_work_order_no(db: Session, product: Item, now: datetime | None = None) -> str:
current_time = now or datetime.now()
year_part = current_time.strftime("%Y")
existing_nos = db.scalars(select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%"))).all()
used_sequences = [
int(work_order_no[4:8])
for work_order_no in existing_nos
if len(work_order_no) > 8 and work_order_no[:4] == year_part and work_order_no[4:8].isdigit() and work_order_no[8] == "-"
]
next_sequence = max(used_sequences, default=0) + 1
product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}"
max_product_name_length = max(80 - len(year_part) - 4 - 1, 1)
return f"{year_part}{next_sequence:04d}-{product_name[:max_product_name_length]}"
def _build_work_order_no(db: Session, product: Item) -> str:
return build_yearly_product_work_order_no(db, product)
def _miniapp_work_order_short_code_sequence(value: str | None) -> int | None:
match = _MINIAPP_WORK_ORDER_SHORT_CODE_RE.match(str(value or "").strip())
if not match:
return None
return int(match.group(1))
def _is_miniapp_work_order_short_code(value: str | None) -> bool:
return _miniapp_work_order_short_code_sequence(value) is not None
def _miniapp_syncable_work_order_status_filter():
return func.upper(func.coalesce(WorkOrder.status, "")).notin_(("SETTLED", "CANCELED", "CANCELLED"))
def _resolve_work_order_by_miniapp_short_code(db: Session, short_code: str, row: dict) -> WorkOrder | None:
sequence = _miniapp_work_order_short_code_sequence(short_code)
if sequence is None:
return None
product_name = str(row.get("product_name") or "").strip()
project_no = str(row.get("project_no") or "").strip()
product_filters = []
if product_name:
product_filters.append(Item.item_name == product_name)
if project_no:
product_filters.append(Item.item_code == project_no)
if not product_filters:
return None
year_part = datetime.now().strftime("%Y")
work_order_prefix = f"{year_part}{sequence:04d}-"
candidates = db.scalars(
select(WorkOrder)
.join(Item, Item.id == WorkOrder.product_item_id)
.where(
WorkOrder.work_order_no.like(f"{work_order_prefix}%"),
_miniapp_syncable_work_order_status_filter(),
or_(*product_filters),
)
.order_by(WorkOrder.id.desc())
).all()
if len(candidates) == 1:
return candidates[0]
return None
def _build_scrap_no(db: Session, work_order: WorkOrder, disposal_type: str) -> str:
date_part = date.today().strftime("%Y%m%d")
type_part = "RWK" if disposal_type == "REWORK" else "SCRAP"
work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18)
return _next_doc_no(db, ScrapRecord, "scrap_no", f"DSP-{type_part}-{date_part}-{work_order_part}")
def _build_completion_receipt_no(db: Session, work_order: WorkOrder) -> str:
date_part = date.today().strftime("%Y%m%d")
work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18)
return _next_doc_no(db, CompletionReceipt, "receipt_no", f"FGI-NB-{date_part}-{work_order_part}")
def _build_operation_report_no(db: Session, work_order: WorkOrder, operation: WorkOrderOperation, employee: Employee) -> str:
date_part = date.today().strftime("%Y%m%d")
work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 12)
operation_part = f"OP{int(operation.seq_no or 0):02d}"
employee_part = _code_token(employee.employee_code or employee.employee_name, f"EMP{employee.id:04d}", 8)
return _next_doc_no(db, OperationReport, "report_no", f"RPT-NB-{date_part}-{work_order_part}-{operation_part}-{employee_part}")
def _production_ledger_txn_read(
txn: ProductionBatchLedgerTxn,
archive: DocumentArchive | None = None,
archive_business_id: int | None = None,
) -> dict:
return {
"production_ledger_txn_id": txn.id,
"production_ledger_id": txn.production_ledger_id,
"txn_type": txn.txn_type,
"qty_delta": float(to_decimal(txn.qty_delta)),
"weight_delta_kg": float(to_decimal(txn.weight_delta_kg)),
"outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)),
"source_doc_type": txn.source_doc_type,
"source_doc_id": txn.source_doc_id,
"source_line_id": txn.source_line_id,
"biz_time": txn.biz_time,
"operator_user_id": txn.operator_user_id,
"remark": txn.remark,
"archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None),
"archive_business_id": archive_business_id,
"archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None,
"archive_error_message": archive.error_message if archive else None,
}
def _build_finished_lot_no(db: Session, work_order: WorkOrder, product: Item, receipt_time: datetime, line_no: int) -> str:
date_part = receipt_time.strftime("%Y%m%d")
product_part = _code_token(product.item_code or product.item_name, f"FG{product.id:04d}", 12)
work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10)
base_lot_no = f"FGL-NB-{date_part}-{product_part}-{work_order_part}-L{line_no:02d}"
candidate = base_lot_no
suffix = 1
while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)):
suffix += 1
candidate = f"{base_lot_no}-{suffix:02d}"
return candidate
def _get_active_warehouse(db: Session, warehouse_type: str, operation_name: str) -> Warehouse:
warehouse = db.scalar(
select(Warehouse)
.where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE")
.order_by(Warehouse.id)
.limit(1)
)
if not warehouse:
warehouse_name = {"FINISHED": "成品库", "SCRAP": "废料库", "RAW": "原材料库"}.get(warehouse_type, "目标仓库")
raise HTTPException(status_code=400, detail=f"{operation_name}缺少启用的{warehouse_name}")
return warehouse
def _build_production_ledger_lot_no(db: Session, prefix: str, ledger: ProductionBatchLedger, item: Item, biz_time: datetime) -> str:
item_part = _code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12)
source_part = _code_token(ledger.material_lot_no, f"LOT{ledger.material_lot_id:04d}", 10)
return _next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{biz_time.strftime('%Y%m%d')}-{item_part}-{source_part}")
def _issue_raw_lot_to_production_ledger(
db: Session,
*,
lot: StockLot,
material_item_id: int,
issued_weight: Decimal,
production_ledger_id: int,
operator_user_id: int | None,
biz_time: datetime,
) -> InventoryTxn:
if issued_weight <= 0:
raise HTTPException(status_code=400, detail="出库重量必须大于0")
if not lot or int(lot.item_id) != int(material_item_id):
raise HTTPException(status_code=400, detail="库存批次不存在或物料不匹配")
warehouse = db.get(Warehouse, lot.warehouse_id)
if not warehouse or warehouse.warehouse_type != "RAW":
raise HTTPException(status_code=400, detail="库存批次不属于原材料库")
usable_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg))
if lot.quality_status != "PASS" or lot.status != "AVAILABLE" or usable_weight <= 0:
raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不可用")
if issued_weight > usable_weight:
raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 剩余重量不足")
lot.remaining_qty = Decimal("0")
lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - issued_weight)
if to_decimal(lot.remaining_weight_kg) <= 0:
lot.status = "DEPLETED"
db.add(lot)
unit_cost = to_decimal(lot.unit_cost, "0.0001")
upsert_stock_balance(
db,
item_id=material_item_id,
warehouse_id=lot.warehouse_id,
location_id=lot.location_id,
qty_delta=Decimal("0"),
weight_delta=-issued_weight,
available_qty_delta=Decimal("0"),
available_weight_delta=-issued_weight,
unit_cost=unit_cost,
)
txn = create_inventory_txn(
db,
txn_type="MATERIAL_ISSUE",
item_id=material_item_id,
warehouse_id=lot.warehouse_id,
location_id=lot.location_id,
lot_id=lot.id,
qty_change=Decimal("0"),
weight_change=-issued_weight,
unit_cost=unit_cost,
source_doc_type="生产台账",
source_doc_id=production_ledger_id,
source_line_id=None,
biz_time=biz_time,
operator_user_id=operator_user_id,
remark=f"生产出库:库存批次号 {lot.lot_no}",
amount_basis="WEIGHT",
)
db.add(txn)
db.flush()
return txn
def _link_latest_issue_ledger_txn_to_inventory_txn(
db: Session,
*,
production_ledger_id: int,
inventory_txn: InventoryTxn,
) -> None:
ledger_txn = db.scalar(
select(ProductionBatchLedgerTxn)
.where(
ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id,
ProductionBatchLedgerTxn.txn_type == "生产出库",
)
.order_by(ProductionBatchLedgerTxn.id.desc())
.limit(1)
)
if not ledger_txn:
return
ledger_txn.source_doc_type = "库存流水"
ledger_txn.source_doc_id = inventory_txn.id
ledger_txn.source_line_id = inventory_txn.id
db.add(ledger_txn)
def _build_production_ledger_work_order_compat_read(
*,
production_ledger_id: int,
material_lot_no: str,
product: Item,
route,
bom,
planned_qty: Decimal,
payload: WorkOrderCreate,
biz_time: datetime,
status: str,
operation_count: int,
) -> WorkOrderRead:
return WorkOrderRead(
work_order_id=production_ledger_id,
work_order_no=material_lot_no,
work_order_type="正常生产",
source_sales_order_item_id=payload.source_sales_order_item_id,
source_order_no=None,
product_item_id=product.id,
product_code=product.item_code,
product_name=product.item_name,
product_version_no=None,
route_id=route.id,
route_name=route.route_name,
bom_id=bom.id,
planned_qty=float(planned_qty),
released_qty=float(planned_qty),
finished_qty=0,
scrap_qty=0,
planned_start_time=payload.planned_start_time or biz_time,
planned_end_time=payload.planned_end_time,
actual_start_time=None,
actual_end_time=None,
priority_level=payload.priority_level or 3,
status=status,
remark=payload.remark,
operation_count=operation_count,
abnormal_operation_count=0,
reported_good_qty=0,
received_qty=0,
max_receivable_qty=float(planned_qty),
all_operations_done=False,
)
def _calculate_actual_cycle_seconds(start_time: datetime, end_time: datetime, report_qty: Decimal) -> float | None:
if report_qty <= 0 or end_time <= start_time:
return None
return round((end_time - start_time).total_seconds() / float(report_qty), 2)
def _build_operation_abnormal_reason(row: dict, actual_cycle_seconds: float | None) -> str | None:
reasons: list[str] = []
report_qty = to_decimal(row.get("report_qty"))
scrap_qty = to_decimal(row.get("scrap_qty"))
allowed_scrap_rate = to_decimal(row.get("allowed_scrap_rate"), "0.0001")
ref_cycle_seconds = to_decimal(row.get("ref_cycle_seconds"), "0.01")
if report_qty > 0 and allowed_scrap_rate >= 0:
actual_scrap_rate = scrap_qty / report_qty if report_qty > 0 else Decimal("0")
if actual_scrap_rate > allowed_scrap_rate:
reasons.append(
f"报废率超标 {round(float(actual_scrap_rate * 100), 2)}% > {round(float(allowed_scrap_rate * 100), 2)}%"
)
if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds:
reasons.append(f"实际节拍 {actual_cycle_seconds}s/件 慢于参考节拍 {float(ref_cycle_seconds):.2f}s/件")
scrap_reason = row.get("scrap_reason")
if scrap_reason:
reasons.append(f"异常说明:{scrap_reason}")
if not reasons:
return None
return "".join(reasons)
def _build_operation_report_read(row: dict) -> OperationReportRead:
payload = dict(row)
actual_cycle_seconds = _calculate_actual_cycle_seconds(
payload["start_time"],
payload["end_time"],
to_decimal(payload.get("report_qty")),
)
payload["ref_cycle_seconds"] = float(payload.get("ref_cycle_seconds") or 0)
payload["actual_cycle_seconds"] = actual_cycle_seconds
payload["abnormal_reason"] = _build_operation_abnormal_reason(payload, actual_cycle_seconds)
return OperationReportRead.model_validate(payload)
def _get_average_issued_weight_cost(db: Session, work_order_id: int) -> Decimal:
row = db.execute(
select(
func.coalesce(func.sum(InventoryTxn.amount), 0).label("total_amount"),
func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("total_weight"),
).where(
InventoryTxn.txn_type == "MATERIAL_ISSUE",
InventoryTxn.source_doc_type == "WORK_ORDER",
InventoryTxn.source_doc_id == work_order_id,
)
).mappings().first()
if not row:
return Decimal("0")
total_amount = to_decimal(row["total_amount"], "0.01")
total_weight = abs(to_decimal(row["total_weight"]))
if total_weight <= 0:
return Decimal("0")
return total_amount / total_weight
def _get_active_scrap_record(db: Session, report_id: int) -> ScrapRecord | None:
return db.scalar(
select(ScrapRecord)
.where(
ScrapRecord.report_id == report_id,
ScrapRecord.status == "CONFIRMED",
)
.order_by(ScrapRecord.id.desc())
.limit(1)
)
def _revert_scrap_record_effect(work_order: WorkOrder, record: ScrapRecord) -> None:
if record.disposal_type == "SCRAP":
work_order.scrap_qty = max(Decimal("0"), to_decimal(work_order.scrap_qty) - to_decimal(record.scrap_qty))
MINIAPP_REPORT_STATUS_LABELS = {
"pending": "待审核",
"approved": "已审核",
"rejected": "已驳回",
}
def _miniapp_report_no(report_id: int, item_id: int) -> str:
return f"WRS-{report_id}-{item_id}"
def _miniapp_effective_scrap_qty(row: dict) -> Decimal:
scrap_qty = to_decimal(row.get("miniapp_scrap_qty"))
defect_qty = to_decimal(row.get("defect_qty"))
return scrap_qty if scrap_qty > 0 else defect_qty
def _new_miniapp_resolve_cache() -> dict[str, dict]:
return {
"production_ledger": {},
"work_order": {},
"operation": {},
"employee": {},
"report": {},
}
def _miniapp_report_item_rows(
db: Session,
*,
limit: int | None = 300,
source_item_id: int | None = None,
start_date: date | None = None,
end_date: date | None = None,
updated_after: datetime | None = None,
updated_until: datetime | None = None,
order_by_updated_asc: bool = False,
) -> list[dict]:
stmt = (
select(
MiniAppProductionReport.id.label("source_report_id"),
MiniAppProductionReportItem.id.label("source_item_id"),
func.coalesce(
MiniAppProductionReportItem.attendance_point_name,
MiniAppProductionReport.attendance_point_name,
).label("attendance_point_name"),
MiniAppProductionReport.employee_phone.label("employee_phone"),
MiniAppPersonnel.name.label("employee_name"),
MiniAppProductionReport.report_date.label("report_date"),
MiniAppProductionReport.start_at.label("start_time"),
MiniAppProductionReport.end_at.label("end_time"),
MiniAppProductionReport.duration_minutes.label("duration_minutes"),
MiniAppProductionReport.effective_minutes.label("effective_minutes"),
MiniAppProductionReport.status.label("status"),
MiniAppProductionReport.reviewer_phone.label("reviewer_phone"),
MiniAppProductionReport.reviewed_at.label("reviewed_at"),
MiniAppProductionReport.reject_reason.label("reject_reason"),
MiniAppProductionReportItem.device_no.label("device_no"),
MiniAppProductionReportItem.project_no.label("project_no"),
MiniAppProductionReportItem.product_name.label("product_name"),
MiniAppProductionReportItem.material_code.label("material_code"),
MiniAppProductionReportItem.material_name.label("material_name"),
MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"),
MiniAppProductionReportItem.process_name.label("process_name"),
MiniAppProductionReportItem.stamping_method.label("stamping_method"),
MiniAppProductionReportItem.standard_beat.label("standard_beat"),
MiniAppProductionReportItem.standard_workload.label("standard_workload"),
MiniAppProductionReportItem.good_qty.label("good_qty"),
MiniAppProductionReportItem.defect_qty.label("defect_qty"),
MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"),
MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"),
MiniAppProductionReportItem.started_at.label("started_at"),
)
.join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id)
.outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone)
)
if source_item_id is not None:
stmt = stmt.where(MiniAppProductionReportItem.id == source_item_id)
if start_date is not None:
stmt = stmt.where(MiniAppProductionReport.report_date >= start_date)
if end_date is not None:
stmt = stmt.where(MiniAppProductionReport.report_date <= end_date)
if updated_after is not None:
stmt = stmt.where(MiniAppProductionReport.updated_at > updated_after)
if updated_until is not None:
stmt = stmt.where(MiniAppProductionReport.updated_at <= updated_until)
if order_by_updated_asc:
stmt = stmt.order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc())
else:
stmt = stmt.order_by(MiniAppProductionReportItem.id.desc())
if limit is not None:
stmt = stmt.limit(limit)
rows = db.execute(stmt).mappings().all()
return [dict(row) for row in rows]
def _preload_miniapp_operation_reports(db: Session, report_nos: list[str], cache: dict[str, dict]) -> None:
if not report_nos:
return
unique_report_nos = list(dict.fromkeys(report_nos))
existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(unique_report_nos))).all()
cache["report"].update({report_no: None for report_no in unique_report_nos})
cache["report"].update({report.report_no: report for report in existing_reports})
def _get_miniapp_report_item_row(db: Session, source_item_id: int) -> dict:
rows = _miniapp_report_item_rows(db, limit=1, source_item_id=source_item_id)
if rows:
return rows[0]
raise HTTPException(status_code=404, detail="小程序报工明细不存在")
def _miniapp_report_matches_target(
report: OperationReport | None,
work_order: WorkOrder | None,
operation: WorkOrderOperation | None,
) -> bool:
return bool(
report
and work_order
and operation
and int(report.work_order_id) == int(work_order.id)
and int(report.work_order_operation_id) == int(operation.id)
)
def _miniapp_report_matches_production_ledger(
report: OperationReport | None,
production_ledger: ProductionBatchLedger | None,
) -> bool:
return bool(
report
and production_ledger
and report.production_ledger_id
and int(report.production_ledger_id) == int(production_ledger.id)
)
def _unlink_stale_miniapp_report(
db: Session,
report: OperationReport | None,
*,
cache: dict[str, dict] | None = None,
) -> None:
if not report:
return
scrap_count = db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.report_id == report.id)) or 0
if scrap_count:
# Do not delete a report already used by disposal records. It will be hidden from miniapp sync state
# by strict matching, and manual correction can handle the dependent disposal data if needed.
return
operation = db.get(WorkOrderOperation, report.work_order_operation_id) if report.work_order_operation_id else None
work_order_id = int(report.work_order_id) if report.work_order_id else None
if operation:
operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) - to_decimal(report.report_qty))
operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) - to_decimal(report.good_qty))
operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) - to_decimal(report.scrap_qty))
operation.rework_qty = max(Decimal("0"), to_decimal(operation.rework_qty) - to_decimal(report.rework_qty))
report_qty = to_decimal(operation.report_qty)
planned_qty = to_decimal(operation.planned_qty)
if planned_qty > 0 and report_qty >= planned_qty:
operation.status = "DONE"
elif report_qty > 0:
operation.status = "IN_PROGRESS"
else:
operation.status = "PENDING"
operation.is_abnormal = 1 if report_qty > 0 and (
to_decimal(operation.scrap_qty) / report_qty
) > to_decimal(operation.allowed_scrap_rate, "0.0001") else 0
db.add(operation)
report_no = report.report_no
db.delete(report)
db.flush()
if cache is not None:
cache["report"][report_no] = None
if work_order_id:
sync_work_order_status(db, work_order_id)
def _select_miniapp_issue_candidate(
db: Session,
candidates: list[WorkOrderMaterialIssue],
row: dict,
) -> WorkOrderMaterialIssue | None:
unsettled_work_order_ids = {
work_order_id
for (work_order_id,) in db.execute(
select(WorkOrder.id).where(
WorkOrder.id.in_({issue.work_order_id for issue in candidates}),
_miniapp_syncable_work_order_status_filter(),
)
).all()
}
latest_by_work_order: dict[int, WorkOrderMaterialIssue] = {}
for issue in candidates:
if issue.work_order_id not in unsettled_work_order_ids:
continue
latest_by_work_order.setdefault(issue.work_order_id, issue)
if len(latest_by_work_order) == 1:
return next(iter(latest_by_work_order.values()))
product_name = str(row.get("product_name") or "").strip()
project_no = str(row.get("project_no") or "").strip()
if not product_name and not project_no:
return None
matched_work_order_ids: set[int] = set()
product_rows = db.execute(
select(WorkOrder.id, Item.item_name, Item.item_code)
.join(Item, Item.id == WorkOrder.product_item_id)
.where(
WorkOrder.id.in_(latest_by_work_order),
_miniapp_syncable_work_order_status_filter(),
)
).all()
for work_order_id, item_name, item_code in product_rows:
if product_name and str(item_name or "").strip() == product_name:
matched_work_order_ids.add(work_order_id)
if project_no and str(item_code or "").strip() == project_no:
matched_work_order_ids.add(work_order_id)
if len(matched_work_order_ids) == 1:
return latest_by_work_order[next(iter(matched_work_order_ids))]
return None
def _resolve_work_order_issue_by_inventory_lot_no(
db: Session,
lot_no: str,
row: dict,
) -> tuple[WorkOrderMaterialIssue | None, bool]:
candidates = db.scalars(
select(WorkOrderMaterialIssue)
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
.join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
.where(
StockLot.lot_no == lot_no,
_miniapp_syncable_work_order_status_filter(),
)
.order_by(WorkOrderMaterialIssue.id.desc())
).all()
if candidates:
return _select_miniapp_issue_candidate(db, list(candidates), row), True
has_filtered_source_lot_candidate = bool(db.scalar(
select(func.count(WorkOrderMaterialIssue.id))
.join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
.where(StockLot.lot_no == lot_no)
) or 0)
candidates = db.scalars(
select(WorkOrderMaterialIssue)
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
.where(
WorkOrderMaterialIssue.material_sub_batch_no == lot_no,
_miniapp_syncable_work_order_status_filter(),
)
.order_by(WorkOrderMaterialIssue.id.desc())
).all()
if candidates:
return _select_miniapp_issue_candidate(db, list(candidates), row), True
has_filtered_sub_batch_candidate = bool(db.scalar(
select(func.count(WorkOrderMaterialIssue.id)).where(WorkOrderMaterialIssue.material_sub_batch_no == lot_no)
) or 0)
if has_filtered_source_lot_candidate or has_filtered_sub_batch_candidate:
return None, True
return None, False
def _find_work_order_for_miniapp_item(db: Session, row: dict, cache: dict[str, dict] | None = None) -> WorkOrder | None:
batch_no = str(row.get("raw_material_batch_no") or "").strip()
product_name = str(row.get("product_name") or "").strip()
project_no = str(row.get("project_no") or "").strip()
cache_key = ("batch", batch_no, product_name, project_no)
if cache is not None and cache_key in cache["work_order"]:
return cache["work_order"][cache_key]
if not batch_no:
if cache is not None:
cache["work_order"][cache_key] = None
return None
issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row)
work_order = db.get(WorkOrder, issue.work_order_id) if issue else None
if not work_order and _is_miniapp_work_order_short_code(batch_no):
work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row)
if not work_order and not has_issue_candidates:
work_order = db.scalar(
select(WorkOrder)
.where(
WorkOrder.work_order_no == batch_no,
_miniapp_syncable_work_order_status_filter(),
)
.limit(1)
)
if work_order:
if cache is not None:
cache["work_order"][cache_key] = work_order
return work_order
if cache is not None:
cache["work_order"][cache_key] = work_order
return work_order
def _find_production_ledger_for_miniapp_item(
db: Session,
row: dict,
cache: dict[str, dict] | None = None,
) -> ProductionBatchLedger | None:
batch_no = str(row.get("raw_material_batch_no") or "").strip()
product_name = str(row.get("product_name") or "").strip()
project_no = str(row.get("project_no") or "").strip()
cache_key = ("production_ledger", batch_no, product_name, project_no)
if cache is not None and cache_key in cache["production_ledger"]:
return cache["production_ledger"][cache_key]
if not batch_no or (not product_name and not project_no):
if cache is not None:
cache["production_ledger"][cache_key] = None
return None
product_filters = []
if product_name:
product_filters.append(Item.item_name == product_name)
if project_no:
product_filters.append(Item.item_code == project_no)
ledger = db.scalar(
select(ProductionBatchLedger)
.join(Item, Item.id == ProductionBatchLedger.product_item_id)
.where(
ProductionBatchLedger.material_lot_no == batch_no,
ProductionBatchLedger.status == "在生产",
ProductionBatchLedger.miniapp_selectable == 1,
or_(*product_filters),
)
.order_by(ProductionBatchLedger.id.desc())
.limit(1)
)
if cache is not None:
cache["production_ledger"][cache_key] = ledger
return ledger
def _find_work_order_operation_for_miniapp_item(
db: Session,
work_order_id: int,
process_name: str | None,
cache: dict[str, dict] | None = None,
*,
allow_fallback: bool = True,
) -> WorkOrderOperation | None:
normalized_process = str(process_name or "").strip()
cache_key = (work_order_id, normalized_process, allow_fallback)
if cache is not None and cache_key in cache["operation"]:
return cache["operation"][cache_key]
operation = None
if normalized_process:
operation = db.scalar(
select(WorkOrderOperation)
.where(
WorkOrderOperation.work_order_id == work_order_id,
WorkOrderOperation.operation_name == normalized_process,
)
.order_by(WorkOrderOperation.seq_no)
.limit(1)
)
if operation:
if cache is not None:
cache["operation"][cache_key] = operation
return operation
if not allow_fallback:
if cache is not None:
cache["operation"][cache_key] = None
return None
operation = db.scalar(
select(WorkOrderOperation)
.where(WorkOrderOperation.work_order_id == work_order_id)
.order_by(WorkOrderOperation.seq_no)
.limit(1)
)
if cache is not None:
cache["operation"][cache_key] = operation
return operation
def _get_or_create_miniapp_employee(db: Session, row: dict, cache: dict[str, dict] | None = None) -> Employee:
phone = str(row.get("employee_phone") or "").strip()
employee_name = str(row.get("employee_name") or "").strip() or phone or "小程序员工"
cache_key = phone or f"name:{employee_name}"
if cache is not None and cache_key in cache["employee"]:
return cache["employee"][cache_key]
if phone:
employee = db.scalar(select(Employee).where(Employee.mobile == phone).order_by(Employee.id.desc()).limit(1))
if employee:
if employee.employee_name != employee_name:
employee.employee_name = employee_name
db.add(employee)
if cache is not None:
cache["employee"][cache_key] = employee
return employee
department = db.scalar(select(Department).order_by(Department.id).limit(1))
if not department:
raise HTTPException(status_code=400, detail="ERP 未配置部门,无法把小程序报工员工映射到 ERP 员工档案")
employee = Employee(
employee_code=next_employee_code(db),
employee_name=employee_name,
dept_id=department.id,
mobile=phone or None,
gender=None,
hire_date=None,
job_title="小程序报工员工",
shift_code=None,
manager_employee_id=None,
is_operator=1,
is_workshop_staff=1,
status="ACTIVE",
remark="由小程序报工同步生成",
)
db.add(employee)
db.flush()
if cache is not None:
cache["employee"][cache_key] = employee
return employee
def _sync_miniapp_item_to_erp(
db: Session,
source_item_id: int,
*,
required: bool = False,
row: dict | None = None,
cache: dict[str, dict] | None = None,
sync_status: bool = True,
) -> OperationReport | None:
if row is None:
row = _get_miniapp_report_item_row(db, source_item_id)
report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"]))
if cache is not None and report_no in cache["report"]:
report = cache["report"][report_no]
else:
report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1))
if cache is not None:
cache["report"][report_no] = report
if str(row.get("status") or "") == "rejected":
_unlink_stale_miniapp_report(db, report, cache=cache)
if required:
raise HTTPException(status_code=400, detail="该小程序报工已驳回,不能同步为 ERP 报工")
return None
production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache)
work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache)
if not work_order:
if not production_ledger:
_unlink_stale_miniapp_report(db, report, cache=cache)
if required:
raise HTTPException(status_code=400, detail="小程序报工缺少有效库存批次号,不能同步为 ERP 报工")
return None
operation = None
if work_order:
operation = _find_work_order_operation_for_miniapp_item(
db,
work_order.id,
row.get("process_name"),
cache,
allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")),
)
if not operation:
_unlink_stale_miniapp_report(db, report, cache=cache)
if required:
raise HTTPException(status_code=400, detail="该库存批次缺少可匹配的小程序报工工序")
return None
employee = _get_or_create_miniapp_employee(db, row, cache)
start_time = row.get("started_at") or row.get("start_time") or datetime.now()
end_time = row.get("end_time") or start_time + timedelta(minutes=float(row.get("allocated_minutes") or 0) or 1)
if end_time <= start_time:
end_time = start_time + timedelta(minutes=1)
good_qty = to_decimal(row.get("good_qty"))
scrap_qty = _miniapp_effective_scrap_qty(row)
report_qty = good_qty + scrap_qty
if report_qty <= 0:
if required:
raise HTTPException(status_code=400, detail="该小程序报工没有有效产出或报废数量")
return None
if production_ledger:
if report and not _miniapp_report_matches_production_ledger(report, production_ledger):
_unlink_stale_miniapp_report(db, report, cache=cache)
report = None
elif report and not _miniapp_report_matches_target(report, work_order, operation):
_unlink_stale_miniapp_report(db, report, cache=cache)
report = None
old_report_qty = to_decimal(report.report_qty) if report else Decimal("0")
old_good_qty = to_decimal(report.good_qty) if report else Decimal("0")
old_scrap_qty = to_decimal(report.scrap_qty) if report else Decimal("0")
actual_cycle_seconds = _calculate_actual_cycle_seconds(start_time, end_time, report_qty)
ref_cycle_seconds = to_decimal(row.get("standard_beat"), "0.01")
abnormal = 0
if operation and report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"):
abnormal = 1
if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds:
abnormal = 1
if not report:
report = OperationReport(
report_no=report_no,
work_order_id=work_order.id if work_order else None,
work_order_operation_id=operation.id if operation else None,
production_ledger_id=production_ledger.id if production_ledger else None,
employee_id=employee.id,
equipment_id=None,
report_source="MINIAPP",
shift_code=None,
start_time=start_time,
end_time=end_time,
report_qty=report_qty,
good_qty=good_qty,
scrap_qty=scrap_qty,
rework_qty=to_decimal(0),
scrap_reason=None,
downtime_minutes=to_decimal(0, "0.01"),
extra_cost_amount=to_decimal(0, "0.01"),
is_abnormal=abnormal,
approved_by=None,
approved_at=None,
remark=(
f"小程序报工同步:考勤点={row.get('attendance_point_name') or '-'} "
f"report_id={row['source_report_id']} item_id={row['source_item_id']}"
),
)
else:
report.work_order_id = work_order.id if work_order else None
report.work_order_operation_id = operation.id if operation else None
report.production_ledger_id = production_ledger.id if production_ledger else None
report.employee_id = employee.id
report.report_source = "MINIAPP"
report.start_time = start_time
report.end_time = end_time
report.report_qty = report_qty
report.good_qty = good_qty
report.scrap_qty = scrap_qty
report.rework_qty = to_decimal(0)
report.is_abnormal = abnormal
report.remark = (
f"小程序报工同步:考勤点={row.get('attendance_point_name') or '-'} "
f"report_id={row['source_report_id']} item_id={row['source_item_id']}"
)
db.add(report)
if operation and work_order:
delta_report_qty = report_qty - old_report_qty
delta_good_qty = good_qty - old_good_qty
delta_scrap_qty = scrap_qty - old_scrap_qty
operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) + delta_report_qty)
operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) + delta_good_qty)
operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) + delta_scrap_qty)
if abnormal:
operation.is_abnormal = 1
if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty):
operation.status = "DONE"
elif to_decimal(operation.report_qty) > 0:
operation.status = "IN_PROGRESS"
db.add(operation)
if work_order.actual_start_time is None:
work_order.actual_start_time = start_time
db.add(work_order)
if sync_status:
sync_work_order_status(db, work_order.id)
db.flush()
if cache is not None:
cache["report"][report_no] = report
return report
def _build_miniapp_operation_report_read(
db: Session,
row: dict,
report: OperationReport | None = None,
cache: dict[str, dict] | None = None,
resolve_targets: bool = True,
) -> MiniAppOperationReportRead:
if not report:
report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"]))
if cache is not None and report_no in cache["report"]:
report = cache["report"][report_no]
else:
report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1))
if cache is not None:
cache["report"][report_no] = report
erp_scrap_qty = _miniapp_effective_scrap_qty(row)
status = str(row.get("status") or "pending")
if not resolve_targets:
return MiniAppOperationReportRead(
source_report_id=int(row["source_report_id"]),
source_item_id=int(row["source_item_id"]),
report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])),
attendance_point_name=str(row.get("attendance_point_name") or ""),
employee_phone=str(row.get("employee_phone") or ""),
employee_name=str(row.get("employee_name") or ""),
report_date=row["report_date"],
start_time=row["start_time"],
end_time=row["end_time"],
duration_minutes=float(row.get("duration_minutes") or 0),
effective_minutes=float(row.get("effective_minutes") or 0),
status=status,
status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status),
reviewer_phone=row.get("reviewer_phone"),
reviewed_at=row.get("reviewed_at"),
reject_reason=row.get("reject_reason"),
device_no=str(row.get("device_no") or ""),
project_no=str(row.get("project_no") or ""),
product_name=str(row.get("product_name") or ""),
material_code=row.get("material_code"),
material_name=row.get("material_name"),
raw_material_batch_no=row.get("raw_material_batch_no"),
process_name=row.get("process_name"),
standard_beat=float(row.get("standard_beat") or 0),
standard_workload=float(row.get("standard_workload") or 0),
good_qty=float(row.get("good_qty") or 0),
defect_qty=float(row.get("defect_qty") or 0),
scrap_qty=float(row.get("miniapp_scrap_qty") or 0),
erp_scrap_qty=float(erp_scrap_qty),
allocated_minutes=float(row.get("allocated_minutes") or 0),
started_at=row.get("started_at"),
work_order_id=report.work_order_id if report else None,
work_order_no=row.get("raw_material_batch_no"),
work_order_operation_id=report.work_order_operation_id if report else None,
operation_name=row.get("process_name"),
erp_report_id=report.id if report else None,
erp_report_no=report.report_no if report else None,
is_synced_to_erp=bool(report),
abnormal_reason=None,
)
production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache)
work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache)
operation = (
_find_work_order_operation_for_miniapp_item(
db,
work_order.id,
row.get("process_name"),
cache,
allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")),
)
if work_order
else None
)
synced_report = (
report
if (
_miniapp_report_matches_production_ledger(report, production_ledger)
or _miniapp_report_matches_target(report, work_order, operation)
)
else None
)
report_qty = to_decimal(row.get("good_qty")) + erp_scrap_qty
abnormal_reason = None
if report_qty > 0 and operation:
scrap_rate = erp_scrap_qty / report_qty
if scrap_rate > to_decimal(operation.allowed_scrap_rate, "0.0001"):
abnormal_reason = (
f"报废率超标 {round(float(scrap_rate * 100), 2)}% > "
f"{round(float(to_decimal(operation.allowed_scrap_rate, '0.0001') * 100), 2)}%"
)
return MiniAppOperationReportRead(
source_report_id=int(row["source_report_id"]),
source_item_id=int(row["source_item_id"]),
report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])),
attendance_point_name=str(row.get("attendance_point_name") or ""),
employee_phone=str(row.get("employee_phone") or ""),
employee_name=str(row.get("employee_name") or ""),
report_date=row["report_date"],
start_time=row["start_time"],
end_time=row["end_time"],
duration_minutes=float(row.get("duration_minutes") or 0),
effective_minutes=float(row.get("effective_minutes") or 0),
status=status,
status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status),
reviewer_phone=row.get("reviewer_phone"),
reviewed_at=row.get("reviewed_at"),
reject_reason=row.get("reject_reason"),
device_no=str(row.get("device_no") or ""),
project_no=str(row.get("project_no") or ""),
product_name=str(row.get("product_name") or ""),
material_code=row.get("material_code"),
material_name=row.get("material_name"),
raw_material_batch_no=row.get("raw_material_batch_no"),
process_name=row.get("process_name"),
standard_beat=float(row.get("standard_beat") or 0),
standard_workload=float(row.get("standard_workload") or 0),
good_qty=float(row.get("good_qty") or 0),
defect_qty=float(row.get("defect_qty") or 0),
scrap_qty=float(row.get("miniapp_scrap_qty") or 0),
erp_scrap_qty=float(erp_scrap_qty),
allocated_minutes=float(row.get("allocated_minutes") or 0),
started_at=row.get("started_at"),
work_order_id=work_order.id if work_order else None,
work_order_no=work_order.work_order_no if work_order else row.get("raw_material_batch_no"),
work_order_operation_id=operation.id if operation else None,
operation_name=operation.operation_name if operation else row.get("process_name"),
erp_report_id=synced_report.id if synced_report else None,
erp_report_no=synced_report.report_no if synced_report else None,
is_synced_to_erp=bool(synced_report),
abnormal_reason=abnormal_reason,
)
@router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead])
def list_work_order_ledger(
work_order_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[WorkOrderLedgerRead]:
if work_order_id and sync_work_order_reference_qty(db, work_order_id):
db.commit()
rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id)
return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows]
@router.get("/production-ledger", response_model=list[ProductionBatchLedgerRead])
def list_production_batch_ledger(
production_ledger_id: int | None = Query(default=None),
status: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[ProductionBatchLedgerRead]:
material_item = aliased(Item)
product_item = aliased(Item)
query = (
select(
ProductionBatchLedger,
material_item.item_code.label("material_code"),
material_item.item_name.label("material_name"),
product_item.item_code.label("product_code"),
product_item.item_name.label("product_name"),
)
.join(material_item, material_item.id == ProductionBatchLedger.material_item_id, isouter=True)
.join(product_item, product_item.id == ProductionBatchLedger.product_item_id, isouter=True)
.order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc())
.limit(limit)
)
if production_ledger_id:
query = query.where(ProductionBatchLedger.id == production_ledger_id)
if status:
query = query.where(ProductionBatchLedger.status == status)
rows = db.execute(query).all()
ledger_ids = [row.ProductionBatchLedger.id for row in rows]
material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, [int(ledger_id) for ledger_id in ledger_ids])
txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids}
settlement_archives: dict[int, DocumentArchive] = {}
settlement_business_id_by_txn_id: dict[int, int] = {}
if ledger_ids:
txn_rows = db.scalars(
select(ProductionBatchLedgerTxn)
.where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
.order_by(ProductionBatchLedgerTxn.biz_time.desc(), ProductionBatchLedgerTxn.id.desc())
).all()
for txn in txn_rows:
txns_by_ledger_id.setdefault(txn.production_ledger_id, []).append(txn)
settlement_group_order = {"成品入库": 1, "生产余料入库": 2, "生产废料入库": 3}
settlement_grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {}
for txn in txn_rows:
if txn.source_doc_type != "库存流水" or not txn.source_doc_id or txn.txn_type not in PRODUCTION_LEDGER_INBOUND_TXN_TYPES:
continue
batch_no = production_document_batch_no_from_text(txn.remark)
group_key = (int(txn.production_ledger_id), f"批次:{batch_no}" if batch_no else txn.biz_time)
settlement_grouped_txns.setdefault(group_key, []).append(txn)
for grouped_txns in settlement_grouped_txns.values():
main_txn = sorted(
grouped_txns,
key=lambda item: (settlement_group_order.get(str(item.txn_type or ""), 99), int(item.source_doc_id or 0)),
)[0]
main_business_id = int(main_txn.source_doc_id)
for txn in grouped_txns:
settlement_business_id_by_txn_id[int(txn.id)] = main_business_id
settlement_business_ids = list(set(settlement_business_id_by_txn_id.values()))
settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids)
result: list[ProductionBatchLedgerRead] = []
for row in rows:
ledger = row.ProductionBatchLedger
material_archive = material_out_archives.get(int(ledger.id))
result.append(
ProductionBatchLedgerRead(
production_ledger_id=ledger.id,
material_lot_id=ledger.material_lot_id,
material_lot_no=ledger.material_lot_no,
material_item_id=ledger.material_item_id,
material_code=row.material_code,
material_name=row.material_name,
product_item_id=ledger.product_item_id,
product_code=row.product_code,
product_name=row.product_name,
status=ledger.status,
miniapp_selectable=bool(ledger.miniapp_selectable),
total_issued_weight_kg=float(to_decimal(ledger.total_issued_weight_kg)),
outside_weight_kg=float(to_decimal(ledger.outside_weight_kg)),
finished_inbound_qty=float(to_decimal(ledger.finished_inbound_qty)),
surplus_inbound_weight_kg=float(to_decimal(ledger.surplus_inbound_weight_kg)),
scrap_inbound_weight_kg=float(to_decimal(ledger.scrap_inbound_weight_kg)),
writeoff_weight_kg=float(to_decimal(ledger.writeoff_weight_kg)),
first_issue_time=ledger.first_issue_time,
last_issue_time=ledger.last_issue_time,
locked_at=ledger.locked_at,
reopened_at=ledger.reopened_at,
lock_count=int(ledger.lock_count or 0),
remark=ledger.remark,
material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING,
material_out_archive_business_id=ledger.id,
material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
material_out_archive_error_message=material_archive.error_message if material_archive else None,
txns=[
_production_ledger_txn_read(
txn,
settlement_archives.get(settlement_business_id_by_txn_id[int(txn.id)])
if int(txn.id) in settlement_business_id_by_txn_id
else None,
settlement_business_id_by_txn_id.get(int(txn.id)),
)
for txn in txns_by_ledger_id.get(ledger.id, [])
],
)
)
return result
@router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead)
def preview_work_order_inbound(
work_order_id: int = Query(...),
finished_qty: float | None = Query(default=None),
db: Session = Depends(get_db),
) -> ProductionWorkOrderInboundPreviewRead:
return preview_production_work_order_inbound(db, work_order_id=work_order_id, finished_qty=finished_qty)
@router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead)
def create_work_order_inbound(
payload: ProductionWorkOrderInboundCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> ProductionWorkOrderInboundRead:
return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id)
@router.get("/production-ledger-inbounds/preview", response_model=ProductionBatchLedgerInboundPreviewRead)
def preview_production_batch_ledger_inbound(
production_ledger_id: int = Query(...),
finished_qty: float = Query(default=0),
db: Session = Depends(get_db),
) -> ProductionBatchLedgerInboundPreviewRead:
snapshot = calculate_production_batch_ledger_snapshot(
db,
production_ledger_id,
smart_reporting_enabled=False,
selected_finished_qty=to_decimal(finished_qty),
)
material = db.get(Item, snapshot.material_item_id)
product = db.get(Item, snapshot.product_item_id)
return ProductionBatchLedgerInboundPreviewRead(
production_ledger_id=snapshot.production_ledger_id,
material_lot_no=snapshot.material_lot_no,
product_item_id=snapshot.product_item_id,
product_code=product.item_code if product else None,
product_name=product.item_name if product else None,
material_item_id=snapshot.material_item_id,
material_code=material.item_code if material else None,
material_name=material.item_name if material else None,
gross_weight_per_piece_kg=float(snapshot.gross_weight_per_piece_kg),
net_weight_per_piece_kg=float(snapshot.net_weight_per_piece_kg),
total_issued_weight_kg=float(snapshot.total_issued_weight_kg),
outside_weight_kg=float(snapshot.outside_weight_kg),
finished_inbound_qty=float(snapshot.finished_inbound_qty),
surplus_inbound_weight_kg=float(snapshot.surplus_inbound_weight_kg),
scrap_inbound_weight_kg=float(snapshot.scrap_inbound_weight_kg),
writeoff_weight_kg=float(snapshot.writeoff_weight_kg),
reference_finished_qty=float(snapshot.reference_finished_qty),
used_material_weight_kg=float(snapshot.used_material_weight_kg),
theoretical_surplus_weight_kg=float(snapshot.theoretical_surplus_weight_kg),
theoretical_scrap_weight_kg=float(snapshot.theoretical_scrap_weight_kg),
)
@router.post("/production-ledger-inbounds", response_model=ProductionBatchLedgerInboundRead)
def create_production_batch_ledger_inbound(
payload: ProductionBatchLedgerInboundCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> ProductionBatchLedgerInboundRead:
finished_qty = to_decimal(payload.finished_qty)
surplus_weight = to_decimal(payload.surplus_weight_kg)
scrap_weight = to_decimal(payload.scrap_weight_kg)
if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0 and not payload.lock_material_batch:
raise HTTPException(status_code=400, detail="请至少填写一种入库数量或勾选该批材料结单")
ledger: ProductionBatchLedger | None = db.get(ProductionBatchLedger, payload.production_ledger_id)
if not ledger:
raise HTTPException(status_code=404, detail="生产台账不存在")
source_lot = db.get(StockLot, ledger.material_lot_id)
material = db.get(Item, ledger.material_item_id)
product = db.get(Item, ledger.product_item_id)
if not source_lot or not material or not product:
raise HTTPException(status_code=400, detail="生产台账来源批次、原材料或产品数据不完整")
snapshot = calculate_production_batch_ledger_snapshot(
db,
payload.production_ledger_id,
smart_reporting_enabled=False,
selected_finished_qty=finished_qty,
)
deviation_remark = str(payload.deviation_remark or "").strip()
total_finished_qty = to_decimal(snapshot.finished_inbound_qty) + finished_qty
reference_finished_qty = to_decimal(snapshot.reference_finished_qty)
if reference_finished_qty <= 0:
finished_exceeded = total_finished_qty > 0
else:
finished_exceeded = total_finished_qty > reference_finished_qty * Decimal("1.15")
def _deviation_exceeded(actual: Decimal, theoretical: Decimal) -> bool:
actual = to_decimal(actual)
theoretical = to_decimal(theoretical)
if theoretical == 0:
return actual != 0
return abs(actual - theoretical) / abs(theoretical) > Decimal("0.15")
if finished_exceeded and not deviation_remark:
raise HTTPException(status_code=400, detail="成品入库数量超过本生产台账参考生产数量上限15%,请填写偏差说明")
if (
_deviation_exceeded(surplus_weight, to_decimal(snapshot.theoretical_surplus_weight_kg))
or _deviation_exceeded(scrap_weight, to_decimal(snapshot.theoretical_scrap_weight_kg))
) and not deviation_remark:
raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明")
gross_weight = to_decimal(snapshot.gross_weight_per_piece_kg)
net_weight = to_decimal(snapshot.net_weight_per_piece_kg)
if net_weight <= 0:
net_weight = to_decimal(product.unit_weight_kg)
if net_weight <= 0:
net_weight = gross_weight
if gross_weight <= 0 and finished_qty > 0:
raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,不能进行生产台账入库")
now = datetime.now()
locked_check_warehouse_ids: set[int] = set()
finished_warehouse = _get_active_warehouse(db, "FINISHED", "生产台账入库") if finished_qty > 0 else None
scrap_warehouse = _get_active_warehouse(db, "SCRAP", "生产台账入库") if scrap_weight > 0 else None
if finished_warehouse:
locked_check_warehouse_ids.add(int(finished_warehouse.id))
if surplus_weight > 0:
locked_check_warehouse_ids.add(int(source_lot.warehouse_id))
if scrap_warehouse:
locked_check_warehouse_ids.add(int(scrap_warehouse.id))
ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产台账入库")
finished_lot_id = surplus_lot_id = scrap_lot_id = None
finished_txn_id = surplus_txn_id = scrap_txn_id = None
base_remark = payload.remark or "生产台账入库"
if payload.deviation_remark:
base_remark = f"{base_remark};偏差说明:{payload.deviation_remark}"
document_batch_no = _production_document_batch_no(now) if (finished_qty > 0 or surplus_weight > 0 or scrap_weight > 0) else None
if finished_qty > 0:
receipt_weight = to_decimal(finished_qty * net_weight)
unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross_weight, "0.0001")
lot_no = _build_production_ledger_lot_no(db, "FGL", ledger, product, now)
source_summary = f"{material.item_name}{ledger.material_lot_no}"
finished_lot = StockLot(
lot_no=lot_no,
parent_lot_id=source_lot.id,
lot_role="FINISHED_FROM_RAW_BATCH",
material_sub_batch_no=None,
item_id=product.id,
warehouse_id=finished_warehouse.id,
location_id=None,
source_doc_type="PRODUCTION_LEDGER_IN",
source_doc_id=ledger.id,
source_line_id=None,
source_material_lot_id=source_lot.id,
source_material_sub_batch_no=ledger.material_lot_no,
source_material_summary=source_summary,
inbound_qty=finished_qty,
inbound_weight_kg=receipt_weight,
remaining_qty=finished_qty,
remaining_weight_kg=receipt_weight,
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=unit_cost,
production_date=now.date(),
expire_date=None,
quality_status="PASS",
status="AVAILABLE",
remark=_with_document_batch_no(f"{base_remark};成品入库;来源库存批次已记录", document_batch_no),
)
db.add(finished_lot)
db.flush()
upsert_stock_balance(
db,
item_id=product.id,
warehouse_id=finished_warehouse.id,
location_id=None,
qty_delta=finished_qty,
weight_delta=receipt_weight,
available_qty_delta=finished_qty,
available_weight_delta=receipt_weight,
unit_cost=unit_cost,
)
finished_txn = create_inventory_txn(
db,
txn_type="FG_IN",
item_id=product.id,
warehouse_id=finished_warehouse.id,
location_id=None,
lot_id=finished_lot.id,
qty_change=finished_qty,
weight_change=receipt_weight,
unit_cost=unit_cost,
source_doc_type="生产台账",
source_doc_id=ledger.id,
source_line_id=finished_lot.id,
biz_time=now,
operator_user_id=context.user.id,
remark=_with_document_batch_no(f"{base_remark};成品入库", document_batch_no),
amount_basis="QTY",
)
finished_lot_id = finished_lot.id
finished_txn_id = finished_txn.id
ledger = post_finished_inbound_to_ledger(
db,
production_ledger_id=payload.production_ledger_id,
finished_qty=finished_qty,
operator_user_id=context.user.id,
source_doc_type="库存流水",
source_doc_id=finished_txn.id,
source_line_id=finished_lot.id,
biz_time=now,
remark=_with_document_batch_no(f"{base_remark};成品入库联动生产台账", document_batch_no),
)
if surplus_weight > 0:
source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight
source_lot.status = "AVAILABLE"
db.add(source_lot)
upsert_stock_balance(
db,
item_id=material.id,
warehouse_id=source_lot.warehouse_id,
location_id=source_lot.location_id,
qty_delta=Decimal("0"),
weight_delta=surplus_weight,
available_qty_delta=Decimal("0"),
available_weight_delta=surplus_weight,
unit_cost=to_decimal(source_lot.unit_cost),
)
surplus_txn = create_inventory_txn(
db,
txn_type="PRODUCTION_SURPLUS_IN",
item_id=material.id,
warehouse_id=source_lot.warehouse_id,
location_id=source_lot.location_id,
lot_id=source_lot.id,
qty_change=Decimal("0"),
weight_change=surplus_weight,
unit_cost=to_decimal(source_lot.unit_cost),
source_doc_type="生产台账",
source_doc_id=ledger.id,
source_line_id=source_lot.id,
biz_time=now,
operator_user_id=context.user.id,
remark=_with_document_batch_no(f"{base_remark};生产余料入库", document_batch_no),
amount_basis="WEIGHT",
)
surplus_lot_id = source_lot.id
surplus_txn_id = surplus_txn.id
ledger = post_surplus_inbound_to_ledger(
db,
production_ledger_id=payload.production_ledger_id,
surplus_weight_kg=surplus_weight,
operator_user_id=context.user.id,
source_doc_type="库存流水",
source_doc_id=surplus_txn.id,
source_line_id=source_lot.id,
biz_time=now,
remark=_with_document_batch_no(f"{base_remark};生产余料入库联动生产台账", document_batch_no),
)
if scrap_weight > 0:
scrap_lot_no = _build_production_ledger_lot_no(db, "SCRAPI", ledger, material, now)
scrap_lot = StockLot(
lot_no=scrap_lot_no,
parent_lot_id=source_lot.id,
lot_role="PRODUCTION_SCRAP",
material_sub_batch_no=None,
item_id=material.id,
warehouse_id=scrap_warehouse.id,
location_id=None,
source_doc_type="PRODUCTION_SCRAP_IN",
source_doc_id=ledger.id,
source_line_id=None,
source_material_lot_id=source_lot.id,
source_material_sub_batch_no=ledger.material_lot_no,
source_material_summary=f"{material.item_name}{ledger.material_lot_no}",
inbound_qty=Decimal("0"),
inbound_weight_kg=scrap_weight,
remaining_qty=Decimal("0"),
remaining_weight_kg=scrap_weight,
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=to_decimal(source_lot.unit_cost),
production_date=now.date(),
expire_date=None,
quality_status="SCRAP",
status="AVAILABLE",
remark=_with_document_batch_no(f"{base_remark};生产废料入库;来源库存批次已记录", document_batch_no),
)
db.add(scrap_lot)
db.flush()
upsert_stock_balance(
db,
item_id=material.id,
warehouse_id=scrap_warehouse.id,
location_id=None,
qty_delta=Decimal("0"),
weight_delta=scrap_weight,
available_qty_delta=Decimal("0"),
available_weight_delta=scrap_weight,
unit_cost=to_decimal(source_lot.unit_cost),
)
scrap_txn = create_inventory_txn(
db,
txn_type="PRODUCTION_SCRAP_IN",
item_id=material.id,
warehouse_id=scrap_warehouse.id,
location_id=None,
lot_id=scrap_lot.id,
qty_change=Decimal("0"),
weight_change=scrap_weight,
unit_cost=to_decimal(source_lot.unit_cost),
source_doc_type="生产台账",
source_doc_id=ledger.id,
source_line_id=scrap_lot.id,
biz_time=now,
operator_user_id=context.user.id,
remark=_with_document_batch_no(f"{base_remark};生产废料入库", document_batch_no),
amount_basis="WEIGHT",
)
scrap_lot_id = scrap_lot.id
scrap_txn_id = scrap_txn.id
ledger = post_scrap_inbound_to_ledger(
db,
production_ledger_id=payload.production_ledger_id,
scrap_weight_kg=scrap_weight,
operator_user_id=context.user.id,
source_doc_type="库存流水",
source_doc_id=scrap_txn.id,
source_line_id=scrap_lot.id,
biz_time=now,
remark=_with_document_batch_no(f"{base_remark};生产废料入库联动生产台账", document_batch_no),
)
if payload.lock_material_batch:
if not payload.lock_confirmed:
raise HTTPException(status_code=400, detail="请确认该批材料结单后再提交")
ledger = lock_production_batch_ledger(
db,
production_ledger_id=payload.production_ledger_id,
operator_user_id=context.user.id,
biz_time=now,
remark=payload.deviation_remark or payload.remark or "生产台账入库后该批材料结单",
)
response_ledger_id = int(ledger.id)
response_material_lot_no = str(ledger.material_lot_no)
response_status = str(ledger.status)
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
db.commit()
archive_result: DocumentArchiveGenerateResult | None = None
if main_txn_id:
try:
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
int(main_txn_id),
created_by=context.user.id,
)
except Exception as exc:
archive_result = _failed_archive_result(
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
int(main_txn_id),
f"库存流水#{main_txn_id}",
exc,
)
response = ProductionBatchLedgerInboundRead(
production_ledger_id=response_ledger_id,
material_lot_no=response_material_lot_no,
finished_qty=float(finished_qty),
surplus_weight_kg=float(surplus_weight),
scrap_weight_kg=float(scrap_weight),
finished_lot_id=finished_lot_id,
surplus_lot_id=surplus_lot_id,
scrap_lot_id=scrap_lot_id,
finished_txn_id=finished_txn_id,
surplus_txn_id=surplus_txn_id,
scrap_txn_id=scrap_txn_id,
locked=bool(payload.lock_material_batch),
status=response_status,
remark=payload.remark,
)
for key, value in _archive_fields_from_result(
archive_result,
document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
business_id=int(main_txn_id) if main_txn_id else None,
).items():
setattr(response, key, value)
return response
@router.get("/work-orders", response_model=list[WorkOrderRead])
def list_work_orders(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[WorkOrderRead]:
if sync_recent_work_order_reference_qty(db, limit=limit):
db.commit()
rows = db.execute(get_work_orders_query(limit=limit)).mappings().all()
return [WorkOrderRead.model_validate(dict(row)) for row in rows]
@router.get("/work-order-materials", response_model=list[WorkOrderMaterialRead])
def list_work_order_materials(
work_order_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[WorkOrderMaterialRead]:
rows = db.execute(get_work_order_materials_query(limit=limit, work_order_id=work_order_id)).mappings().all()
return [WorkOrderMaterialRead.model_validate(dict(row)) for row in rows]
@router.get("/work-order-material-issues", response_model=list[WorkOrderMaterialIssueRead])
def list_work_order_material_issues(
work_order_id: int | None = Query(default=None),
limit: int = Query(default=300, ge=1, le=800),
db: Session = Depends(get_db),
) -> list[WorkOrderMaterialIssueRead]:
rows = db.execute(get_work_order_material_issues_query(limit=limit, work_order_id=work_order_id)).mappings().all()
return [WorkOrderMaterialIssueRead.model_validate(dict(row)) for row in rows]
@router.get("/work-order-operations", response_model=list[WorkOrderOperationRead])
def list_work_order_operations(
work_order_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[WorkOrderOperationRead]:
if work_order_id and sync_work_order_reference_qty(db, work_order_id):
db.commit()
rows = db.execute(get_work_order_operations_query(limit=limit, work_order_id=work_order_id)).mappings().all()
return [WorkOrderOperationRead.model_validate(dict(row)) for row in rows]
@router.post("/work-orders", response_model=WorkOrderRead)
def create_work_order(
payload: WorkOrderCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> WorkOrderRead:
product = db.get(Item, payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if payload.source_sales_order_item_id:
source_order_item = db.get(SalesOrderItem, payload.source_sales_order_item_id)
if not source_order_item:
raise HTTPException(status_code=404, detail="来源订单明细不存在")
selected_lots = [
{
"source_lot_id": int(row.source_lot_id),
"material_item_id": int(row.material_item_id) if row.material_item_id else None,
"issued_weight_kg": to_decimal(row.issued_weight_kg),
}
for row in payload.selected_stock_lots
]
if not selected_lots:
raise HTTPException(status_code=400, detail="生产出库必须选择库存批次号")
if len(selected_lots) != 1:
raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号")
route = get_default_route(db, payload.product_item_id)
bom = get_default_bom(db, payload.product_item_id)
bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all()
if not bom_items:
raise HTTPException(status_code=400, detail="产品需规缺少默认用料,无法按库存批次出库")
selected_lot_payload = selected_lots[0]
selected_lot = db.scalar(
select(StockLot)
.where(StockLot.id == int(selected_lot_payload["source_lot_id"]))
.with_for_update()
)
if not selected_lot:
raise HTTPException(status_code=400, detail="库存批次不存在")
selected_material_item_id = int(selected_lot_payload["material_item_id"] or selected_lot.item_id)
if selected_material_item_id != int(selected_lot.item_id):
raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 与选择的原材料不匹配")
selected_lot_payload["material_item_id"] = selected_material_item_id
bom_item_by_material_id = {int(item.material_item_id): item for item in bom_items}
selected_bom_item = bom_item_by_material_id.get(selected_material_item_id)
if not selected_bom_item:
raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料")
ensure_warehouses_unlocked(db, {int(selected_lot.warehouse_id)}, "生产出库")
issue_weight = to_decimal(selected_lot_payload["issued_weight_kg"])
if issue_weight <= 0:
raise HTTPException(status_code=400, detail="出库重量必须大于0")
planned_qty = to_decimal(payload.planned_qty)
if issue_weight > 0:
gross_weight = to_decimal(selected_bom_item.gross_weight_per_piece_kg)
if gross_weight <= 0:
gross_weight = to_decimal(product.unit_weight_kg)
if gross_weight <= 0:
raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重/净重,无法按领料重量计算参考生产数量")
planned_qty = (issue_weight / gross_weight).to_integral_value(rounding=ROUND_FLOOR)
if planned_qty <= 0:
raise HTTPException(status_code=400, detail="参考生产数量必须大于0")
biz_time = datetime.now()
ledger = record_production_issue_to_ledger(
db,
material_lot_id=selected_lot.id,
product_item_id=payload.product_item_id,
issued_weight_kg=issue_weight,
operator_user_id=context.user.id,
source_doc_type="库存流水",
source_doc_id=None,
source_line_id=None,
biz_time=biz_time,
remark=payload.remark or "原材料库生产出库形成生产台账",
)
inventory_txn = _issue_raw_lot_to_production_ledger(
db,
lot=selected_lot,
material_item_id=selected_material_item_id,
issued_weight=issue_weight,
production_ledger_id=ledger.id,
operator_user_id=context.user.id,
biz_time=biz_time,
)
_link_latest_issue_ledger_txn_to_inventory_txn(db, production_ledger_id=ledger.id, inventory_txn=inventory_txn)
production_ledger_id = int(ledger.id)
production_material_lot_no = str(ledger.material_lot_no)
production_ledger_status = str(ledger.status)
operation_count = db.scalar(select(func.count(ProcessRouteOperation.id)).where(ProcessRouteOperation.route_id == route.id)) or 0
response = _build_production_ledger_work_order_compat_read(
production_ledger_id=production_ledger_id,
material_lot_no=production_material_lot_no,
product=product,
route=route,
bom=bom,
planned_qty=planned_qty,
payload=payload,
biz_time=biz_time,
status=production_ledger_status,
operation_count=int(operation_count),
)
db.commit()
archive_result: DocumentArchiveGenerateResult | None = None
try:
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
production_ledger_id,
created_by=context.user.id,
)
except Exception as exc:
archive_result = _failed_archive_result(
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
production_ledger_id,
f"生产台账#{production_ledger_id}",
exc,
)
for key, value in _archive_fields_from_result(
archive_result,
document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
business_id=production_ledger_id,
).items():
setattr(response, key, value)
return response
@router.get("/miniapp-operation-reports/sync-state", response_model=MiniAppOperationReportSyncStateRead)
def get_miniapp_operation_report_sync_state(
db: Session = Depends(get_db),
) -> MiniAppOperationReportSyncStateRead:
return MiniAppOperationReportSyncStateRead(last_synced_at=get_miniapp_operation_report_last_sync(db))
@router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead)
def sync_miniapp_operation_reports(
db: Session = Depends(get_db),
) -> MiniAppOperationReportSyncRead:
lock_miniapp_operation_report_last_sync(db)
sync_from = get_miniapp_operation_report_last_sync(db)
sync_to = datetime.now()
rows = _miniapp_report_item_rows(
db,
limit=None,
updated_after=sync_from,
updated_until=sync_to,
order_by_updated_asc=True,
)
cache = _new_miniapp_resolve_cache()
report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows]
_preload_miniapp_operation_reports(db, report_nos, cache)
touched_work_order_ids: set[int] = set()
synced = 0
skipped = 0
for row in rows:
synced_report = _sync_miniapp_item_to_erp(
db,
int(row["source_item_id"]),
required=False,
row=row,
cache=cache,
sync_status=False,
)
if synced_report:
synced += 1
if synced_report.work_order_id:
touched_work_order_ids.add(int(synced_report.work_order_id))
else:
skipped += 1
for work_order_id in touched_work_order_ids:
sync_work_order_status(db, work_order_id)
row = upsert_miniapp_operation_report_last_sync(db, sync_to)
db.commit()
db.refresh(row)
return MiniAppOperationReportSyncRead(
sync_from=sync_from,
sync_to=sync_to,
last_synced_at=sync_to,
total=len(rows),
synced=synced,
skipped=skipped,
failed=0,
)
@router.get("/miniapp-operation-reports", response_model=list[MiniAppOperationReportRead])
def list_miniapp_operation_reports(
limit: int = Query(default=300, ge=1, le=800),
sync_to_erp: bool = Query(default=True),
start_date: date | None = Query(default=None),
end_date: date | None = Query(default=None),
db: Session = Depends(get_db),
) -> list[MiniAppOperationReportRead]:
if start_date and end_date and start_date > end_date:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
rows = _miniapp_report_item_rows(db, limit=limit, start_date=start_date, end_date=end_date)
cache = _new_miniapp_resolve_cache()
report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows]
_preload_miniapp_operation_reports(db, report_nos, cache)
result: list[MiniAppOperationReportRead] = []
touched_work_order_ids: set[int] = set()
for row in rows:
synced_report = None
if sync_to_erp:
synced_report = _sync_miniapp_item_to_erp(
db,
int(row["source_item_id"]),
required=False,
row=row,
cache=cache,
sync_status=False,
)
if synced_report and synced_report.work_order_id:
touched_work_order_ids.add(int(synced_report.work_order_id))
result.append(_build_miniapp_operation_report_read(db, row, synced_report, cache=cache, resolve_targets=sync_to_erp))
if sync_to_erp:
for work_order_id in touched_work_order_ids:
sync_work_order_status(db, work_order_id)
db.commit()
return result
@router.get("/operation-reports", response_model=list[OperationReportRead])
def list_operation_reports(
work_order_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[OperationReportRead]:
rows = db.execute(get_operation_reports_query(limit=limit, work_order_id=work_order_id)).mappings().all()
return [_build_operation_report_read(dict(row)) for row in rows]
@router.post("/miniapp-scrap-records", response_model=ScrapRecordRead)
def create_miniapp_scrap_record(payload: MiniAppScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead:
report = _sync_miniapp_item_to_erp(db, payload.source_item_id, required=True)
if not report:
raise HTTPException(status_code=400, detail="小程序报工无法同步为 ERP 报工")
work_order = db.get(WorkOrder, report.work_order_id)
if not work_order:
raise HTTPException(status_code=404, detail="库存批次号不存在")
source_row = _get_miniapp_report_item_row(db, payload.source_item_id)
source_scrap_qty = _miniapp_effective_scrap_qty(source_row)
scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_scrap_qty
scrap_payload = ScrapRecordCreate(
report_id=report.id,
work_order_id=report.work_order_id,
work_order_operation_id=report.work_order_operation_id,
item_id=work_order.product_item_id,
scrap_qty=float(scrap_qty),
scrap_weight_kg=payload.scrap_weight_kg,
scrap_reason=payload.scrap_reason,
disposal_type="SCRAP",
saleable_scrap_weight_kg=payload.saleable_scrap_weight_kg,
scrap_cost_amount=payload.scrap_cost_amount,
)
return create_scrap_record(scrap_payload, db)
@router.post("/operation-reports", response_model=OperationReportRead)
def create_operation_report(
payload: OperationReportCreate,
db: Session = Depends(get_db),
) -> OperationReportRead:
work_order = db.get(WorkOrder, payload.work_order_id)
if not work_order:
raise HTTPException(status_code=404, detail="工单不存在")
operation = db.get(WorkOrderOperation, payload.work_order_operation_id)
if not operation or operation.work_order_id != payload.work_order_id:
raise HTTPException(status_code=400, detail="工单工序不存在或不属于当前工单")
employee = db.get(Employee, payload.employee_id)
if not employee:
raise HTTPException(status_code=404, detail="员工不存在")
if payload.equipment_id:
equipment = db.get(Equipment, payload.equipment_id)
if not equipment:
raise HTTPException(status_code=404, detail="设备不存在")
if payload.end_time <= payload.start_time:
raise HTTPException(status_code=400, detail="报工结束时间必须晚于开始时间")
report_qty = to_decimal(payload.report_qty)
good_qty = to_decimal(payload.good_qty)
scrap_qty = to_decimal(payload.scrap_qty)
rework_qty = to_decimal(payload.rework_qty)
if report_qty <= 0:
raise HTTPException(status_code=400, detail="报工数量必须大于0")
if good_qty < 0 or scrap_qty < 0 or rework_qty < 0:
raise HTTPException(status_code=400, detail="报工数量、合格数、报废数、返工数不能小于0")
if good_qty + scrap_qty + rework_qty > report_qty:
raise HTTPException(status_code=400, detail="合格数、报废数、返工数合计不能超过报工数量")
route_operation = db.get(ProcessRouteOperation, operation.route_operation_id) if operation.route_operation_id else None
ref_cycle_seconds = to_decimal(route_operation.std_cycle_seconds if route_operation else 0, "0.01")
actual_cycle_seconds = _calculate_actual_cycle_seconds(payload.start_time, payload.end_time, report_qty)
abnormal_reasons: list[str] = []
if report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"):
abnormal_reasons.append("报废率超出工序允许值")
if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds:
abnormal_reasons.append("实际节拍慢于参考节拍")
abnormal = 1 if abnormal_reasons else 0
report = OperationReport(
report_no=_build_operation_report_no(db, work_order, operation, employee),
work_order_id=payload.work_order_id,
work_order_operation_id=payload.work_order_operation_id,
employee_id=payload.employee_id,
equipment_id=payload.equipment_id,
report_source=payload.report_source,
shift_code=payload.shift_code,
start_time=payload.start_time,
end_time=payload.end_time,
report_qty=report_qty,
good_qty=good_qty,
scrap_qty=scrap_qty,
rework_qty=rework_qty,
scrap_reason=payload.scrap_reason,
downtime_minutes=to_decimal(payload.downtime_minutes, "0.01"),
extra_cost_amount=to_decimal(payload.extra_cost_amount, "0.01"),
is_abnormal=abnormal,
approved_by=None,
approved_at=None,
remark=payload.remark,
)
db.add(report)
operation.report_qty = to_decimal(operation.report_qty) + report_qty
operation.good_qty = to_decimal(operation.good_qty) + good_qty
operation.scrap_qty = to_decimal(operation.scrap_qty) + scrap_qty
operation.rework_qty = to_decimal(operation.rework_qty) + rework_qty
if abnormal:
operation.is_abnormal = 1
if operation.report_qty > 0 and (to_decimal(operation.scrap_qty) / to_decimal(operation.report_qty)) > to_decimal(
operation.allowed_scrap_rate, "0.0001"
):
operation.is_abnormal = 1
if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty):
operation.status = "DONE"
else:
operation.status = "IN_PROGRESS"
db.add(operation)
if work_order.actual_start_time is None:
work_order.actual_start_time = payload.start_time
db.add(work_order)
sync_work_order_status(db, work_order.id)
db.commit()
row = db.execute(get_operation_reports_query(limit=1).where(OperationReport.id == report.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="报工记录创建后读取失败")
return _build_operation_report_read(dict(row))
@router.get("/scrap-records", response_model=list[ScrapRecordRead])
def list_scrap_records(
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[ScrapRecordRead]:
rows = db.execute(get_scrap_records_query(limit=limit)).mappings().all()
return [ScrapRecordRead.model_validate(dict(row)) for row in rows]
@router.post("/scrap-records", response_model=ScrapRecordRead)
def create_scrap_record(payload: ScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead:
report = db.get(OperationReport, payload.report_id)
if not report:
raise HTTPException(status_code=404, detail="报工记录不存在")
if report.work_order_id != payload.work_order_id or report.work_order_operation_id != payload.work_order_operation_id:
raise HTTPException(status_code=400, detail="处置记录与来源报工不匹配")
work_order = db.get(WorkOrder, payload.work_order_id)
if not work_order:
raise HTTPException(status_code=404, detail="工单不存在")
operation = db.get(WorkOrderOperation, payload.work_order_operation_id)
if not operation or operation.work_order_id != payload.work_order_id:
raise HTTPException(status_code=404, detail="工序不存在")
item = db.get(Item, payload.item_id)
if not item:
raise HTTPException(status_code=404, detail="物料不存在")
disposal_type = str(payload.disposal_type or "SCRAP").strip().upper()
if disposal_type != "SCRAP":
raise HTTPException(status_code=400, detail="生产过程只登记报废;客户退货返工请在发货概览模块处理")
existing_active_record = _get_active_scrap_record(db, payload.report_id)
if existing_active_record:
_revert_scrap_record_effect(work_order, existing_active_record)
existing_active_record.status = "OVERRIDDEN"
db.add(existing_active_record)
source_qty = to_decimal(report.rework_qty if disposal_type == "REWORK" and to_decimal(report.rework_qty) > 0 else report.scrap_qty)
scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_qty
if scrap_qty <= 0:
raise HTTPException(status_code=400, detail="当前来源报工没有可处置的报废/返工数量")
scrap_weight = to_decimal(payload.scrap_weight_kg)
if scrap_weight <= 0:
scrap_weight = scrap_qty * to_decimal(item.unit_weight_kg)
saleable_scrap_weight = Decimal("0")
scrap_cost_amount = Decimal("0")
if disposal_type == "SCRAP":
saleable_scrap_weight = (
to_decimal(payload.saleable_scrap_weight_kg)
if to_decimal(payload.saleable_scrap_weight_kg) > 0
else scrap_weight
)
scrap_cost_amount = to_decimal(payload.scrap_cost_amount, "0.01")
if scrap_cost_amount <= 0 and saleable_scrap_weight > 0:
scrap_cost_amount = to_decimal(_get_average_issued_weight_cost(db, payload.work_order_id) * saleable_scrap_weight, "0.01")
else:
scrap_cost_amount = to_decimal(scrap_qty * to_decimal(operation.std_labor_cost, "0.0001"), "0.01")
record = ScrapRecord(
scrap_no=_build_scrap_no(db, work_order, disposal_type),
report_id=payload.report_id,
work_order_id=payload.work_order_id,
work_order_operation_id=payload.work_order_operation_id,
item_id=payload.item_id,
scrap_qty=scrap_qty,
scrap_weight_kg=scrap_weight,
scrap_reason=payload.scrap_reason or report.scrap_reason,
disposal_type=disposal_type,
saleable_scrap_weight_kg=saleable_scrap_weight,
scrap_cost_amount=scrap_cost_amount,
status="CONFIRMED",
)
db.add(record)
if disposal_type == "SCRAP":
work_order.scrap_qty = to_decimal(work_order.scrap_qty) + scrap_qty
db.add(work_order)
sync_work_order_status(db, work_order.id)
db.commit()
row = db.execute(get_scrap_records_query(limit=1).where(ScrapRecord.id == record.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="报废/返工记录创建后读取失败")
return ScrapRecordRead.model_validate(dict(row))
@router.get("/completion-receipts", response_model=list[CompletionReceiptRead])
def list_completion_receipts(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[CompletionReceiptRead]:
rows = db.execute(get_completion_receipts_query(limit=limit)).mappings().all()
return [CompletionReceiptRead.model_validate(dict(row)) for row in rows]
@router.get("/completion-items", response_model=list[CompletionReceiptItemRead])
def list_completion_receipt_items(
receipt_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[CompletionReceiptItemRead]:
rows = db.execute(get_completion_receipt_items_query(limit=limit, receipt_id=receipt_id)).mappings().all()
return [CompletionReceiptItemRead.model_validate(dict(row)) for row in rows]
def _build_work_order_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]:
rows = db.execute(get_work_order_material_issues_query(limit=500, work_order_id=work_order_id)).mappings().all()
if not rows:
return {
"source_material_lot_id": None,
"source_material_sub_batch_no": None,
"source_material_summary": None,
"source_material_weight_kg": Decimal("0"),
"source_material_amount": Decimal("0"),
"source_material_unit_cost": Decimal("0"),
}
first = rows[0]
parts: list[str] = []
total_weight = Decimal("0")
total_amount = Decimal("0")
for row in rows:
weight = to_decimal(row["issued_weight_kg"])
amount = to_decimal(row["amount"], "0.01")
source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"]
parts.append(f"{source_lot_no} / {row['material_name']} / {weight}kg / ¥{amount}")
if weight > 0:
total_weight += weight
total_amount += amount
first_lot_no = first["source_lot_no"] or first["material_sub_batch_no"]
material_unit_cost = (total_amount / total_weight) if total_weight > 0 else Decimal("0")
return {
"source_material_lot_id": first["source_lot_id"],
"source_material_sub_batch_no": first_lot_no,
"source_material_summary": "".join(parts)[:1000],
"source_material_weight_kg": to_decimal(total_weight, "0.000001"),
"source_material_amount": to_decimal(total_amount, "0.01"),
"source_material_unit_cost": to_decimal(material_unit_cost, "0.0001"),
}
def _is_rework_work_order(work_order: WorkOrder) -> bool:
return str(work_order.work_order_type or "").upper() == "REWORK"
def _build_rework_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]:
row = db.execute(
select(
ReturnDisposition.id.label("return_disposition_id"),
ReturnItem.source_lot_id.label("source_lot_id"),
)
.join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id)
.where(ReturnDisposition.rework_work_order_id == work_order_id)
.limit(1)
).mappings().first()
if not row:
raise HTTPException(status_code=400, detail="该返工工单缺少退货返工来源,不能返工入库")
finished_lot = db.get(StockLot, row["source_lot_id"]) if row["source_lot_id"] else None
raw_lot = db.get(StockLot, finished_lot.source_material_lot_id) if finished_lot and finished_lot.source_material_lot_id else None
source_material_lot_id = finished_lot.source_material_lot_id if finished_lot else None
source_material_sub_batch_no = (
finished_lot.source_material_sub_batch_no
if finished_lot and finished_lot.source_material_sub_batch_no
else raw_lot.lot_no if raw_lot else None
)
source_material_summary = (
finished_lot.source_material_summary
if finished_lot and finished_lot.source_material_summary
else raw_lot.lot_no if raw_lot else "退货返工来源"
)
source_material_unit_cost = to_decimal(raw_lot.unit_cost if raw_lot else finished_lot.unit_cost if finished_lot else 0, "0.0001")
return {
"source_material_lot_id": source_material_lot_id,
"source_material_sub_batch_no": source_material_sub_batch_no or "退货返工来源批次未指定",
"source_material_summary": f"退货返工;{source_material_summary}",
"source_material_weight_kg": Decimal("0"),
"source_material_amount": Decimal("0"),
"source_material_unit_cost": source_material_unit_cost,
}
def _get_work_order_gross_weight_per_piece(db: Session, work_order: WorkOrder, product: Item) -> Decimal:
gross_weight = to_decimal(
db.scalar(
select(BomItem.gross_weight_per_piece_kg)
.where(BomItem.bom_id == work_order.bom_id, BomItem.gross_weight_per_piece_kg > 0)
.order_by(BomItem.seq_no, BomItem.id)
.limit(1)
),
"0.000001",
)
if gross_weight <= 0:
gross_weight = to_decimal(product.unit_weight_kg, "0.000001")
return gross_weight
def _calculate_finished_material_cost_per_piece(
source_material: dict[str, object | None],
gross_weight_per_piece: Decimal,
) -> Decimal:
material_unit_cost = to_decimal(source_material.get("source_material_unit_cost"), "0.0001")
if material_unit_cost <= 0 or gross_weight_per_piece <= 0:
return Decimal("0")
return to_decimal(material_unit_cost * gross_weight_per_piece, "0.0001")
def _append_finished_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None:
existing = str(work_order.remark or "").strip()
parts = [part.strip() for part in existing.replace(";", "").split("") if part.strip()]
if "生产入库已结单" not in parts:
parts.append("生产入库已结单")
remark = str(settle_remark or "").strip()
if remark:
parts.append(f"生产入库结单说明:{remark}")
unique_parts: list[str] = []
for part in parts:
if part not in unique_parts:
unique_parts.append(part)
work_order.remark = "".join(unique_parts)[-255:]
def _rework_finished_inbound_qty(db: Session, work_order_id: int) -> Decimal:
return to_decimal(
db.scalar(
select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where(
InventoryTxn.txn_type == "REWORK_FINISHED_IN",
InventoryTxn.source_doc_type == "WORK_ORDER",
InventoryTxn.source_doc_id == work_order_id,
)
),
"0.000001",
)
def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal:
return to_decimal(
db.scalar(
select(func.coalesce(func.sum(StockLot.inbound_qty), 0)).where(
StockLot.lot_role == "REWORK_SCRAP",
StockLot.source_doc_type == "WORK_ORDER",
StockLot.source_doc_id == work_order_id,
)
),
"0.000001",
)
def _settle_rework_work_order_if_ready(
db: Session,
work_order: WorkOrder,
*,
incoming_finished_qty: Decimal = Decimal("0"),
settle_remark: str | None = None,
) -> None:
planned_qty = to_decimal(work_order.planned_qty)
processed_qty = _rework_finished_inbound_qty(db, work_order.id) + incoming_finished_qty + _rework_scrap_inbound_qty(db, work_order.id)
if processed_qty < planned_qty:
raise HTTPException(
status_code=400,
detail=f"返工工单尚未闭环:返工出库 {planned_qty} 件,已回库/入废料 {processed_qty}",
)
work_order.status = "SETTLED"
work_order.actual_end_time = work_order.actual_end_time or datetime.now()
work_order.updated_at = datetime.now()
parts = [part.strip() for part in str(work_order.remark or "").replace(";", "").split("") if part.strip()]
if "返工工单已结单" not in parts:
parts.append("返工工单已结单")
remark = str(settle_remark or "").strip()
if remark:
parts.append(f"返工结单说明:{remark}")
work_order.remark = "".join(dict.fromkeys(parts))[-255:]
db.add(work_order)
def _validate_finished_settle_request(
payload: CompletionReceiptCreate,
*,
receipt_qty: Decimal,
expected_receipt_qty: Decimal,
) -> None:
if not payload.settle_work_order:
return
lower_limit = expected_receipt_qty * Decimal("0.85")
upper_limit = expected_receipt_qty * INBOUND_TOLERANCE_MULTIPLIER
if lower_limit <= receipt_qty <= upper_limit:
return
if not str(payload.settle_remark or "").strip():
raise HTTPException(status_code=400, detail="该工单结单入库成品数量超出标准成品数量上下15%,请填写结单说明")
@router.post("/completion-receipts", response_model=CompletionReceiptRead)
def create_completion_receipt(
payload: CompletionReceiptCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> CompletionReceiptRead:
work_order = db.get(WorkOrder, payload.work_order_id)
if not work_order:
raise HTTPException(status_code=404, detail="工单不存在")
if str(work_order.status or "").upper() == "SETTLED":
raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续成品入库")
warehouse = db.get(Warehouse, payload.warehouse_id)
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库")
if not payload.items:
raise HTTPException(status_code=400, detail="成品入库至少需要一条明细")
is_rework = _is_rework_work_order(work_order)
limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id)
max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty)
if max_receivable_qty <= 0:
detail = "返工工单当前没有可返工入库数量" if is_rework else "该工单当前没有可入库的完工数量"
raise HTTPException(status_code=400, detail=detail)
total_receipt_qty = Decimal("0")
for item_payload in payload.items:
product = db.get(Item, item_payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
if item_payload.product_item_id != work_order.product_item_id:
raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致")
receipt_qty = to_decimal(item_payload.receipt_qty)
if receipt_qty <= 0:
raise HTTPException(status_code=400, detail="入库数量必须大于0")
total_receipt_qty += receipt_qty
if is_rework:
if total_receipt_qty > max_receivable_qty:
raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}")
else:
validate_inbound_with_tolerance("成品入库数量", total_receipt_qty, max_receivable_qty)
if not is_rework:
_validate_finished_settle_request(
payload,
receipt_qty=total_receipt_qty,
expected_receipt_qty=max_receivable_qty,
)
elif payload.settle_work_order:
_settle_rework_work_order_if_ready(
db,
work_order,
incoming_finished_qty=total_receipt_qty,
settle_remark=payload.settle_remark,
)
if is_rework:
source_material = _build_rework_source_material_summary(db, payload.work_order_id)
else:
source_material = _build_work_order_source_material_summary(db, payload.work_order_id)
if not source_material["source_material_sub_batch_no"]:
raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录,不能进行成品入库")
ensure_employee_has_permission(
db,
payload.receiver_employee_id,
"MENU_INVENTORY_LEDGER",
"接收人",
required=False,
)
completion = CompletionReceipt(
receipt_no=_build_completion_receipt_no(db, work_order),
work_order_id=payload.work_order_id,
warehouse_id=payload.warehouse_id,
receipt_time=payload.receipt_time or datetime.now(),
receiver_employee_id=payload.receiver_employee_id,
status="POSTED",
remark=payload.remark,
)
db.add(completion)
db.flush()
remaining_receivable_qty = max_receivable_qty
for index, item_payload in enumerate(payload.items, start=1):
product = db.get(Item, item_payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
if item_payload.product_item_id != work_order.product_item_id:
raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致")
location = get_location_or_default(db, payload.warehouse_id, item_payload.location_id)
receipt_qty = to_decimal(item_payload.receipt_qty)
if receipt_qty <= 0:
raise HTTPException(status_code=400, detail="入库数量必须大于0")
gross_weight = _get_work_order_gross_weight_per_piece(db, work_order, product)
if gross_weight <= 0:
raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,无法计算成品入库材料成本")
receipt_weight = receipt_qty * to_decimal(product.unit_weight_kg)
unit_cost = _calculate_finished_material_cost_per_piece(source_material, gross_weight)
lot_no = _build_finished_lot_no(db, work_order, product, completion.receipt_time, index)
receipt_item = CompletionReceiptItem(
completion_receipt_id=completion.id,
line_no=index,
product_item_id=item_payload.product_item_id,
location_id=location.id if location else None,
lot_no=lot_no,
source_material_lot_id=source_material["source_material_lot_id"],
source_material_sub_batch_no=source_material["source_material_sub_batch_no"],
source_material_summary=source_material["source_material_summary"],
receipt_qty=receipt_qty,
receipt_weight_kg=receipt_weight,
unit_cost=unit_cost,
status="POSTED",
remark=item_payload.remark,
)
db.add(receipt_item)
db.flush()
lot = StockLot(
lot_no=lot_no,
parent_lot_id=source_material["source_material_lot_id"],
lot_role="FINISHED_FROM_RAW_BATCH",
material_sub_batch_no=None,
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=location.id if location else None,
source_doc_type="REWORK_RECEIPT" if is_rework else "FG_RECEIPT",
source_doc_id=completion.id,
source_line_id=receipt_item.id,
source_material_lot_id=source_material["source_material_lot_id"],
source_material_sub_batch_no=source_material["source_material_sub_batch_no"],
source_material_summary=source_material["source_material_summary"],
inbound_qty=receipt_qty,
inbound_weight_kg=receipt_weight,
remaining_qty=receipt_qty,
remaining_weight_kg=receipt_weight,
locked_qty=to_decimal(0),
locked_weight_kg=to_decimal(0),
unit_cost=unit_cost,
production_date=completion.receipt_time.date(),
expire_date=None,
quality_status="PASS",
status="AVAILABLE",
remark="返工入库;来源库存批次已记录" if is_rework else "完工入库;来源库存批次已记录",
)
db.add(lot)
db.flush()
upsert_stock_balance(
db,
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=location.id if location else None,
qty_delta=receipt_qty,
weight_delta=receipt_weight,
available_qty_delta=receipt_qty,
available_weight_delta=receipt_weight,
unit_cost=unit_cost,
)
create_inventory_txn(
db,
txn_type="REWORK_FINISHED_IN" if is_rework else "FG_IN",
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=location.id if location else None,
lot_id=lot.id,
qty_change=receipt_qty,
weight_change=receipt_weight,
unit_cost=unit_cost,
source_doc_type="WORK_ORDER" if is_rework else "FG_RECEIPT",
source_doc_id=work_order.id if is_rework else completion.id,
source_line_id=receipt_item.id,
biz_time=completion.receipt_time,
operator_user_id=context.user.id,
remark="返工入库" if is_rework else "完工入库",
amount_basis="QTY",
)
work_order.finished_qty = to_decimal(work_order.finished_qty) + receipt_qty
db.add(work_order)
remaining_receivable_qty -= receipt_qty
if payload.settle_work_order:
if not is_rework:
work_order.status = "SETTLED"
work_order.actual_end_time = work_order.actual_end_time or datetime.now()
work_order.updated_at = datetime.now()
_append_finished_work_order_settle_remark(work_order, payload.settle_remark)
db.add(work_order)
else:
sync_work_order_status(db, work_order.id)
db.commit()
row = db.execute(get_completion_receipts_query(limit=1).where(CompletionReceipt.id == completion.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="完工入库单创建后读取失败")
return CompletionReceiptRead.model_validate(dict(row))