from __future__ import annotations from collections import defaultdict from datetime import date, datetime, time, timedelta from decimal import Decimal, ROUND_FLOOR import re from fastapi import HTTPException from sqlalchemy import Integer, Select, and_, bindparam, case, func, literal, or_, select, text from sqlalchemy.orm import Session, aliased from app.models.document_archive import DocumentArchive from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Unit, Warehouse from app.models.operations import ( AccountingPeriod, CompletionReceipt, CompletionReceiptItem, CostAllocation, Delivery, DeliveryItem, Equipment, InventoryTxn, MaintenanceOrder, MaintenancePlan, OverheadEntry, ProcessRoute, ProcessRouteOperation, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, PurchaseReturn, PurchaseReturnItem, ReturnDisposition, ReturnItem, ReturnOrder, ScrapRecord, StatementSnapshot, StockLot, Supplier, WarehouseLocation, WorkCenter, WorkOrder, WorkOrderMaterialIssue, WorkOrderMaterial, WorkOrderOperation, OperationReport, ) from app.models.org import Department, Employee, User from app.models.planning import MaterialDemand from app.models.sales import Customer, SalesOrder, SalesOrderItem from app.schemas.operations import ( CostAllocationRead, ProductMaterialCostBoardRead, ProductMaterialCostExpenseRead, ProductMaterialCostRowRead, ProductMaterialCostSummaryRead, ) from app.services.document_archives import ( ARCHIVE_STATUS_MISSING, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, DOCUMENT_TYPE_WAREHOUSE_OPERATION, FILE_FORMAT_PDF, ) from app.services.sales_planning import code_token, decimalize, next_doc_no from app.services.system_config import get_raw_material_lot_prefix, lock_raw_material_lot_prefix_config def to_decimal(value: float | Decimal | int | None, places: str = "0.000001") -> Decimal: raw = value if value is not None else 0 return decimalize(raw, places) def start_of_day(value: date) -> datetime: return datetime.combine(value, time.min) def end_of_day(value: date) -> datetime: return datetime.combine(value, time.max) def clamp_non_negative(value: Decimal, places: str = "0.000001") -> Decimal: return max(Decimal("0"), value).quantize(Decimal(places)) def is_raw_material_item(db: Session, item_id: int) -> bool: item = db.get(Item, item_id) return bool(item and item.item_type == "RAW_MATERIAL") def is_weight_only_stock_context(db: Session, item_id: int, warehouse_id: int) -> bool: _ = item_id warehouse = db.get(Warehouse, warehouse_id) warehouse_type = str(warehouse.warehouse_type if warehouse else "").upper() return warehouse_type in {"RAW", "SCRAP"} def _raw_lot_sequence_width(next_sequence: int) -> int: return 4 if next_sequence <= 9999 else len(str(next_sequence)) def build_inventory_lot_no(db: Session, material_item_id: int) -> str: _ = material_item_id lock_raw_material_lot_prefix_config(db) prefix = get_raw_material_lot_prefix(db) existing_lot_nos = db.scalars( select(StockLot.lot_no).where(StockLot.lot_no.like(f"{prefix}%")) ).all() max_sequence = 0 pattern = re.compile(rf"{re.escape(prefix)}(\d+)") for lot_no in existing_lot_nos: match = pattern.fullmatch(str(lot_no)) if match: max_sequence = max(max_sequence, int(match.group(1))) sequence = max_sequence + 1 candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}" while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)): sequence += 1 candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}" return candidate def build_yearly_product_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_raw_lot_product_work_order_no(*, biz_time: datetime, lot_no: str, product: Item) -> str: date_part = biz_time.strftime("%Y%m%d") normalized_product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") product_name = normalized_product_name or str(product.item_code or f"产品{product.id}") prefix = f"{date_part}-{lot_no}-" max_product_name_length = max(80 - len(prefix), 0) return f"{prefix}{product_name[:max_product_name_length]}"[:80] def first_positive_decimal(*values: Decimal) -> Decimal: for value in values: if value > 0: return value return Decimal("0") FINANCE_EXPENSE_LAYOUT = [ ("material_usage_value", "使用原材料价值:"), ("material_loss_value", "不良率损耗:"), ("operator_wage", "操作工人工资:(按照报工系统核实)"), ("manager_wage", "管理人员工资:"), ("finance_charge", "财务费用:"), ("mould_maintenance", "模具维护成本:"), ("outsource_process", "(生产)制造费用 -外协加工费"), ("production_packaging", "(生产)制造费用-生产包装"), ("equipment_depreciation", "设备折旧费"), ("utility", "水电能耗:"), ("office_supplies", "管理费用-办公用品:"), ("factory_rent", "厂房租金:"), ("temporary_wage", "临时工工资:"), ("staff_welfare", "职工福利-餐费、饮品等"), ("social_insurance", "社保费:"), ("cash_misc", "现金支出的白条费用:"), ("other", "其它:"), ] OVERHEAD_TYPE_ALIASES = { "OPERATOR_WAGE": "operator_wage", "DIRECT_LABOR": "operator_wage", "LABOR": "operator_wage", "MANAGER_WAGE": "manager_wage", "MANAGER_SALARY": "manager_wage", "MANAGEMENT_WAGE": "manager_wage", "FINANCE_CHARGE": "finance_charge", "FINANCE_FEE": "finance_charge", "FINANCIAL_EXPENSE": "finance_charge", "MOULD_MAINTENANCE": "mould_maintenance", "MOLD_MAINTENANCE": "mould_maintenance", "OUTSOURCE_PROCESS": "outsource_process", "OUTSOURCE_FEE": "outsource_process", "PRODUCTION_PACKAGING": "production_packaging", "PACKAGING": "production_packaging", "EQUIPMENT_DEPRECIATION": "equipment_depreciation", "DEPRECIATION": "equipment_depreciation", "UTILITY": "utility", "WATER_ELECTRIC": "utility", "OFFICE_SUPPLIES": "office_supplies", "FACTORY_RENT": "factory_rent", "RENT": "factory_rent", "TEMPORARY_WAGE": "temporary_wage", "TEMP_WAGE": "temporary_wage", "STAFF_WELFARE": "staff_welfare", "WELFARE": "staff_welfare", "SOCIAL_INSURANCE": "social_insurance", "INSURANCE": "social_insurance", "CASH_MISC": "cash_misc", "CASH_WHITE_STRIP": "cash_misc", "OTHER": "other", } def _to_decimal_sum(value: float | Decimal | int | None, places: str = "0.000001") -> Decimal: return to_decimal(value, places) def _to_float(value: Decimal | float | int | None, places: str = "0.000001") -> float: return float(to_decimal(value, places)) def _normalize_overhead_type(value: str | None) -> str | None: if not value: return None normalized = str(value).strip().upper() return OVERHEAD_TYPE_ALIASES.get(normalized, "other") def _join_distinct(parts: list[str]) -> str | None: cleaned = [part for part in parts if part] if not cleaned: return None result: list[str] = [] seen: set[str] = set() for part in cleaned: if part in seen: continue seen.add(part) result.append(part) return " + ".join(result) def _to_candidate_datetime(value: date | datetime | None) -> datetime | None: if value is None: return None if isinstance(value, datetime): return value if isinstance(value, date): return datetime.combine(value, time.min) return None def _extract_overhead_effective_date(description: str | None) -> date | None: if not description: return None match = re.search(r"\[EXPENSE_DATE:(\d{4}-\d{2}-\d{2})\]", description) if not match: return None try: return date.fromisoformat(match.group(1)) except ValueError: return None def build_supplier_code(db: Session | None = None) -> str: date_part = date.today().strftime("%Y%m%d") prefix = f"SUP-NB-{date_part}" if db is None: return f"{prefix}-{datetime.now().strftime('%H%M%S')}" return next_doc_no(db, Supplier, "supplier_code", prefix) def get_default_location(db: Session, warehouse_id: int) -> WarehouseLocation | None: return db.scalar( select(WarehouseLocation) .where( WarehouseLocation.warehouse_id == warehouse_id, WarehouseLocation.status == "ACTIVE", ) .order_by(WarehouseLocation.is_default.desc(), WarehouseLocation.id) ) def get_location_or_default(db: Session, warehouse_id: int, location_id: int | None) -> WarehouseLocation | None: if location_id: location = db.get(WarehouseLocation, location_id) if not location: raise HTTPException(status_code=404, detail="库位不存在") return location return get_default_location(db, warehouse_id) def get_default_bom(db: Session, product_item_id: int) -> Bom: bom = db.scalar( select(Bom).where( Bom.product_item_id == product_item_id, Bom.is_default == 1, Bom.status == "ACTIVE", ) ) if not bom: raise HTTPException(status_code=400, detail="产品缺少默认 BOM") return bom def get_default_route(db: Session, product_item_id: int) -> ProcessRoute: route = db.scalar( select(ProcessRoute).where( ProcessRoute.product_item_id == product_item_id, ProcessRoute.is_default == 1, ProcessRoute.status == "ACTIVE", ) ) if not route: raise HTTPException(status_code=400, detail="产品缺少默认工艺路线") return route def get_stock_balance_record( db: Session, item_id: int, warehouse_id: int, location_id: int | None, ) -> StockBalance | None: stmt = select(StockBalance).where( StockBalance.item_id == item_id, StockBalance.warehouse_id == warehouse_id, ) if location_id is None: stmt = stmt.where(StockBalance.location_id.is_(None)) else: stmt = stmt.where(StockBalance.location_id == location_id) stmt = stmt.with_for_update() return db.scalar(stmt) def _assert_balance_delta_not_negative(current: Decimal, delta: Decimal, label: str) -> None: if to_decimal(current) + to_decimal(delta) < 0: raise HTTPException(status_code=400, detail=f"库存余额不足,不能执行出库扣减:{label}不足") def upsert_stock_balance( db: Session, *, item_id: int, warehouse_id: int, location_id: int | None, qty_delta: Decimal, weight_delta: Decimal, available_qty_delta: Decimal | None = None, available_weight_delta: Decimal | None = None, allocated_qty_delta: Decimal | None = None, allocated_weight_delta: Decimal | None = None, unit_cost: Decimal | None = None, ) -> StockBalance: weight_only_stock = is_weight_only_stock_context(db, item_id, warehouse_id) if weight_only_stock: qty_delta = Decimal("0") available_qty_delta = Decimal("0") if available_qty_delta is not None else None allocated_qty_delta = Decimal("0") if allocated_qty_delta is not None else None effective_available_qty_delta = available_qty_delta if available_qty_delta is not None else qty_delta effective_available_weight_delta = available_weight_delta if available_weight_delta is not None else weight_delta effective_allocated_qty_delta = allocated_qty_delta if allocated_qty_delta is not None else Decimal("0") effective_allocated_weight_delta = allocated_weight_delta if allocated_weight_delta is not None else Decimal("0") balance = get_stock_balance_record(db, item_id, warehouse_id, location_id) if balance is None: _assert_balance_delta_not_negative(Decimal("0"), qty_delta, "现存数量") _assert_balance_delta_not_negative(Decimal("0"), weight_delta, "现存重量") _assert_balance_delta_not_negative(Decimal("0"), effective_available_qty_delta, "可用数量") _assert_balance_delta_not_negative(Decimal("0"), effective_available_weight_delta, "可用重量") _assert_balance_delta_not_negative(Decimal("0"), effective_allocated_qty_delta, "已分配数量") _assert_balance_delta_not_negative(Decimal("0"), effective_allocated_weight_delta, "已分配重量") else: _assert_balance_delta_not_negative(to_decimal(balance.qty_on_hand), qty_delta, "现存数量") _assert_balance_delta_not_negative(to_decimal(balance.weight_on_hand_kg), weight_delta, "现存重量") _assert_balance_delta_not_negative(to_decimal(balance.qty_available), effective_available_qty_delta, "可用数量") _assert_balance_delta_not_negative( to_decimal(balance.weight_available_kg), effective_available_weight_delta, "可用重量", ) _assert_balance_delta_not_negative(to_decimal(balance.qty_allocated), effective_allocated_qty_delta, "已分配数量") _assert_balance_delta_not_negative( to_decimal(balance.weight_allocated_kg), effective_allocated_weight_delta, "已分配重量", ) if balance is None: balance = StockBalance( item_id=item_id, warehouse_id=warehouse_id, location_id=location_id, qty_on_hand=to_decimal(0), weight_on_hand_kg=to_decimal(0), qty_available=to_decimal(0), weight_available_kg=to_decimal(0), qty_allocated=to_decimal(0), weight_allocated_kg=to_decimal(0), avg_unit_cost=to_decimal(unit_cost or 0, "0.0001"), ) db.add(balance) db.flush() previous_weight = to_decimal(balance.weight_on_hand_kg) previous_cost = to_decimal(balance.avg_unit_cost, "0.0001") incoming_weight = max(Decimal("0"), weight_delta) incoming_cost = to_decimal(unit_cost or 0, "0.0001") balance.qty_on_hand = clamp_non_negative(to_decimal(balance.qty_on_hand) + qty_delta) balance.weight_on_hand_kg = clamp_non_negative(to_decimal(balance.weight_on_hand_kg) + weight_delta) balance.qty_available = clamp_non_negative(to_decimal(balance.qty_available) + effective_available_qty_delta) balance.weight_available_kg = clamp_non_negative( to_decimal(balance.weight_available_kg) + effective_available_weight_delta ) balance.qty_allocated = clamp_non_negative(to_decimal(balance.qty_allocated) + effective_allocated_qty_delta) balance.weight_allocated_kg = clamp_non_negative( to_decimal(balance.weight_allocated_kg) + effective_allocated_weight_delta ) if weight_only_stock: balance.qty_on_hand = Decimal("0") balance.qty_available = Decimal("0") balance.qty_allocated = Decimal("0") if incoming_weight > 0: total_weight = previous_weight + incoming_weight if total_weight > 0: weighted_cost = ((previous_weight * previous_cost) + (incoming_weight * incoming_cost)) / total_weight balance.avg_unit_cost = to_decimal(weighted_cost, "0.0001") elif unit_cost is not None and to_decimal(balance.avg_unit_cost, "0.0001") == 0: balance.avg_unit_cost = incoming_cost db.add(balance) return balance def create_inventory_txn( db: Session, *, txn_type: str, item_id: int, warehouse_id: int, location_id: int | None, lot_id: int | None, qty_change: Decimal, weight_change: Decimal, unit_cost: Decimal, source_doc_type: str, source_doc_id: int, source_line_id: int | None, biz_time: datetime, operator_user_id: int | None, remark: str | None = None, amount_basis: str = "QTY", logistics_waybill_no: str | None = None, logistics_freight_amount: Decimal | None = None, logistics_photo_url: str | None = None, ) -> InventoryTxn: if is_weight_only_stock_context(db, item_id, warehouse_id): qty_change = Decimal("0") if abs(weight_change) > 0: amount_basis = "WEIGHT" base_value = abs(weight_change) if amount_basis == "WEIGHT" and abs(weight_change) > 0 else abs(qty_change) amount = to_decimal(base_value * unit_cost, "0.01") txn = InventoryTxn( txn_no=next_doc_no( db, InventoryTxn, "txn_no", f"TXN-NB-{biz_time.strftime('%Y%m%d')}-{code_token(txn_type, 'BIZ', 10)}-{code_token(source_doc_type, 'SRC', 10)}", ), txn_type=txn_type, item_id=item_id, warehouse_id=warehouse_id, location_id=location_id, lot_id=lot_id, qty_change=qty_change, weight_change_kg=weight_change, unit_cost=to_decimal(unit_cost, "0.0001"), amount=amount, source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time, operator_user_id=operator_user_id, logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, remark=remark, ) db.add(txn) db.flush() return txn def issue_item_from_lots( db: Session, *, item_id: int, required_qty: Decimal, required_weight: Decimal, enforce_qty: bool, enforce_weight: bool, source_doc_id: int, source_line_id: int | None, biz_time: datetime, operator_user_id: int | None, remark: str | None = None, ) -> tuple[Decimal, Decimal, Decimal]: raw_material = is_raw_material_item(db, item_id) if raw_material: enforce_qty = False enforce_weight = required_weight > 0 remaining_weight = required_weight if enforce_weight else Decimal("0") remaining_qty = required_qty if enforce_qty else Decimal("0") total_qty = Decimal("0") total_weight = Decimal("0") total_amount = Decimal("0") lot_filters = [ StockLot.item_id == item_id, StockLot.quality_status == "PASS", StockLot.status == "AVAILABLE", StockLot.remaining_weight_kg > 0 if raw_material else or_(StockLot.remaining_qty > 0, StockLot.remaining_weight_kg > 0), ] if raw_material: lot_filters.append(StockLot.warehouse_id.in_(select(Warehouse.id).where(Warehouse.warehouse_type == "RAW"))) lots = db.scalars( select(StockLot) .where(*lot_filters) .order_by(StockLot.created_at, StockLot.id) ).all() for lot in lots: available_qty = Decimal("0") if raw_material else clamp_non_negative(to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty)) available_weight = clamp_non_negative( to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg) ) if (raw_material and available_weight <= 0) or (not raw_material and available_qty <= 0 and available_weight <= 0): continue if raw_material: take_weight = min(remaining_weight, available_weight) take_qty = Decimal("0") elif remaining_weight > 0 and available_weight > 0: take_weight = min(remaining_weight, available_weight) ratio = take_weight / available_weight if available_weight > 0 else Decimal("0") take_qty = available_qty * ratio if available_qty > 0 else Decimal("0") else: take_qty = min(remaining_qty, available_qty) ratio = take_qty / available_qty if available_qty > 0 else Decimal("0") take_weight = available_weight * ratio if available_weight > 0 else Decimal("0") take_qty = clamp_non_negative(take_qty) take_weight = clamp_non_negative(take_weight) if take_qty <= 0 and take_weight <= 0: continue lot.remaining_qty = Decimal("0") if raw_material else clamp_non_negative(to_decimal(lot.remaining_qty) - take_qty) lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - take_weight) if raw_material: lot.locked_qty = Decimal("0") if (raw_material and lot.remaining_weight_kg <= 0) or (not raw_material and lot.remaining_qty <= 0 and lot.remaining_weight_kg <= 0): lot.status = "DEPLETED" db.add(lot) upsert_stock_balance( db, item_id=item_id, warehouse_id=lot.warehouse_id, location_id=lot.location_id, qty_delta=-take_qty, weight_delta=-take_weight, available_qty_delta=-take_qty, available_weight_delta=-take_weight, unit_cost=to_decimal(lot.unit_cost, "0.0001"), ) txn = create_inventory_txn( db, txn_type="MATERIAL_ISSUE", item_id=item_id, warehouse_id=lot.warehouse_id, location_id=lot.location_id, lot_id=lot.id, qty_change=-take_qty, weight_change=-take_weight, unit_cost=to_decimal(lot.unit_cost, "0.0001"), source_doc_type="WORK_ORDER", source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time, operator_user_id=operator_user_id, remark=remark, amount_basis="WEIGHT" if take_weight > 0 else "QTY", ) material_sub_batch_no = lot.lot_no txn.remark = f"{remark or '生产领料'};库存批次号 {material_sub_batch_no}" db.add(txn) if source_line_id: db.add( WorkOrderMaterialIssue( work_order_material_id=source_line_id, work_order_id=source_doc_id, material_item_id=item_id, source_lot_id=lot.id, material_sub_batch_no=material_sub_batch_no, issued_qty=take_qty, issued_weight_kg=take_weight, unit_cost=to_decimal(lot.unit_cost, "0.0001"), amount=to_decimal(txn.amount, "0.01"), issue_time=biz_time, operator_user_id=operator_user_id, remark=remark, ) ) total_qty += take_qty total_weight += take_weight total_amount += to_decimal(txn.amount, "0.01") remaining_qty = clamp_non_negative(remaining_qty - take_qty) remaining_weight = clamp_non_negative(remaining_weight - take_weight) if remaining_qty <= 0 and remaining_weight <= 0: break if (enforce_qty and remaining_qty > 0) or (enforce_weight and remaining_weight > 0): raise HTTPException(status_code=400, detail="原材料可用库存不足,无法自动领料") return total_qty, total_weight, total_amount def issue_selected_stock_lots( db: Session, *, work_order_id: int, work_order_material_id: int, material_item_id: int, selected_lots: list[dict], operator_user_id: int | None, ) -> list[WorkOrderMaterialIssue]: if not selected_lots: raise HTTPException(status_code=400, detail="请选择至少一个库存批次") source_lot_ids = [int(row.get("source_lot_id") or 0) for row in selected_lots] if len(source_lot_ids) != len(set(source_lot_ids)): raise HTTPException(status_code=400, detail="库存批次重复选择") issues: list[WorkOrderMaterialIssue] = [] biz_time = datetime.now() for row in selected_lots: source_lot_id = int(row.get("source_lot_id") or 0) issued_weight = to_decimal(row.get("issued_weight_kg")) if issued_weight <= 0: raise HTTPException(status_code=400, detail="出库重量必须大于0") lot = db.scalar(select(StockLot).where(StockLot.id == source_lot_id).with_for_update()) 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="WORK_ORDER", source_doc_id=work_order_id, source_line_id=work_order_material_id, biz_time=biz_time, operator_user_id=operator_user_id, remark=f"生产出库:{lot.lot_no}", amount_basis="WEIGHT", ) db.add(txn) issue = WorkOrderMaterialIssue( work_order_material_id=work_order_material_id, work_order_id=work_order_id, material_item_id=material_item_id, source_lot_id=lot.id, material_sub_batch_no=lot.lot_no, issued_qty=Decimal("0"), issued_weight_kg=issued_weight, unit_cost=unit_cost, amount=to_decimal(issued_weight * unit_cost, "0.01"), issue_time=biz_time, operator_user_id=operator_user_id, remark=f"生产出库:{lot.lot_no}", ) db.add(issue) issues.append(issue) db.flush() return issues def sync_purchase_order_item_status(item: PurchaseOrderItem) -> None: ordered_qty = to_decimal(item.order_qty) received_qty = to_decimal(item.received_qty) ordered_weight = to_decimal(item.order_weight_kg) received_weight = to_decimal(item.received_weight_kg) if ordered_qty > 0: if received_qty >= ordered_qty: item.status = "CLOSED" elif received_qty > 0: item.status = "PARTIAL" else: item.status = "OPEN" elif ordered_weight > 0 and received_weight >= ordered_weight: item.status = "CLOSED" elif received_weight > 0: item.status = "PARTIAL" else: item.status = "OPEN" def recalc_purchase_order_item_received_weight(db: Session, purchase_order_item_id: int) -> PurchaseOrderItem | None: item = db.get(PurchaseOrderItem, purchase_order_item_id) if not item: return None accepted_qty = db.scalar( select(func.coalesce(func.sum(PurchaseReceiptItem.accepted_qty), 0)) .where( PurchaseReceiptItem.purchase_order_item_id == purchase_order_item_id, PurchaseReceiptItem.status == "PASSED", ) ) or Decimal("0") accepted_weight = db.scalar( select(func.coalesce(func.sum(PurchaseReceiptItem.accepted_weight_kg), 0)) .where( PurchaseReceiptItem.purchase_order_item_id == purchase_order_item_id, PurchaseReceiptItem.status == "PASSED", ) ) or Decimal("0") item.received_qty = to_decimal(accepted_qty) item.received_weight_kg = to_decimal(accepted_weight) sync_purchase_order_item_status(item) db.add(item) return item def sync_purchase_order_status(db: Session, purchase_order_id: int) -> None: order = db.get(PurchaseOrder, purchase_order_id) if not order: return if order.status == "LOCKED": db.add(order) return items = db.scalars( select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == purchase_order_id) ).all() pending_count = db.scalar( select(func.count(PurchaseReceiptItem.id)) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .where(PurchaseReceipt.purchase_order_id == purchase_order_id, PurchaseReceiptItem.status == "PENDING_QC") ) or 0 rejected_count = db.scalar( select(func.count(PurchaseReceiptItem.id)) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .where(PurchaseReceipt.purchase_order_id == purchase_order_id, PurchaseReceiptItem.status == "REJECTED") ) or 0 if pending_count > 0: order.status = "PENDING_QC" elif items and all(item.status == "CLOSED" for item in items): order.status = "RECEIVED" elif any(item.status in {"PARTIAL", "CLOSED"} for item in items): order.status = "PARTIAL" elif rejected_count > 0: order.status = "REJECTED" elif order.status in {"LOCKED", "PENDING_QC", "PARTIAL", "REJECTED", "RECEIVED"}: order.status = "LOCKED" else: order.status = "OPEN" db.add(order) def sync_sales_order_status(db: Session, sales_order_id: int) -> None: if not sales_order_id: return order = db.get(SalesOrder, sales_order_id) if not order: return items = db.scalars(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == sales_order_id)).all() if items and all(to_decimal(item.delivered_qty) >= to_decimal(item.order_qty) for item in items): order.status = "CLOSED" elif any(to_decimal(item.delivered_qty) > 0 for item in items): order.status = "PARTIAL" else: order.status = "OPEN" db.add(order) def sync_return_order_status(db: Session, return_order_id: int) -> None: order = db.get(ReturnOrder, return_order_id) if not order: return items = db.scalars(select(ReturnItem).where(ReturnItem.return_order_id == return_order_id)).all() if items and all(item.disposition_status == "CLOSED" for item in items): order.status = "CLOSED" elif any(item.disposition_status != "PENDING" for item in items): order.status = "PARTIAL" else: order.status = "CREATED" db.add(order) def sync_work_order_status(db: Session, work_order_id: int) -> None: work_order = db.get(WorkOrder, work_order_id) if not work_order: return if str(work_order.status or "").upper() == "SETTLED": return planned_qty = to_decimal(work_order.planned_qty) finished_qty = to_decimal(work_order.finished_qty) operation_count = db.scalar( select(func.count(WorkOrderOperation.id)).where(WorkOrderOperation.work_order_id == work_order_id) ) or 0 done_count = db.scalar( select(func.count(WorkOrderOperation.id)).where( WorkOrderOperation.work_order_id == work_order_id, WorkOrderOperation.status == "DONE", ) ) or 0 if finished_qty >= planned_qty and planned_qty > 0: work_order.status = "COMPLETED" work_order.actual_end_time = work_order.actual_end_time or datetime.now() elif done_count == operation_count and operation_count > 0: work_order.status = "READY_TO_RECEIPT" elif work_order.actual_start_time is not None or done_count > 0: work_order.status = "IN_PROGRESS" elif to_decimal(work_order.released_qty) > 0: work_order.status = "RELEASED" else: work_order.status = "CREATED" db.add(work_order) def calculate_work_order_reference_qty(db: Session, work_order: WorkOrder) -> Decimal | None: material_rows = db.execute( select( WorkOrderMaterial.id.label("work_order_material_id"), WorkOrderMaterial.issued_weight_kg.label("issued_weight_kg"), WorkOrderMaterial.required_weight_kg.label("required_weight_kg"), BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"), Item.unit_weight_kg.label("product_unit_weight_kg"), ) .join(BomItem, BomItem.id == WorkOrderMaterial.bom_item_id) .join(WorkOrder, WorkOrder.id == WorkOrderMaterial.work_order_id) .join(Item, Item.id == WorkOrder.product_item_id) .where(WorkOrderMaterial.work_order_id == work_order.id) .order_by(BomItem.seq_no, WorkOrderMaterial.id) ).mappings().all() material_row = next((row for row in material_rows if to_decimal(row["issued_weight_kg"]) > 0), None) if not material_row and material_rows: material_row = material_rows[0] if not material_row: return None issue_weight = to_decimal(material_row["issued_weight_kg"]) if issue_weight <= 0: issue_weight = to_decimal(material_row["required_weight_kg"]) gross_weight = to_decimal(material_row["gross_weight_per_piece_kg"]) if gross_weight <= 0: gross_weight = to_decimal(material_row["product_unit_weight_kg"]) if issue_weight <= 0 or gross_weight <= 0: return None return (issue_weight / gross_weight).to_integral_value(rounding=ROUND_FLOOR) def sync_work_order_reference_qty(db: Session, work_order_id: int) -> bool: work_order = db.get(WorkOrder, work_order_id) if not work_order: return False reference_qty = calculate_work_order_reference_qty(db, work_order) if reference_qty is None or reference_qty <= 0: return False current_qty = to_decimal(work_order.planned_qty) changed = current_qty != reference_qty or to_decimal(work_order.released_qty) != reference_qty if changed: work_order.planned_qty = reference_qty work_order.released_qty = reference_qty db.add(work_order) operations = db.scalars(select(WorkOrderOperation).where(WorkOrderOperation.work_order_id == work_order_id)).all() for operation in operations: if to_decimal(operation.planned_qty) != reference_qty: operation.planned_qty = reference_qty changed = True report_qty = to_decimal(operation.report_qty) next_status = "DONE" if report_qty >= reference_qty else "IN_PROGRESS" if report_qty > 0 else "PENDING" if operation.status != next_status: operation.status = next_status changed = True db.add(operation) material_rows = db.execute( select(WorkOrderMaterial, BomItem) .join(BomItem, BomItem.id == WorkOrderMaterial.bom_item_id) .where(WorkOrderMaterial.work_order_id == work_order_id) .order_by(BomItem.seq_no, WorkOrderMaterial.id) ).all() for material_row, bom_item in material_rows: issued_weight = to_decimal(material_row.issued_weight_kg) expected_required_weight = issued_weight if issued_weight > 0 else reference_qty * to_decimal(bom_item.gross_weight_per_piece_kg) expected_required_weight = to_decimal(expected_required_weight) if to_decimal(material_row.required_weight_kg) != expected_required_weight: material_row.required_weight_kg = expected_required_weight changed = True db.add(material_row) if changed: sync_work_order_status(db, work_order_id) return changed def sync_recent_work_order_reference_qty(db: Session, limit: int = 500) -> bool: work_order_ids = db.scalars(select(WorkOrder.id).order_by(WorkOrder.id.desc()).limit(limit)).all() changed = False for work_order_id in work_order_ids: changed = sync_work_order_reference_qty(db, work_order_id) or changed return changed def calculate_work_order_cost_summary(db: Session, work_order_id: int) -> dict[str, Decimal]: work_order = db.get(WorkOrder, work_order_id) if not work_order: raise HTTPException(status_code=404, detail="工单不存在") material_amount = to_decimal( db.scalar( select(func.coalesce(func.sum(InventoryTxn.amount), 0)).where( InventoryTxn.txn_type == "MATERIAL_ISSUE", InventoryTxn.source_doc_type == "WORK_ORDER", InventoryTxn.source_doc_id == work_order_id, ) ), "0.01", ) active_scrap_record_ids = ( select(func.max(ScrapRecord.id).label("scrap_record_id")) .where(ScrapRecord.status != "OVERRIDDEN") .group_by(ScrapRecord.report_id) .subquery() ) operations = db.scalars( select(WorkOrderOperation).where(WorkOrderOperation.work_order_id == work_order_id) ).all() labor_amount = Decimal("0") machine_amount = Decimal("0") for operation in operations: report_qty = to_decimal(operation.report_qty) labor_amount += report_qty * to_decimal(operation.std_labor_cost, "0.0001") machine_amount += report_qty * to_decimal(operation.std_machine_cost, "0.0001") rework_labor_amount = to_decimal( db.scalar( select(func.coalesce(func.sum(ScrapRecord.scrap_qty * WorkOrderOperation.std_labor_cost), 0)) .join(WorkOrderOperation, WorkOrderOperation.id == ScrapRecord.work_order_operation_id) .where(ScrapRecord.id.in_(select(active_scrap_record_ids.c.scrap_record_id))) .where( ScrapRecord.work_order_id == work_order_id, ScrapRecord.disposal_type == "REWORK", ScrapRecord.status == "CONFIRMED", ) ), "0.01", ) labor_amount += rework_labor_amount abnormal_loss_amount = to_decimal( db.scalar( select(func.coalesce(func.sum(ScrapRecord.scrap_cost_amount), 0)).where( ScrapRecord.id.in_(select(active_scrap_record_ids.c.scrap_record_id)), ScrapRecord.work_order_id == work_order_id, ScrapRecord.status == "CONFIRMED", ) ), "0.01", ) abnormal_loss_amount += to_decimal( db.scalar( select(func.coalesce(func.sum(OperationReport.extra_cost_amount), 0)).where( OperationReport.work_order_id == work_order_id, OperationReport.is_abnormal == 1, ) ), "0.01", ) total_cost_amount = material_amount + labor_amount + machine_amount + abnormal_loss_amount finished_qty = to_decimal(work_order.finished_qty) base_qty = finished_qty if finished_qty > 0 else to_decimal(work_order.planned_qty) unit_cost = (total_cost_amount / base_qty) if base_qty > 0 else Decimal("0") return { "material_amount": to_decimal(material_amount, "0.01"), "labor_amount": to_decimal(labor_amount, "0.01"), "machine_amount": to_decimal(machine_amount, "0.01"), "abnormal_loss_amount": to_decimal(abnormal_loss_amount, "0.01"), "total_cost_amount": to_decimal(total_cost_amount, "0.01"), "unit_cost": to_decimal(unit_cost, "0.0001"), } def create_work_order_materials_and_operations( db: Session, *, work_order: WorkOrder, bom: Bom, route: ProcessRoute, auto_issue_materials: bool, operator_user_id: int | None, issue_weight_override: Decimal | None = None, ) -> None: bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all() planned_qty = to_decimal(work_order.planned_qty) for index, bom_item in enumerate(bom_items): required_qty = Decimal("0") required_weight = planned_qty * to_decimal(bom_item.gross_weight_per_piece_kg) if index == 0 and issue_weight_override is not None and issue_weight_override > 0: required_weight = to_decimal(issue_weight_override) enforce_qty = False enforce_weight = required_weight > 0 material_row = WorkOrderMaterial( work_order_id=work_order.id, bom_item_id=bom_item.id, material_item_id=bom_item.material_item_id, required_qty=required_qty, required_weight_kg=required_weight, issued_qty=to_decimal(0), issued_weight_kg=to_decimal(0), returned_qty=to_decimal(0), returned_weight_kg=to_decimal(0), status="OPEN", remark=None, ) db.add(material_row) db.flush() if auto_issue_materials and required_weight > 0: issued_qty, issued_weight, _ = issue_item_from_lots( db, item_id=bom_item.material_item_id, required_qty=required_qty, required_weight=required_weight, enforce_qty=enforce_qty, enforce_weight=enforce_weight, source_doc_id=work_order.id, source_line_id=material_row.id, biz_time=work_order.planned_start_time or datetime.now(), operator_user_id=operator_user_id, remark=f"工单 {work_order.work_order_no} 自动领料", ) material_row.issued_qty = issued_qty material_row.issued_weight_kg = issued_weight material_row.status = "ISSUED" db.add(material_row) route_ops = db.scalars( select(ProcessRouteOperation) .where(ProcessRouteOperation.route_id == route.id, ProcessRouteOperation.status == "ACTIVE") .order_by(ProcessRouteOperation.seq_no) ).all() for route_op in route_ops: db.add( WorkOrderOperation( work_order_id=work_order.id, route_operation_id=route_op.id, seq_no=route_op.seq_no, operation_name=route_op.operation_name, work_center_id=route_op.work_center_id, planned_qty=planned_qty, report_qty=to_decimal(0), good_qty=to_decimal(0), scrap_qty=to_decimal(0), rework_qty=to_decimal(0), std_labor_cost=to_decimal(route_op.std_labor_cost, "0.0001"), std_machine_cost=to_decimal(route_op.std_machine_cost, "0.0001"), std_outsource_cost=to_decimal(route_op.std_outsource_cost, "0.0001"), allowed_scrap_rate=to_decimal(route_op.allowed_scrap_rate, "0.0001"), is_abnormal=0, status="PENDING", remark=None, ) ) def generate_cost_allocations(db: Session, period_id: int) -> list[CostAllocationRead]: period = db.get(AccountingPeriod, period_id) if not period: raise HTTPException(status_code=404, detail="财务期间不存在") period_start = start_of_day(period.start_date) period_end = end_of_day(period.end_date) total_overhead = to_decimal( db.scalar(select(func.coalesce(func.sum(OverheadEntry.amount), 0)).where(OverheadEntry.period_id == period_id)), "0.01", ) db.query(CostAllocation).filter(CostAllocation.period_id == period_id).delete() db.flush() work_orders = db.scalars( select(WorkOrder).where( or_( and_(WorkOrder.actual_end_time.is_not(None), WorkOrder.actual_end_time.between(period_start, period_end)), WorkOrder.updated_at.between(period_start, period_end), ) ) ).all() if not work_orders: return [] basis_map: dict[int, Decimal] = {} total_basis = Decimal("0") for work_order in work_orders: basis = to_decimal(work_order.finished_qty) if basis <= 0: basis = to_decimal(work_order.released_qty) if basis <= 0: basis = to_decimal(work_order.planned_qty) basis_map[work_order.id] = basis total_basis += basis rows: list[CostAllocation] = [] for work_order in work_orders: product = db.get(Item, work_order.product_item_id) if not product: continue basis = basis_map[work_order.id] cost_summary = calculate_work_order_cost_summary(db, work_order.id) overhead_amount = (total_overhead * basis / total_basis) if total_basis > 0 else Decimal("0") total_cost = cost_summary["total_cost_amount"] + overhead_amount base_qty = to_decimal(work_order.finished_qty) if base_qty <= 0: base_qty = to_decimal(work_order.planned_qty) allocation = CostAllocation( period_id=period_id, work_order_id=work_order.id, product_item_id=work_order.product_item_id, allocation_basis_value=basis, overhead_amount=to_decimal(overhead_amount, "0.01"), material_amount=cost_summary["material_amount"], labor_amount=cost_summary["labor_amount"], machine_amount=cost_summary["machine_amount"], abnormal_loss_amount=cost_summary["abnormal_loss_amount"], total_cost_amount=to_decimal(total_cost, "0.01"), unit_cost=to_decimal((total_cost / base_qty) if base_qty > 0 else 0, "0.0001"), ) db.add(allocation) rows.append(allocation) db.flush() return get_cost_allocation_rows(db, period_id=period_id, limit=500) def build_statement_summary(db: Session, period_id: int, report_date: date) -> dict: overhead_total = float( db.scalar(select(func.coalesce(func.sum(OverheadEntry.amount), 0)).where(OverheadEntry.period_id == period_id)) or 0 ) allocation_total = float( db.scalar(select(func.coalesce(func.sum(CostAllocation.total_cost_amount), 0)).where(CostAllocation.period_id == period_id)) or 0 ) statement_rows = db.execute( select( Item.item_type.label("item_type"), func.count(StockBalance.id).label("line_count"), func.coalesce(func.sum(StockBalance.qty_on_hand), 0).label("qty_on_hand"), func.coalesce(func.sum(StockBalance.weight_on_hand_kg), 0).label("weight_on_hand_kg"), func.coalesce( func.sum( case( (Item.item_type == "RAW_MATERIAL", StockBalance.avg_unit_cost * StockBalance.weight_on_hand_kg), else_=StockBalance.avg_unit_cost * StockBalance.qty_on_hand, ) ), 0, ).label("stock_amount"), ) .join(Item, Item.id == StockBalance.item_id) .group_by(Item.item_type) ).mappings() inventory_summary = [dict(row) for row in statement_rows] return { "report_date": report_date.isoformat(), "overhead_total": overhead_total, "allocation_total": allocation_total, "inventory_summary": inventory_summary, } def _build_finance_expense_rows(expense_amounts: dict[str, Decimal]) -> list[ProductMaterialCostExpenseRead]: return [ ProductMaterialCostExpenseRead( key=key, label=label, amount=_to_float(expense_amounts.get(key, Decimal("0")), "0.01"), ) for key, label in FINANCE_EXPENSE_LAYOUT ] def build_product_material_cost_board( db: Session, *, date_from: date, date_to: date, ) -> ProductMaterialCostBoardRead: if date_to < date_from: raise HTTPException(status_code=400, detail="结束日期不能早于开始日期") start_dt = start_of_day(date_from) end_dt = end_of_day(date_to) base_rows = db.execute( text( """ select wo.id as work_order_id, wo.work_order_no, wo.created_at, wo.updated_at, wo.actual_end_time, wo.planned_qty, wo.finished_qty, wo.scrap_qty, wo.status, wo.source_sales_order_item_id, prod_item.id as product_item_id, prod_item.item_code as product_code, prod_item.item_name as product_name, prod_item.unit_weight_kg as product_unit_weight_kg, so_item.order_qty as order_qty, so_item.unit_price as unit_price, so_item.customer_part_no as customer_part_no, so.order_no as source_order_no, so.order_date, customer.customer_name from pp_work_order wo join md_item prod_item on prod_item.id = wo.product_item_id left join so_sales_order_item so_item on so_item.id = wo.source_sales_order_item_id left join so_sales_order so on so.id = so_item.sales_order_id left join md_customer customer on customer.id = so.customer_id order by wo.id desc """ ) ).mappings().all() work_order_ids = [int(row["work_order_id"]) for row in base_rows] if not work_order_ids: empty_expenses = _build_finance_expense_rows({key: Decimal("0") for key, _ in FINANCE_EXPENSE_LAYOUT}) return ProductMaterialCostBoardRead( title="冲压产品材料成本核算表", date_from=date_from, date_to=date_to, generated_at=datetime.now(), rows=[], expense_rows=empty_expenses, summary=ProductMaterialCostSummaryRead( row_count=0, sales_total=0, gross_material_total=0, scrap_revenue_total=0, material_cost_total=0, material_loss_total=0, labor_total=0, outsource_total=0, rework_total=0, scrap_total=0, overhead_total=0, direct_cost_total=0, profit_amount=0, cost_ratio=0, ), ) material_stmt = text( """ select wom.work_order_id, wom.material_item_id, wom.required_weight_kg, wom.issued_weight_kg, bi.gross_weight_per_piece_kg, bi.net_weight_per_piece_kg, bi.loss_rate, material_item.item_code as material_code, material_item.item_name as material_name, material_item.material_grade as material_grade, material_item.scrap_sale_price as material_scrap_sale_price from pp_work_order_material wom join md_bom_item bi on bi.id = wom.bom_item_id join md_item material_item on material_item.id = wom.material_item_id where wom.work_order_id in :work_order_ids order by wom.work_order_id, wom.id """ ).bindparams(bindparam("work_order_ids", expanding=True)) material_rows = db.execute(material_stmt, {"work_order_ids": work_order_ids}).mappings().all() materials_by_work_order: dict[int, list[dict]] = defaultdict(list) material_item_ids: set[int] = set() for row in material_rows: payload = dict(row) materials_by_work_order[int(payload["work_order_id"])].append(payload) material_item_ids.add(int(payload["material_item_id"])) report_stmt = text( """ select r.work_order_id, coalesce(sum(r.report_qty), 0) as report_qty, coalesce(sum(r.good_qty), 0) as good_qty, coalesce(sum(r.scrap_qty), 0) as scrap_qty, coalesce(sum(r.rework_qty), 0) as rework_qty, coalesce(sum(r.extra_cost_amount), 0) as extra_cost_amount, max(r.end_time) as last_report_time from pp_operation_report r where r.work_order_id in :work_order_ids and r.end_time <= :end_dt group by r.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) report_rows = db.execute(report_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() report_map = {int(row["work_order_id"]): dict(row) for row in report_rows} final_report_stmt = text( """ select final_operation.work_order_id, coalesce(sum(r.report_qty), 0) as report_qty, coalesce(sum(r.good_qty), 0) as good_qty, coalesce(sum(r.scrap_qty), 0) as scrap_qty, coalesce(sum(r.rework_qty), 0) as rework_qty, max(r.end_time) as last_report_time from ( select woo.work_order_id, woo.id as work_order_operation_id from pp_work_order_operation woo join ( select work_order_id, max(seq_no) as max_seq_no from pp_work_order_operation where work_order_id in :work_order_ids group by work_order_id ) final_seq on final_seq.work_order_id = woo.work_order_id and final_seq.max_seq_no = woo.seq_no ) final_operation left join pp_operation_report r on r.work_order_operation_id = final_operation.work_order_operation_id and r.end_time <= :end_dt group by final_operation.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) final_report_rows = db.execute( final_report_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}, ).mappings().all() final_report_map = {int(row["work_order_id"]): dict(row) for row in final_report_rows} operation_cost_stmt = text( """ select r.work_order_id, coalesce(sum(r.report_qty * woo.std_labor_cost), 0) as labor_amount, coalesce(sum(r.report_qty * woo.std_outsource_cost), 0) as outsource_amount from pp_operation_report r join pp_work_order_operation woo on woo.id = r.work_order_operation_id where r.work_order_id in :work_order_ids and r.end_time <= :end_dt group by r.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) operation_cost_rows = db.execute( operation_cost_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt} ).mappings().all() operation_cost_map = {int(row["work_order_id"]): dict(row) for row in operation_cost_rows} receipt_stmt = text( """ select cr.work_order_id, coalesce(sum(cri.receipt_qty), 0) as receipt_qty, max(cr.receipt_time) as last_receipt_time, group_concat(distinct cr.receipt_no separator ', ') as receipt_nos from pp_completion_receipt cr join pp_completion_receipt_item cri on cri.completion_receipt_id = cr.id where cr.work_order_id in :work_order_ids and cr.receipt_time <= :end_dt group by cr.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) receipt_rows = db.execute(receipt_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() receipt_map = {int(row["work_order_id"]): dict(row) for row in receipt_rows} delivery_stmt = text( """ select cr.work_order_id, coalesce(sum(di.delivery_qty), 0) as delivery_qty, max(d.delivery_date) as last_delivery_date, group_concat(distinct d.delivery_no separator ', ') as delivery_nos from so_delivery_item di join so_delivery d on d.id = di.delivery_id join wh_stock_lot lot on lot.id = di.lot_id and lot.source_doc_type = 'FG_RECEIPT' join pp_completion_receipt_item cri on cri.id = lot.source_line_id join pp_completion_receipt cr on cr.id = cri.completion_receipt_id and lot.source_doc_id = cr.id where cr.work_order_id in :work_order_ids and d.delivery_date <= :end_dt group by cr.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) delivery_rows = db.execute(delivery_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() delivery_map = {int(row["work_order_id"]): dict(row) for row in delivery_rows} scrap_stmt = text( """ select sr.work_order_id, coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.scrap_qty else 0 end), 0) as scrap_qty, coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.scrap_weight_kg else 0 end), 0) as scrap_weight_kg, coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.saleable_scrap_weight_kg else 0 end), 0) as saleable_scrap_weight_kg, coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.scrap_cost_amount else 0 end), 0) as scrap_cost_amount, coalesce(sum(case when sr.disposal_type = 'REWORK' then sr.scrap_qty else 0 end), 0) as rework_qty, coalesce(sum(case when sr.disposal_type = 'REWORK' then sr.scrap_cost_amount else 0 end), 0) as rework_cost_amount, max(sr.created_at) as last_scrap_time from pp_scrap_record sr join ( select max(id) as id from pp_scrap_record where status <> 'OVERRIDDEN' and created_at <= :end_dt group by report_id ) active_sr on active_sr.id = sr.id where sr.work_order_id in :work_order_ids and sr.status <> 'OVERRIDDEN' and sr.created_at <= :end_dt group by sr.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) scrap_rows = db.execute(scrap_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() scrap_map = {int(row["work_order_id"]): dict(row) for row in scrap_rows} return_disposition_stmt = text( """ select mapped.work_order_id, coalesce(sum(case when mapped.disposition_type = 'REWORK' then mapped.return_qty else 0 end), 0) as return_rework_qty, coalesce(sum(case when mapped.disposition_type = 'REWORK' then mapped.extra_cost_amount else 0 end), 0) as return_rework_cost_amount, coalesce(sum(case when mapped.disposition_type = 'SCRAP' then mapped.return_qty else 0 end), 0) as return_scrap_qty, coalesce(sum(case when mapped.disposition_type = 'SCRAP' then mapped.scrap_weight_kg else 0 end), 0) as return_scrap_weight_kg, coalesce(sum(case when mapped.disposition_type = 'SCRAP' then mapped.scrap_sale_amount else 0 end), 0) as return_scrap_sale_amount, max(mapped.closed_at) as last_return_disposition_time from ( select coalesce(cr.work_order_id, source_wo.id) as work_order_id, rd.disposition_type, ri.return_qty, rd.extra_cost_amount, rd.scrap_weight_kg, rd.scrap_sale_amount, rd.closed_at from rt_return_disposition rd join rt_return_item ri on ri.id = rd.return_item_id left join wh_stock_lot source_lot on source_lot.id = ri.source_lot_id and source_lot.source_doc_type = 'FG_RECEIPT' left join pp_completion_receipt_item cri on cri.id = source_lot.source_line_id left join pp_completion_receipt cr on cr.id = cri.completion_receipt_id left join pp_work_order source_wo on source_wo.source_sales_order_item_id = ri.sales_order_item_id where rd.status = 'CLOSED' and rd.closed_at <= :end_dt ) mapped where mapped.work_order_id in :work_order_ids group by mapped.work_order_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) return_disposition_rows = db.execute( return_disposition_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}, ).mappings().all() return_disposition_map = {int(row["work_order_id"]): dict(row) for row in return_disposition_rows} issue_stmt = text( """ select txn.source_doc_id as work_order_id, txn.item_id as material_item_id, coalesce(sum(abs(txn.weight_change_kg)), 0) as issued_weight_kg, coalesce(sum(abs(txn.amount)), 0) as issued_amount from wh_inventory_txn txn where txn.source_doc_type = 'WORK_ORDER' and txn.txn_type = 'MATERIAL_ISSUE' and txn.source_doc_id in :work_order_ids and txn.biz_time <= :end_dt group by txn.source_doc_id, txn.item_id """ ).bindparams(bindparam("work_order_ids", expanding=True)) issue_rows = db.execute(issue_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() issue_map: dict[tuple[int, int], dict] = {} for row in issue_rows: issue_map[(int(row["work_order_id"]), int(row["material_item_id"]))] = dict(row) material_stock_map: dict[int, dict] = {} balance_cost_map: dict[int, Decimal] = {} if material_item_ids: material_item_list = sorted(material_item_ids) material_stat_stmt = text( """ select txn.item_id, coalesce(sum(case when txn.txn_type = 'PURCHASE_IN' then abs(txn.weight_change_kg) else 0 end), 0) as purchased_weight_kg, coalesce(sum(case when txn.txn_type = 'PURCHASE_IN' then abs(txn.amount) else 0 end), 0) as purchased_amount, coalesce(sum(case when txn.txn_type = 'MATERIAL_ISSUE' then abs(txn.weight_change_kg) else 0 end), 0) as issued_weight_kg from wh_inventory_txn txn where txn.item_id in :material_item_ids and txn.txn_type in ('PURCHASE_IN', 'MATERIAL_ISSUE') and txn.biz_time <= :end_dt group by txn.item_id """ ).bindparams(bindparam("material_item_ids", expanding=True)) material_stat_rows = db.execute( material_stat_stmt, {"material_item_ids": material_item_list, "end_dt": end_dt}, ).mappings().all() material_stock_map = {int(row["item_id"]): dict(row) for row in material_stat_rows} balance_cost_stmt = text( """ select sb.item_id, case when sum(sb.weight_on_hand_kg) > 0 then sum(sb.avg_unit_cost * sb.weight_on_hand_kg) / sum(sb.weight_on_hand_kg) else avg(sb.avg_unit_cost) end as avg_unit_cost from wh_stock_balance sb where sb.item_id in :material_item_ids group by sb.item_id """ ).bindparams(bindparam("material_item_ids", expanding=True)) for row in db.execute(balance_cost_stmt, {"material_item_ids": material_item_list}).mappings().all(): balance_cost_map[int(row["item_id"])] = to_decimal(row["avg_unit_cost"], "0.0001") overhead_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) overhead_rows = db.execute( text( """ select oe.overhead_type, oe.amount, oe.description, ap.start_date, ap.end_date from fi_overhead_entry oe join fi_accounting_period ap on ap.id = oe.period_id where ap.end_date >= :date_from and ap.start_date <= :date_to """ ), {"date_from": date_from, "date_to": date_to}, ).mappings().all() for row in overhead_rows: normalized_key = _normalize_overhead_type(row["overhead_type"]) or "other" effective_date = _extract_overhead_effective_date(row.get("description")) if effective_date: if date_from <= effective_date <= date_to: overhead_amounts[normalized_key] += to_decimal(row["amount"], "0.01") continue period_start = row["start_date"] period_end = row["end_date"] if not period_start or not period_end: continue overlap_start = max(period_start, date_from) overlap_end = min(period_end, date_to) if overlap_start > overlap_end: continue total_days = max((period_end - period_start).days + 1, 1) overlap_days = (overlap_end - overlap_start).days + 1 prorated_amount = to_decimal(row["amount"], "0.01") * Decimal(overlap_days) / Decimal(total_days) overhead_amounts[normalized_key] += to_decimal(prorated_amount, "0.01") maintenance_cost_rows = db.execute( text( """ select maintenance_type, coalesce(sum(cost_amount), 0) as cost_amount from em_maintenance_order where status = 'COMPLETED' and coalesce(end_time, start_time, created_at) >= :start_dt and coalesce(end_time, start_time, created_at) <= :end_dt group by maintenance_type """ ), {"start_dt": start_dt, "end_dt": end_dt}, ).mappings().all() for row in maintenance_cost_rows: maintenance_type = str(row.get("maintenance_type") or "").upper() target_key = "mould_maintenance" if any(token in maintenance_type for token in ("MOULD", "MOLD", "模具")) else "other" overhead_amounts[target_key] += to_decimal(row.get("cost_amount"), "0.01") board_rows: list[ProductMaterialCostRowRead] = [] row_payloads: list[dict] = [] for base_row in base_rows: work_order_id = int(base_row["work_order_id"]) report_summary = report_map.get(work_order_id, {}) final_report_summary = final_report_map.get(work_order_id, {}) receipt_summary = receipt_map.get(work_order_id, {}) delivery_summary = delivery_map.get(work_order_id, {}) scrap_summary = scrap_map.get(work_order_id, {}) return_disposition_summary = return_disposition_map.get(work_order_id, {}) operation_cost_summary = operation_cost_map.get(work_order_id, {}) relevant_dates: list[datetime] = [] if base_row.get("order_date") and date_from <= base_row["order_date"] <= date_to: relevant_dates.append(datetime.combine(base_row["order_date"], time.min)) for candidate in ( report_summary.get("last_report_time"), receipt_summary.get("last_receipt_time"), delivery_summary.get("last_delivery_date"), return_disposition_summary.get("last_return_disposition_time"), ): candidate_dt = _to_candidate_datetime(candidate) if candidate_dt and start_dt <= candidate_dt <= end_dt: relevant_dates.append(candidate_dt) if base_row.get("created_at") and start_dt <= base_row["created_at"] <= end_dt: relevant_dates.append(base_row["created_at"]) if base_row.get("updated_at") and start_dt <= base_row["updated_at"] <= end_dt: relevant_dates.append(base_row["updated_at"]) if not relevant_dates: continue work_order_material_rows = materials_by_work_order.get(work_order_id, []) material_codes: list[str] = [] material_names: list[str] = [] material_grades: list[str] = [] gross_weight_kg = Decimal("0") net_weight_kg = Decimal("0") loss_rate = Decimal("0") purchase_material_weight = Decimal("0") used_material_weight = Decimal("0") material_inventory_weight = Decimal("0") material_inventory_value = Decimal("0") material_price_weight_total = Decimal("0") weighted_material_cost_sum = Decimal("0") scrap_price_weight_total = Decimal("0") weighted_scrap_sale_price_sum = Decimal("0") for material_row in work_order_material_rows: material_item_id = int(material_row["material_item_id"]) material_codes.append(material_row.get("material_code") or "") material_names.append(material_row.get("material_name") or "") material_grades.append(material_row.get("material_grade") or "") gross_weight_kg += to_decimal(material_row.get("gross_weight_per_piece_kg")) net_weight_kg += to_decimal(material_row.get("net_weight_per_piece_kg")) loss_rate = max(loss_rate, to_decimal(material_row.get("loss_rate"), "0.0001")) issue_detail = issue_map.get((work_order_id, material_item_id), {}) item_issue_weight = to_decimal( issue_detail.get("issued_weight_kg") if issue_detail.get("issued_weight_kg") is not None else material_row.get("issued_weight_kg"), "0.000001", ) item_issue_amount = to_decimal(issue_detail.get("issued_amount"), "0.01") material_stats = material_stock_map.get(material_item_id, {}) purchased_weight_total = to_decimal(material_stats.get("purchased_weight_kg"), "0.000001") purchased_amount_total = to_decimal(material_stats.get("purchased_amount"), "0.01") issued_weight_total = to_decimal(material_stats.get("issued_weight_kg"), "0.000001") remaining_weight_total = max(Decimal("0"), purchased_weight_total - issued_weight_total) if item_issue_weight > 0 and item_issue_amount > 0: material_unit_price = item_issue_amount / item_issue_weight elif purchased_weight_total > 0 and purchased_amount_total > 0: material_unit_price = purchased_amount_total / purchased_weight_total else: material_unit_price = balance_cost_map.get(material_item_id, Decimal("0")) if issued_weight_total > 0 and item_issue_weight > 0: allocated_remaining_weight = remaining_weight_total * item_issue_weight / issued_weight_total elif item_issue_weight > 0: allocated_remaining_weight = remaining_weight_total else: allocated_remaining_weight = Decimal("0") purchase_material_weight += item_issue_weight + allocated_remaining_weight used_material_weight += item_issue_weight material_inventory_weight += allocated_remaining_weight material_inventory_value += allocated_remaining_weight * material_unit_price material_price_weight_total += item_issue_weight weighted_material_cost_sum += material_unit_price * item_issue_weight scrap_sale_price = to_decimal(material_row.get("material_scrap_sale_price"), "0.0001") scrap_price_basis = item_issue_weight if item_issue_weight > 0 else to_decimal( material_row.get("required_weight_kg"), "0.000001" ) if scrap_price_basis > 0: scrap_price_weight_total += scrap_price_basis weighted_scrap_sale_price_sum += scrap_sale_price * scrap_price_basis if gross_weight_kg <= 0 and base_row.get("product_unit_weight_kg") is not None: gross_weight_kg = to_decimal(base_row["product_unit_weight_kg"]) if net_weight_kg <= 0: net_weight_kg = to_decimal(base_row.get("product_unit_weight_kg")) actual_end_time = _to_candidate_datetime(base_row.get("actual_end_time")) completed_before_cutoff = bool(actual_end_time and actual_end_time <= end_dt) disposed_scrap_qty = to_decimal(scrap_summary.get("scrap_qty"), "0.000001") work_order_scrap_qty = to_decimal(base_row.get("scrap_qty"), "0.000001") final_report_scrap_qty = to_decimal(final_report_summary.get("scrap_qty"), "0.000001") production_scrap_qty = first_positive_decimal( disposed_scrap_qty, final_report_scrap_qty, work_order_scrap_qty if completed_before_cutoff else Decimal("0"), ) return_scrap_qty = to_decimal(return_disposition_summary.get("return_scrap_qty"), "0.000001") actual_scrap_qty = production_scrap_qty + return_scrap_qty receipt_qty = to_decimal(receipt_summary.get("receipt_qty"), "0.000001") final_report_good_qty = to_decimal(final_report_summary.get("good_qty"), "0.000001") effective_rework_qty = to_decimal(scrap_summary.get("rework_qty"), "0.000001") finished_qty = to_decimal(base_row.get("finished_qty"), "0.000001") actual_production_qty = first_positive_decimal( receipt_qty, final_report_good_qty + effective_rework_qty, finished_qty if completed_before_cutoff else Decimal("0"), ) shipped_qty = to_decimal(delivery_summary.get("delivery_qty"), "0.000001") inventory_qty = max(Decimal("0"), actual_production_qty - shipped_qty) order_qty = to_decimal(base_row.get("order_qty"), "0.000001") if order_qty <= 0: order_qty = to_decimal(base_row.get("planned_qty"), "0.000001") untaxed_unit_price = to_decimal(base_row.get("unit_price"), "0.0001") material_unit_price_kg = ( weighted_material_cost_sum / material_price_weight_total if material_price_weight_total > 0 else balance_cost_map.get( int(work_order_material_rows[0]["material_item_id"]) if work_order_material_rows else 0, Decimal("0"), ) ) scrap_unit_price_kg = ( weighted_scrap_sale_price_sum / scrap_price_weight_total if scrap_price_weight_total > 0 else to_decimal(work_order_material_rows[0].get("material_scrap_sale_price"), "0.0001") if work_order_material_rows else Decimal("0") ) gross_material_cost_per_piece = material_unit_price_kg * gross_weight_kg gross_material_total_cost = actual_production_qty * gross_material_cost_per_piece scrap_weight_kg = max(Decimal("0"), gross_weight_kg - net_weight_kg) scrap_revenue_per_piece = scrap_weight_kg * scrap_unit_price_kg return_scrap_sale_amount = to_decimal(return_disposition_summary.get("return_scrap_sale_amount"), "0.01") scrap_total_revenue = actual_production_qty * scrap_revenue_per_piece + return_scrap_sale_amount material_cost_per_piece = gross_material_cost_per_piece - scrap_revenue_per_piece material_total_cost = actual_production_qty * material_cost_per_piece loss_total_cost = material_total_cost * loss_rate labor_total_cost = to_decimal(operation_cost_summary.get("labor_amount"), "0.01") outsource_total_cost = to_decimal(operation_cost_summary.get("outsource_amount"), "0.01") labor_cost_per_piece = (labor_total_cost / actual_production_qty) if actual_production_qty > 0 else Decimal("0") outsource_cost_per_piece = ( outsource_total_cost / actual_production_qty if actual_production_qty > 0 else Decimal("0") ) product_cost_per_piece = material_cost_per_piece * (Decimal("1") + loss_rate) + labor_cost_per_piece + outsource_cost_per_piece production_rework_qty = first_positive_decimal( to_decimal(scrap_summary.get("rework_qty"), "0.000001"), to_decimal(final_report_summary.get("rework_qty"), "0.000001"), to_decimal(report_summary.get("rework_qty"), "0.000001"), ) return_rework_qty = to_decimal(return_disposition_summary.get("return_rework_qty"), "0.000001") rework_qty = production_rework_qty + return_rework_qty production_rework_cost_total = to_decimal(scrap_summary.get("rework_cost_amount"), "0.01") if production_rework_cost_total <= 0 and production_rework_qty > 0: production_rework_cost_total = production_rework_qty * (labor_cost_per_piece + outsource_cost_per_piece) return_rework_cost_total = to_decimal(return_disposition_summary.get("return_rework_cost_amount"), "0.01") rework_cost_total = production_rework_cost_total + return_rework_cost_total rework_cost_per_piece = (rework_cost_total / rework_qty) if rework_qty > 0 else Decimal("0") scrap_residual_value_per_piece = net_weight_kg * scrap_unit_price_kg production_scrap_total_cost = max(Decimal("0"), product_cost_per_piece - scrap_residual_value_per_piece) * production_scrap_qty return_scrap_total_cost = max(Decimal("0"), product_cost_per_piece * return_scrap_qty - return_scrap_sale_amount) scrap_total_cost = production_scrap_total_cost + return_scrap_total_cost batch_adjustment_avg_cost = ( (rework_cost_total + scrap_total_cost) / actual_production_qty if actual_production_qty > 0 else Decimal("0") ) product_cost_with_adjustment_per_piece = product_cost_per_piece + batch_adjustment_avg_cost product_total_cost = product_cost_with_adjustment_per_piece * actual_production_qty material_inventory_producible_qty = ( material_inventory_weight / gross_weight_kg if gross_weight_kg > 0 else Decimal("0") ) inventory_value = inventory_qty * untaxed_unit_price untaxed_total_amount = actual_production_qty * untaxed_unit_price if material_unit_price_kg <= 0 and outsource_total_cost > 0: supply_mode = "外协发加工" elif material_unit_price_kg <= 0: supply_mode = "客供料" else: supply_mode = "自家料" biz_date = max(relevant_dates).date() if relevant_dates else None delivery_note_no = ( delivery_summary.get("delivery_nos") or receipt_summary.get("receipt_nos") or base_row.get("source_order_no") or base_row["work_order_no"] ) row_payloads.append( { "work_order_id": work_order_id, "work_order_no": base_row["work_order_no"], "biz_date": biz_date, "product_code": base_row.get("customer_part_no") or base_row["product_code"], "customer_name": base_row.get("customer_name"), "product_name": base_row["product_name"], "supply_mode": supply_mode, "delivery_note_no": delivery_note_no, "order_qty": order_qty, "actual_production_qty": actual_production_qty, "shipped_qty": shipped_qty, "inventory_qty": inventory_qty, "inventory_value": inventory_value, "untaxed_unit_price": untaxed_unit_price, "untaxed_total_amount": untaxed_total_amount, "purchase_material_weight_kg": purchase_material_weight, "used_material_weight_kg": used_material_weight, "material_inventory_weight_kg": material_inventory_weight, "material_inventory_producible_qty": material_inventory_producible_qty, "material_inventory_value": material_inventory_value, "material_grade": _join_distinct(material_grades), "material_unit_price_kg": material_unit_price_kg, "gross_weight_g": gross_weight_kg * Decimal("1000"), "gross_material_cost_per_piece": gross_material_cost_per_piece, "gross_material_total_cost": gross_material_total_cost, "net_weight_g": net_weight_kg * Decimal("1000"), "scrap_weight_g": scrap_weight_kg * Decimal("1000"), "scrap_unit_price_kg": scrap_unit_price_kg, "scrap_revenue_per_piece": scrap_revenue_per_piece, "scrap_total_revenue": scrap_total_revenue, "material_cost_per_piece": material_cost_per_piece, "material_total_cost": material_total_cost, "loss_rate": loss_rate, "loss_total_cost": loss_total_cost, "labor_cost_per_piece": labor_cost_per_piece, "labor_total_cost": labor_total_cost, "outsource_cost_per_piece": outsource_cost_per_piece, "outsource_total_cost": outsource_total_cost, "product_cost_per_piece": product_cost_per_piece, "product_cost_with_adjustment_per_piece": product_cost_with_adjustment_per_piece, "product_total_cost": product_total_cost, "rework_qty": rework_qty, "rework_cost_per_piece": rework_cost_per_piece, "rework_total_cost": rework_cost_total, "scrap_qty": actual_scrap_qty, "scrap_residual_value_per_piece": scrap_residual_value_per_piece, "scrap_total_cost": scrap_total_cost, "batch_adjustment_avg_cost": batch_adjustment_avg_cost, } ) row_payloads.sort( key=lambda row: ( row["biz_date"] or date.min, row["work_order_no"], ), reverse=True, ) for index, payload in enumerate(row_payloads, start=1): board_rows.append( ProductMaterialCostRowRead( work_order_id=payload["work_order_id"], work_order_no=payload["work_order_no"], row_no=index, biz_date=payload["biz_date"], product_code=payload["product_code"], customer_name=payload["customer_name"], product_name=payload["product_name"], supply_mode=payload["supply_mode"], delivery_note_no=payload["delivery_note_no"], order_qty=_to_float(payload["order_qty"], "0.000001"), actual_production_qty=_to_float(payload["actual_production_qty"], "0.000001"), shipped_qty=_to_float(payload["shipped_qty"], "0.000001"), inventory_qty=_to_float(payload["inventory_qty"], "0.000001"), inventory_value=_to_float(payload["inventory_value"], "0.01"), untaxed_unit_price=_to_float(payload["untaxed_unit_price"], "0.0001"), untaxed_total_amount=_to_float(payload["untaxed_total_amount"], "0.01"), purchase_material_weight_kg=_to_float(payload["purchase_material_weight_kg"], "0.000001"), used_material_weight_kg=_to_float(payload["used_material_weight_kg"], "0.000001"), material_inventory_weight_kg=_to_float(payload["material_inventory_weight_kg"], "0.000001"), material_inventory_producible_qty=_to_float(payload["material_inventory_producible_qty"], "0.000001"), material_inventory_value=_to_float(payload["material_inventory_value"], "0.01"), material_grade=payload["material_grade"], material_unit_price_kg=_to_float(payload["material_unit_price_kg"], "0.0001"), gross_weight_g=_to_float(payload["gross_weight_g"], "0.001"), gross_material_cost_per_piece=_to_float(payload["gross_material_cost_per_piece"], "0.0001"), gross_material_total_cost=_to_float(payload["gross_material_total_cost"], "0.01"), net_weight_g=_to_float(payload["net_weight_g"], "0.001"), scrap_weight_g=_to_float(payload["scrap_weight_g"], "0.001"), scrap_unit_price_kg=_to_float(payload["scrap_unit_price_kg"], "0.0001"), scrap_revenue_per_piece=_to_float(payload["scrap_revenue_per_piece"], "0.0001"), scrap_total_revenue=_to_float(payload["scrap_total_revenue"], "0.01"), material_cost_per_piece=_to_float(payload["material_cost_per_piece"], "0.0001"), material_total_cost=_to_float(payload["material_total_cost"], "0.01"), loss_rate=_to_float(payload["loss_rate"], "0.0001"), loss_total_cost=_to_float(payload["loss_total_cost"], "0.01"), labor_cost_per_piece=_to_float(payload["labor_cost_per_piece"], "0.0001"), labor_total_cost=_to_float(payload["labor_total_cost"], "0.01"), outsource_cost_per_piece=_to_float(payload["outsource_cost_per_piece"], "0.0001"), outsource_total_cost=_to_float(payload["outsource_total_cost"], "0.01"), product_cost_per_piece=_to_float(payload["product_cost_per_piece"], "0.0001"), product_cost_with_adjustment_per_piece=_to_float( payload["product_cost_with_adjustment_per_piece"], "0.0001" ), product_total_cost=_to_float(payload["product_total_cost"], "0.01"), rework_qty=_to_float(payload["rework_qty"], "0.000001"), rework_cost_per_piece=_to_float(payload["rework_cost_per_piece"], "0.0001"), rework_total_cost=_to_float(payload["rework_total_cost"], "0.01"), scrap_qty=_to_float(payload["scrap_qty"], "0.000001"), scrap_residual_value_per_piece=_to_float(payload["scrap_residual_value_per_piece"], "0.0001"), scrap_total_cost=_to_float(payload["scrap_total_cost"], "0.01"), batch_adjustment_avg_cost=_to_float(payload["batch_adjustment_avg_cost"], "0.0001"), ) ) sales_total = sum(to_decimal(row.untaxed_total_amount, "0.01") for row in board_rows) gross_material_total = sum(to_decimal(row.gross_material_total_cost, "0.01") for row in board_rows) scrap_revenue_total = sum(to_decimal(row.scrap_total_revenue, "0.01") for row in board_rows) material_cost_total = sum(to_decimal(row.material_total_cost, "0.01") for row in board_rows) material_loss_total = sum(to_decimal(row.loss_total_cost, "0.01") for row in board_rows) labor_total = sum(to_decimal(row.labor_total_cost, "0.01") for row in board_rows) outsource_total = sum(to_decimal(row.outsource_total_cost, "0.01") for row in board_rows) rework_total = sum(to_decimal(row.rework_total_cost, "0.01") for row in board_rows) scrap_total = sum(to_decimal(row.scrap_total_cost, "0.01") for row in board_rows) direct_cost_total = sum(to_decimal(row.product_total_cost, "0.01") for row in board_rows) expense_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) expense_amounts["material_usage_value"] = material_cost_total expense_amounts["material_loss_value"] = material_loss_total expense_amounts["operator_wage"] = labor_total if labor_total > 0 else overhead_amounts.get("operator_wage", Decimal("0")) expense_amounts["manager_wage"] = overhead_amounts.get("manager_wage", Decimal("0")) expense_amounts["finance_charge"] = overhead_amounts.get("finance_charge", Decimal("0")) expense_amounts["mould_maintenance"] = overhead_amounts.get("mould_maintenance", Decimal("0")) expense_amounts["outsource_process"] = ( outsource_total if outsource_total > 0 else overhead_amounts.get("outsource_process", Decimal("0")) ) expense_amounts["production_packaging"] = overhead_amounts.get("production_packaging", Decimal("0")) expense_amounts["equipment_depreciation"] = overhead_amounts.get("equipment_depreciation", Decimal("0")) expense_amounts["utility"] = overhead_amounts.get("utility", Decimal("0")) expense_amounts["office_supplies"] = overhead_amounts.get("office_supplies", Decimal("0")) expense_amounts["factory_rent"] = overhead_amounts.get("factory_rent", Decimal("0")) expense_amounts["temporary_wage"] = overhead_amounts.get("temporary_wage", Decimal("0")) expense_amounts["staff_welfare"] = overhead_amounts.get("staff_welfare", Decimal("0")) expense_amounts["social_insurance"] = overhead_amounts.get("social_insurance", Decimal("0")) expense_amounts["cash_misc"] = overhead_amounts.get("cash_misc", Decimal("0")) expense_amounts["other"] = overhead_amounts.get("other", Decimal("0")) + rework_total + scrap_total profit_amount = sales_total - sum(expense_amounts.values()) overhead_total = sum(overhead_amounts.values()) cost_ratio = (sales_total - profit_amount) / sales_total if sales_total > 0 else Decimal("0") return ProductMaterialCostBoardRead( title="冲压产品材料成本核算表", date_from=date_from, date_to=date_to, generated_at=datetime.now(), rows=board_rows, expense_rows=_build_finance_expense_rows(expense_amounts), summary=ProductMaterialCostSummaryRead( row_count=len(board_rows), sales_total=_to_float(sales_total, "0.01"), gross_material_total=_to_float(gross_material_total, "0.01"), scrap_revenue_total=_to_float(scrap_revenue_total, "0.01"), material_cost_total=_to_float(material_cost_total, "0.01"), material_loss_total=_to_float(material_loss_total, "0.01"), labor_total=_to_float(labor_total, "0.01"), outsource_total=_to_float(outsource_total, "0.01"), rework_total=_to_float(rework_total, "0.01"), scrap_total=_to_float(scrap_total, "0.01"), overhead_total=_to_float(overhead_total, "0.01"), direct_cost_total=_to_float(direct_cost_total, "0.01"), profit_amount=_to_float(profit_amount, "0.01"), cost_ratio=_to_float(cost_ratio, "0.0001"), ), ) def get_cost_allocation_rows(db: Session, period_id: int | None = None, limit: int = 100) -> list[CostAllocationRead]: stmt = ( select( CostAllocation.id.label("cost_allocation_id"), CostAllocation.period_id.label("period_id"), AccountingPeriod.period_code.label("period_code"), CostAllocation.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), CostAllocation.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), CostAllocation.allocation_basis_value.label("allocation_basis_value"), CostAllocation.overhead_amount.label("overhead_amount"), CostAllocation.material_amount.label("material_amount"), CostAllocation.labor_amount.label("labor_amount"), CostAllocation.machine_amount.label("machine_amount"), CostAllocation.abnormal_loss_amount.label("abnormal_loss_amount"), CostAllocation.total_cost_amount.label("total_cost_amount"), CostAllocation.unit_cost.label("unit_cost"), ) .join(AccountingPeriod, AccountingPeriod.id == CostAllocation.period_id) .join(WorkOrder, WorkOrder.id == CostAllocation.work_order_id) .join(Item, Item.id == CostAllocation.product_item_id) .order_by(CostAllocation.id.desc()) .limit(limit) ) if period_id: stmt = stmt.where(CostAllocation.period_id == period_id) rows = db.execute(stmt).mappings().all() return [CostAllocationRead.model_validate(dict(row)) for row in rows] def create_rework_work_order( db: Session, *, product_item_id: int, planned_qty: Decimal, remark: str | None, ) -> WorkOrder: route = get_default_route(db, product_item_id) bom = get_default_bom(db, product_item_id) product = db.get(Item, product_item_id) if not product: raise HTTPException(status_code=404, detail="返工工单产品不存在") work_order = WorkOrder( work_order_no=build_yearly_product_work_order_no(db, product), work_order_type="REWORK", source_sales_order_item_id=None, product_item_id=product_item_id, route_id=route.id, bom_id=bom.id, planned_qty=planned_qty, released_qty=planned_qty, finished_qty=to_decimal(0), scrap_qty=to_decimal(0), planned_start_time=datetime.now(), planned_end_time=None, actual_start_time=None, actual_end_time=None, priority_level=2, status="RELEASED", remark=remark, ) db.add(work_order) db.flush() create_work_order_materials_and_operations( db, work_order=work_order, bom=bom, route=route, auto_issue_materials=False, operator_user_id=None, ) return work_order def get_purchase_order_query(limit: int = 50) -> Select: receipt_item_subquery = ( select( PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_qty), else_=0)), 0).label("accepted_qty"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_weight_kg), else_=0)), 0).label("accepted_weight_kg"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PENDING_QC", 1), else_=0)), 0).label("pending_count"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", 1), else_=0)), 0).label("passed_count"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "REJECTED", 1), else_=0)), 0).label("rejected_count"), ) .group_by(PurchaseReceiptItem.purchase_order_item_id) .subquery() ) return_item_subquery = ( select( PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), ) .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) .where(PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED") .group_by(PurchaseReturnItem.purchase_order_item_id) .subquery() ) line_subquery = ( select( PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), func.count(PurchaseOrderItem.id).label("line_count"), func.coalesce(func.sum(PurchaseOrderItem.order_qty), 0).label("total_order_qty"), func.coalesce(func.sum(PurchaseOrderItem.order_weight_kg), 0).label("total_order_weight_kg"), func.coalesce(func.sum(receipt_item_subquery.c.accepted_qty), 0).label("total_received_qty"), func.coalesce(func.sum(receipt_item_subquery.c.accepted_weight_kg), 0).label("total_received_weight_kg"), func.coalesce(func.sum(return_item_subquery.c.returned_weight_kg), 0).label("total_return_weight_kg"), func.coalesce(func.sum(receipt_item_subquery.c.pending_count), 0).label("pending_qc_count"), func.coalesce(func.sum(receipt_item_subquery.c.passed_count), 0).label("passed_count"), func.coalesce(func.sum(receipt_item_subquery.c.rejected_count), 0).label("rejected_count"), func.count(func.distinct(SalesOrder.id)).label("source_order_count"), literal(None).label("source_order_nos"), ) .outerjoin(receipt_item_subquery, receipt_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) .outerjoin(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id) .outerjoin(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id) .outerjoin(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) .group_by(PurchaseOrderItem.purchase_order_id) .subquery() ) latest_archive_version_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.archive_version).label("archive_version"), ) .where(DocumentArchive.document_type == "采购订单", DocumentArchive.file_format == "PDF") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_id_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.id).label("archive_id"), ) .join( latest_archive_version_subquery, and_( latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, ), ) .where(DocumentArchive.document_type == "采购订单", DocumentArchive.file_format == "PDF") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_subquery = ( select( DocumentArchive.business_id.label("business_id"), DocumentArchive.status.label("archive_status"), DocumentArchive.archive_version.label("archive_version"), DocumentArchive.created_at.label("archive_created_at"), DocumentArchive.error_message.label("archive_error_message"), ) .join( latest_archive_id_subquery, latest_archive_id_subquery.c.archive_id == DocumentArchive.id, ) .where(DocumentArchive.document_type == "采购订单", DocumentArchive.file_format == "PDF") .subquery() ) total_order_qty = func.coalesce(line_subquery.c.total_order_qty, 0) total_order_weight = func.coalesce(line_subquery.c.total_order_weight_kg, 0) total_received_qty = func.coalesce(line_subquery.c.total_received_qty, 0) total_received_weight = func.coalesce(line_subquery.c.total_received_weight_kg, 0) total_return_weight = func.coalesce(line_subquery.c.total_return_weight_kg, 0) pending_qc_count = func.coalesce(line_subquery.c.pending_qc_count, 0) rejected_count = func.coalesce(line_subquery.c.rejected_count, 0) display_status = case( (pending_qc_count > 0, "PENDING_QC"), (and_(total_order_qty > 0, total_received_qty >= total_order_qty), "RECEIVED"), (total_received_qty > 0, "PARTIAL_RECEIVED"), (and_(total_order_weight > 0, total_received_weight >= total_order_weight), "RECEIVED"), (total_received_weight > 0, "PARTIAL_RECEIVED"), (rejected_count > 0, "REJECTED"), else_=PurchaseOrder.status, ) purchaser = aliased(Employee) return ( select( PurchaseOrder.id.label("purchase_order_id"), PurchaseOrder.po_no.label("po_no"), case( (func.coalesce(line_subquery.c.source_order_count, 0) > 0, "SALES_ORDER"), else_="STOCKING", ).label("source_type"), line_subquery.c.source_order_nos.label("source_order_nos"), PurchaseOrder.supplier_id.label("supplier_id"), Supplier.supplier_code.label("supplier_code"), Supplier.supplier_name.label("supplier_name"), PurchaseOrder.order_date.label("order_date"), PurchaseOrder.expected_date.label("expected_date"), PurchaseOrder.warning_lead_days.label("warning_lead_days"), PurchaseOrder.purchaser_employee_id.label("purchaser_employee_id"), purchaser.employee_name.label("purchaser_name"), PurchaseOrder.target_warehouse_type.label("target_warehouse_type"), PurchaseOrder.tax_rate.label("tax_rate"), PurchaseOrder.total_amount.label("total_amount"), PurchaseOrder.status.label("status"), display_status.label("display_status"), PurchaseOrder.remark.label("remark"), func.coalesce(line_subquery.c.line_count, 0).label("line_count"), total_order_qty.label("total_order_qty"), total_order_weight.label("total_order_weight_kg"), total_received_qty.label("total_received_qty"), total_received_weight.label("total_received_weight_kg"), (total_return_weight > 0).label("has_return"), total_return_weight.label("total_return_weight_kg"), func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), latest_archive_subquery.c.archive_version.label("archive_version"), latest_archive_subquery.c.archive_created_at.label("archive_created_at"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .outerjoin(purchaser, purchaser.id == PurchaseOrder.purchaser_employee_id) .outerjoin(line_subquery, line_subquery.c.purchase_order_id == PurchaseOrder.id) .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == PurchaseOrder.id) .order_by(PurchaseOrder.id.desc()) .limit(limit) ) def get_purchase_order_items_query(limit: int = 100, purchase_order_id: int | None = None) -> Select: receipt_item_subquery = ( select( PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_qty), else_=0)), 0).label("accepted_qty"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_weight_kg), else_=0)), 0).label("accepted_weight_kg"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PENDING_QC", 1), else_=0)), 0).label("pending_count"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "REJECTED", 1), else_=0)), 0).label("rejected_count"), ) .group_by(PurchaseReceiptItem.purchase_order_item_id) .subquery() ) return_item_subquery = ( select( PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), ) .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) .where(PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED") .group_by(PurchaseReturnItem.purchase_order_item_id) .subquery() ) accepted_qty = func.coalesce(receipt_item_subquery.c.accepted_qty, 0) accepted_weight = func.coalesce(receipt_item_subquery.c.accepted_weight_kg, 0) returned_weight = func.coalesce(return_item_subquery.c.returned_weight_kg, 0) net_received_weight = case((accepted_weight - returned_weight < 0, 0), else_=accepted_weight - returned_weight) item_display_status = case( (func.coalesce(receipt_item_subquery.c.pending_count, 0) > 0, "PENDING_QC"), (and_(PurchaseOrderItem.order_qty > 0, accepted_qty >= PurchaseOrderItem.order_qty), "CLOSED"), (accepted_qty > 0, "PARTIAL_RECEIVED"), (and_(PurchaseOrderItem.order_weight_kg > 0, accepted_weight >= PurchaseOrderItem.order_weight_kg), "CLOSED"), (accepted_weight > 0, "PARTIAL_RECEIVED"), (func.coalesce(receipt_item_subquery.c.rejected_count, 0) > 0, "REJECTED"), else_=PurchaseOrderItem.status, ) stmt = ( select( PurchaseOrderItem.id.label("purchase_order_item_id"), PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), PurchaseOrder.po_no.label("po_no"), PurchaseOrderItem.line_no.label("line_no"), PurchaseOrderItem.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), Item.specification.label("specification"), Item.material_grade.label("material_grade"), Material.quality_rule.label("performance_requirement"), Material.purchase_calc_mode.label("purchase_calc_mode"), PurchaseOrderItem.source_demand_id.label("source_demand_id"), PurchaseOrderItem.order_qty.label("order_qty"), PurchaseOrderItem.order_weight_kg.label("order_weight_kg"), accepted_qty.label("received_qty"), accepted_weight.label("received_weight_kg"), returned_weight.label("returned_weight_kg"), net_received_weight.label("net_received_weight_kg"), (returned_weight > 0).label("has_return"), PurchaseOrderItem.unit_price.label("unit_price"), PurchaseOrderItem.line_amount.label("line_amount"), PurchaseOrderItem.status.label("status"), item_display_status.label("display_status"), PurchaseOrderItem.remark.label("remark"), ) .join(PurchaseOrder, PurchaseOrder.id == PurchaseOrderItem.purchase_order_id) .join(Item, Item.id == PurchaseOrderItem.material_item_id) .outerjoin(Material, Material.item_id == PurchaseOrderItem.material_item_id) .outerjoin(receipt_item_subquery, receipt_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) .order_by(PurchaseOrderItem.id.desc()) .limit(limit) ) if purchase_order_id: stmt = stmt.where(PurchaseOrderItem.purchase_order_id == purchase_order_id) return stmt def get_purchase_receipt_query(limit: int = 50) -> Select: line_subquery = ( select( PurchaseReceiptItem.receipt_id.label("receipt_id"), func.count(PurchaseReceiptItem.id).label("line_count"), func.coalesce(func.sum(PurchaseReceiptItem.received_qty), 0).label("total_received_qty"), func.coalesce(func.sum(PurchaseReceiptItem.received_weight_kg), 0).label("total_received_weight_kg"), func.group_concat(func.distinct(Item.item_name)).label("material_names"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PENDING_QC", 1), else_=0)), 0).label("pending_count"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", 1), else_=0)), 0).label("passed_count"), func.coalesce(func.sum(case((PurchaseReceiptItem.status == "REJECTED", 1), else_=0)), 0).label("rejected_count"), ) .join(Item, Item.id == PurchaseReceiptItem.material_item_id) .group_by(PurchaseReceiptItem.receipt_id) .subquery() ) line_count = func.coalesce(line_subquery.c.line_count, 0) pending_count = func.coalesce(line_subquery.c.pending_count, 0) passed_count = func.coalesce(line_subquery.c.passed_count, 0) rejected_count = func.coalesce(line_subquery.c.rejected_count, 0) receipt_status = case( (line_count <= 0, PurchaseReceipt.status), (pending_count > 0, "PENDING_QC"), (passed_count == line_count, "PASSED"), (rejected_count == line_count, "REJECTED"), (passed_count + rejected_count == line_count, "PARTIAL"), else_=PurchaseReceipt.status, ) latest_archive_version_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.archive_version).label("archive_version"), ) .where(DocumentArchive.document_type == "到货入库单", DocumentArchive.file_format == "PDF") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_id_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.id).label("archive_id"), ) .join( latest_archive_version_subquery, and_( latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, ), ) .where(DocumentArchive.document_type == "到货入库单", DocumentArchive.file_format == "PDF") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_subquery = ( select( DocumentArchive.business_id.label("business_id"), DocumentArchive.status.label("archive_status"), DocumentArchive.archive_version.label("archive_version"), DocumentArchive.created_at.label("archive_created_at"), DocumentArchive.error_message.label("archive_error_message"), ) .join( latest_archive_id_subquery, latest_archive_id_subquery.c.archive_id == DocumentArchive.id, ) .where(DocumentArchive.document_type == "到货入库单", DocumentArchive.file_format == "PDF") .subquery() ) receiver = aliased(Employee) return ( select( PurchaseReceipt.id.label("receipt_id"), PurchaseReceipt.receipt_no.label("receipt_no"), PurchaseReceipt.purchase_order_id.label("purchase_order_id"), PurchaseOrder.po_no.label("po_no"), PurchaseOrder.supplier_id.label("supplier_id"), Supplier.supplier_name.label("supplier_name"), PurchaseReceipt.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), PurchaseOrder.target_warehouse_type.label("target_warehouse_type"), PurchaseReceipt.receipt_date.label("receipt_date"), PurchaseReceipt.receiver_employee_id.label("receiver_employee_id"), receiver.employee_name.label("receiver_name"), PurchaseReceipt.supplier_delivery_no.label("supplier_delivery_no"), PurchaseReceipt.logistics_waybill_no.label("logistics_waybill_no"), PurchaseReceipt.logistics_freight_amount.label("logistics_freight_amount"), PurchaseReceipt.logistics_photo_url.label("logistics_photo_url"), line_subquery.c.material_names.label("material_names"), receipt_status.label("status"), PurchaseReceipt.remark.label("remark"), func.coalesce(line_subquery.c.line_count, 0).label("line_count"), func.coalesce(line_subquery.c.total_received_qty, 0).label("total_received_qty"), func.coalesce(line_subquery.c.total_received_weight_kg, 0).label("total_received_weight_kg"), func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), latest_archive_subquery.c.archive_version.label("archive_version"), latest_archive_subquery.c.archive_created_at.label("archive_created_at"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) .outerjoin(receiver, receiver.id == PurchaseReceipt.receiver_employee_id) .outerjoin(line_subquery, line_subquery.c.receipt_id == PurchaseReceipt.id) .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == PurchaseReceipt.id) .order_by(PurchaseReceipt.id.desc()) .limit(limit) ) def get_purchase_receipt_items_query(limit: int = 100, receipt_id: int | None = None) -> Select: stmt = ( select( PurchaseReceiptItem.id.label("receipt_item_id"), PurchaseReceiptItem.receipt_id.label("receipt_id"), PurchaseReceipt.receipt_no.label("receipt_no"), PurchaseReceiptItem.line_no.label("line_no"), PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), PurchaseReceiptItem.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), PurchaseReceipt.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), PurchaseReceiptItem.location_id.label("location_id"), WarehouseLocation.location_name.label("location_name"), PurchaseReceiptItem.lot_no.label("lot_no"), PurchaseReceiptItem.received_qty.label("received_qty"), PurchaseReceiptItem.received_weight_kg.label("received_weight_kg"), PurchaseReceiptItem.accepted_qty.label("accepted_qty"), PurchaseReceiptItem.accepted_weight_kg.label("accepted_weight_kg"), PurchaseReceiptItem.unit_cost.label("unit_cost"), PurchaseReceiptItem.status.label("status"), case( (PurchaseReceiptItem.status == "PENDING_QC", "PENDING_QC"), (PurchaseReceiptItem.status == "PASSED", "PASS"), (PurchaseReceiptItem.status == "REJECTED", "REJECT"), else_=func.coalesce(StockLot.quality_status, "PENDING_QC"), ).label("quality_status"), PurchaseReceiptItem.remark.label("remark"), ) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) .join(Item, Item.id == PurchaseReceiptItem.material_item_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == PurchaseReceiptItem.location_id) .outerjoin( StockLot, or_( and_( StockLot.source_doc_type == "PURCHASE_RECEIPT", StockLot.source_doc_id == PurchaseReceiptItem.receipt_id, StockLot.source_line_id == PurchaseReceiptItem.id, ), StockLot.id == PurchaseReceiptItem.merge_to_lot_id, ), ) .order_by(PurchaseReceiptItem.id.desc()) .limit(limit) ) if receipt_id: stmt = stmt.where(PurchaseReceiptItem.receipt_id == receipt_id) return stmt def sync_purchase_receipt_status(db: Session, receipt_id: int) -> str | None: receipt = db.get(PurchaseReceipt, receipt_id) if not receipt: return None statuses = [ str(status or "").upper() for status in db.scalars( select(PurchaseReceiptItem.status).where(PurchaseReceiptItem.receipt_id == receipt_id) ).all() ] if not statuses: return receipt.status if any(status == "PENDING_QC" for status in statuses): next_status = "PENDING_QC" elif all(status == "PASSED" for status in statuses): next_status = "PASSED" elif all(status == "REJECTED" for status in statuses): next_status = "REJECTED" elif all(status in {"PASSED", "REJECTED"} for status in statuses): next_status = "PARTIAL" else: next_status = receipt.status receipt.status = next_status db.add(receipt) return next_status def get_stock_lots_query(limit: int = 100, item_type: str | None = None) -> Select: source_material_lot = aliased(StockLot) stmt = ( select( StockLot.id.label("lot_id"), StockLot.lot_no.label("lot_no"), StockLot.parent_lot_id.label("parent_lot_id"), StockLot.lot_role.label("lot_role"), StockLot.material_sub_batch_no.label("material_sub_batch_no"), StockLot.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), Item.item_type.label("item_type"), Item.specification.label("specification"), StockLot.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), Warehouse.warehouse_type.label("warehouse_type"), StockLot.location_id.label("location_id"), WarehouseLocation.location_name.label("location_name"), StockLot.source_doc_type.label("source_doc_type"), StockLot.source_doc_id.label("source_doc_id"), StockLot.source_line_id.label("source_line_id"), PurchaseReceipt.receipt_no.label("source_receipt_no"), PurchaseOrder.po_no.label("source_purchase_order_no"), StockLot.source_material_lot_id.label("source_material_lot_id"), func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( "source_material_lot_no" ), StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), StockLot.source_material_summary.label("source_material_summary"), StockLot.logistics_waybill_no.label("logistics_waybill_no"), StockLot.logistics_freight_amount.label("logistics_freight_amount"), StockLot.logistics_photo_url.label("logistics_photo_url"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockLot.inbound_qty).label("inbound_qty"), StockLot.inbound_weight_kg.label("inbound_weight_kg"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockLot.remaining_qty).label("remaining_qty"), StockLot.remaining_weight_kg.label("remaining_weight_kg"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockLot.locked_qty).label("locked_qty"), StockLot.locked_weight_kg.label("locked_weight_kg"), StockLot.unit_cost.label("unit_cost"), StockLot.quality_status.label("quality_status"), StockLot.remark.label("quality_user_name"), StockLot.status.label("status"), StockLot.created_at.label("created_at"), ) .join(Item, Item.id == StockLot.item_id) .join(Warehouse, Warehouse.id == StockLot.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == StockLot.location_id) .outerjoin( PurchaseReceiptItem, and_( StockLot.source_doc_type == "PURCHASE_RECEIPT", PurchaseReceiptItem.receipt_id == StockLot.source_doc_id, PurchaseReceiptItem.id == StockLot.source_line_id, ), ) .outerjoin(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .outerjoin(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) .order_by(StockLot.id.desc()) .limit(limit) ) if item_type: stmt = stmt.where(Item.item_type == item_type) return stmt def get_stock_lot_options_query(material_item_id: int | None = None) -> Select: usable_weight_expr = StockLot.remaining_weight_kg - StockLot.locked_weight_kg is_available_expr = case( ( (StockLot.quality_status == "PASS") & (StockLot.status == "AVAILABLE") & (usable_weight_expr > 0), 1, ), else_=0, ) stmt = ( select( StockLot.id.label("lot_id"), StockLot.lot_no.label("lot_no"), StockLot.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), StockLot.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), StockLot.remaining_weight_kg.label("remaining_weight_kg"), StockLot.unit_cost.label("unit_cost"), StockLot.quality_status.label("quality_status"), StockLot.status.label("status"), is_available_expr.label("is_available_for_issue"), case( (StockLot.quality_status != "PASS", "未质检放行"), (usable_weight_expr <= 0, "已用完"), (StockLot.status != "AVAILABLE", "当前状态不可用"), else_=None, ).label("disabled_reason"), ) .join(Item, Item.id == StockLot.item_id) .join(Warehouse, Warehouse.id == StockLot.warehouse_id) .where(Warehouse.warehouse_type == "RAW") .order_by(is_available_expr.desc(), StockLot.lot_no.asc(), StockLot.id.asc()) ) if material_item_id is not None: stmt = stmt.where(StockLot.item_id == material_item_id) return stmt def get_stock_lot_purchase_links(db: Session, lot_id: int) -> list[dict[str, object]]: lot = db.get(StockLot, lot_id) if not lot: raise HTTPException(status_code=404, detail="库存批次不存在") returned_subquery = ( select( PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), PurchaseReturnItem.lot_id.label("lot_id"), func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), ) .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) .where(PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED") .group_by(PurchaseReturnItem.purchase_order_item_id, PurchaseReturnItem.lot_id) .subquery() ) accepted_weight = func.coalesce(func.sum(PurchaseReceiptItem.accepted_weight_kg), 0) returned_weight = func.coalesce(returned_subquery.c.returned_weight_kg, 0) available_by_receipt = accepted_weight - returned_weight non_negative_returnable = case((available_by_receipt < 0, 0), else_=available_by_receipt) available_lot_weight = StockLot.remaining_weight_kg - StockLot.locked_weight_kg non_negative_available_lot_weight = case((available_lot_weight < 0, 0), else_=available_lot_weight) returnable_weight = case( (non_negative_returnable > non_negative_available_lot_weight, non_negative_available_lot_weight), else_=non_negative_returnable, ) direct_receipt_item = and_( StockLot.source_doc_type == "PURCHASE_RECEIPT", PurchaseReceiptItem.receipt_id == StockLot.source_doc_id, PurchaseReceiptItem.id == StockLot.source_line_id, ) merged_receipt_item = PurchaseReceiptItem.merge_to_lot_id == StockLot.id stmt = ( select( PurchaseOrder.id.label("purchase_order_id"), PurchaseOrder.po_no.label("po_no"), PurchaseOrderItem.id.label("purchase_order_item_id"), PurchaseOrderItem.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), StockLot.id.label("lot_id"), StockLot.lot_no.label("lot_no"), accepted_weight.label("accepted_weight_kg"), returned_weight.label("returned_weight_kg"), returnable_weight.label("returnable_weight_kg"), PurchaseReceiptItem.unit_cost.label("unit_cost"), ) .select_from(PurchaseReceiptItem) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .join(PurchaseOrderItem, PurchaseOrderItem.id == PurchaseReceiptItem.purchase_order_item_id) .join(Item, Item.id == PurchaseReceiptItem.material_item_id) .join(StockLot, StockLot.id == lot_id) .outerjoin( returned_subquery, (returned_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) & (returned_subquery.c.lot_id == StockLot.id), ) .where( PurchaseReceipt.warehouse_id == StockLot.warehouse_id, or_(direct_receipt_item, merged_receipt_item), PurchaseReceiptItem.material_item_id == lot.item_id, PurchaseReceiptItem.lot_no == lot.lot_no, PurchaseReceiptItem.status == "PASSED", ) .group_by( PurchaseOrder.id, PurchaseOrder.po_no, PurchaseOrderItem.id, PurchaseOrderItem.material_item_id, Item.item_code, Item.item_name, StockLot.id, StockLot.lot_no, returned_subquery.c.returned_weight_kg, PurchaseReceiptItem.unit_cost, StockLot.remaining_weight_kg, StockLot.locked_weight_kg, ) .order_by(PurchaseOrder.id.asc(), PurchaseOrderItem.line_no.asc()) ) return [dict(row) for row in db.execute(stmt).mappings().all()] def get_stock_balances_query(limit: int = 100) -> Select: stock_unit = aliased(Unit) purchase_unit = aliased(Unit) sales_unit = aliased(Unit) return ( select( StockBalance.id.label("stock_balance_id"), StockBalance.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), Item.item_type.label("item_type"), Item.specification.label("specification"), Item.remark.label("item_remark"), stock_unit.unit_name.label("stock_unit_name"), purchase_unit.unit_name.label("purchase_unit_name"), sales_unit.unit_name.label("sales_unit_name"), StockBalance.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), Warehouse.warehouse_type.label("warehouse_type"), StockBalance.location_id.label("location_id"), WarehouseLocation.location_name.label("location_name"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockBalance.qty_on_hand).label("qty_on_hand"), StockBalance.weight_on_hand_kg.label("weight_on_hand_kg"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockBalance.qty_available).label("qty_available"), StockBalance.weight_available_kg.label("weight_available_kg"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockBalance.qty_allocated).label("qty_allocated"), StockBalance.weight_allocated_kg.label("weight_allocated_kg"), StockBalance.avg_unit_cost.label("avg_unit_cost"), StockBalance.updated_at.label("updated_at"), ) .join(Item, Item.id == StockBalance.item_id) .join(Warehouse, Warehouse.id == StockBalance.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == StockBalance.location_id) .outerjoin(stock_unit, stock_unit.id == Item.stock_unit_id) .outerjoin(purchase_unit, purchase_unit.id == Item.purchase_unit_id) .outerjoin(sales_unit, sales_unit.id == Item.sales_unit_id) .order_by(StockBalance.updated_at.desc(), StockBalance.id.desc()) .limit(limit) ) WAREHOUSE_LEDGER_TYPES = {"RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"} WAREHOUSE_LEDGER_SORT_COLUMNS = { "biz_time": InventoryTxn.biz_time, "txn_type": InventoryTxn.txn_type, "item_code": Item.item_code, "item_name": Item.item_name, "lot_no": StockLot.lot_no, "qty_change": InventoryTxn.qty_change, "weight_change_kg": InventoryTxn.weight_change_kg, "amount": InventoryTxn.amount, } def inventory_txn_direction_expression(): return case( (or_(InventoryTxn.txn_type.like("STOCKTAKE_%"), InventoryTxn.source_doc_type == "STOCKTAKE"), "ADJUST"), (or_(InventoryTxn.weight_change_kg > 0, InventoryTxn.qty_change > 0), "IN"), (or_(InventoryTxn.weight_change_kg < 0, InventoryTxn.qty_change < 0), "OUT"), else_="ADJUST", ) def inventory_txn_archive_document_type_expression(): return case( ( and_( InventoryTxn.source_doc_type == "生产台账", InventoryTxn.txn_type.in_(("PRODUCTION_OUT", "MATERIAL_ISSUE")), ), DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ), ( InventoryTxn.txn_type.in_( ( "FG_IN", "PRODUCTION_FINISHED_IN", "PRODUCTION_SURPLUS_IN", "PRODUCTION_SCRAP_IN", "成品入库", "生产余料入库", "生产废料入库", ) ), DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, ), else_=DOCUMENT_TYPE_WAREHOUSE_OPERATION, ) def inventory_txn_archive_business_id_expression(): return case( ( and_( InventoryTxn.source_doc_type == "生产台账", InventoryTxn.txn_type.in_(("PRODUCTION_OUT", "MATERIAL_ISSUE")), ), InventoryTxn.source_doc_id, ), else_=InventoryTxn.id, ) def latest_inventory_archive_subquery(): return ( select( DocumentArchive.document_type.label("document_type"), DocumentArchive.business_id.label("business_id"), DocumentArchive.status.label("archive_status"), DocumentArchive.error_message.label("archive_error_message"), func.row_number() .over( partition_by=(DocumentArchive.document_type, DocumentArchive.business_id), order_by=(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()), ) .label("row_no"), ) .where(DocumentArchive.file_format == FILE_FORMAT_PDF) .subquery() ) def get_inventory_txn_ledger_query( *, warehouse_type: str, warehouse_id: int | None = None, keyword: str | None = None, direction: str = "ALL", txn_type: str | None = None, item_id: int | None = None, lot_no: str | None = None, start_date: date | None = None, end_date: date | None = None, sort_key: str = "biz_time", sort_direction: str = "desc", ) -> Select: normalized_warehouse_type = str(warehouse_type or "").upper() direction_expr = inventory_txn_direction_expression().label("direction") archive_document_type_expr = inventory_txn_archive_document_type_expression().label("archive_document_type") archive_business_id_expr = inventory_txn_archive_business_id_expression().label("archive_business_id") latest_archive_subquery = latest_inventory_archive_subquery() operator_employee = aliased(Employee) source_material_lot = aliased(StockLot) stmt = ( select( InventoryTxn.id.label("inventory_txn_id"), InventoryTxn.txn_no.label("txn_no"), InventoryTxn.txn_type.label("txn_type"), InventoryTxn.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), InventoryTxn.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), Warehouse.warehouse_type.label("warehouse_type"), InventoryTxn.location_id.label("location_id"), WarehouseLocation.location_name.label("location_name"), InventoryTxn.lot_id.label("lot_id"), StockLot.lot_no.label("lot_no"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=InventoryTxn.qty_change).label("qty_change"), InventoryTxn.weight_change_kg.label("weight_change_kg"), InventoryTxn.unit_cost.label("unit_cost"), InventoryTxn.amount.label("amount"), InventoryTxn.source_doc_type.label("source_doc_type"), InventoryTxn.source_doc_id.label("source_doc_id"), InventoryTxn.source_line_id.label("source_line_id"), StockLot.source_material_lot_id.label("source_material_lot_id"), func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( "source_material_lot_no" ), StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), StockLot.source_material_summary.label("source_material_summary"), InventoryTxn.biz_time.label("biz_time"), InventoryTxn.operator_user_id.label("operator_user_id"), func.coalesce(operator_employee.employee_name, User.nickname, User.username).label("operator_name"), InventoryTxn.logistics_waybill_no.label("logistics_waybill_no"), InventoryTxn.logistics_freight_amount.label("logistics_freight_amount"), InventoryTxn.logistics_photo_url.label("logistics_photo_url"), InventoryTxn.remark.label("remark"), direction_expr, archive_document_type_expr, archive_business_id_expr, func.coalesce(latest_archive_subquery.c.archive_status, ARCHIVE_STATUS_MISSING).label("archive_status"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ) .join(Item, Item.id == InventoryTxn.item_id) .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) .outerjoin(User, User.id == InventoryTxn.operator_user_id) .outerjoin(operator_employee, operator_employee.id == User.employee_id) .outerjoin( latest_archive_subquery, and_( latest_archive_subquery.c.document_type == archive_document_type_expr, latest_archive_subquery.c.business_id == archive_business_id_expr, latest_archive_subquery.c.row_no == 1, ), ) .where(Warehouse.warehouse_type == normalized_warehouse_type) ) if warehouse_id: stmt = stmt.where(InventoryTxn.warehouse_id == warehouse_id) if txn_type: stmt = stmt.where(InventoryTxn.txn_type == str(txn_type).upper()) if item_id: stmt = stmt.where(InventoryTxn.item_id == item_id) if lot_no: lot_no_pattern = f"%{lot_no.strip()}%" stmt = stmt.where( or_( StockLot.lot_no.like(lot_no_pattern), source_material_lot.lot_no.like(lot_no_pattern), StockLot.source_material_sub_batch_no.like(lot_no_pattern), ) ) if start_date: stmt = stmt.where(InventoryTxn.biz_time >= start_of_day(start_date)) if end_date: stmt = stmt.where(InventoryTxn.biz_time <= end_of_day(end_date)) if keyword and keyword.strip(): pattern = f"%{keyword.strip()}%" stmt = stmt.where( or_( Item.item_code.like(pattern), Item.item_name.like(pattern), InventoryTxn.txn_type.like(pattern), InventoryTxn.source_doc_type.like(pattern), InventoryTxn.txn_no.like(pattern), InventoryTxn.logistics_waybill_no.like(pattern), InventoryTxn.remark.like(pattern), StockLot.lot_no.like(pattern), source_material_lot.lot_no.like(pattern), StockLot.source_material_sub_batch_no.like(pattern), StockLot.source_material_summary.like(pattern), ) ) normalized_direction = str(direction or "ALL").upper() if normalized_direction in {"IN", "OUT", "ADJUST"}: stmt = stmt.where(direction_expr == normalized_direction) sort_column = WAREHOUSE_LEDGER_SORT_COLUMNS.get(sort_key, InventoryTxn.biz_time) if str(sort_direction or "desc").lower() == "asc": stmt = stmt.order_by(sort_column.asc(), InventoryTxn.id.asc()) else: stmt = stmt.order_by(sort_column.desc(), InventoryTxn.id.desc()) return stmt def summarize_inventory_txn_ledger(db: Session, ledger_stmt: Select) -> dict[str, object]: ledger_subquery = ledger_stmt.order_by(None).subquery() row = db.execute( select( func.coalesce(func.sum(case((ledger_subquery.c.direction == "IN", 1), else_=0)), 0).label("in_count"), func.coalesce(func.sum(case((ledger_subquery.c.direction == "OUT", 1), else_=0)), 0).label("out_count"), func.coalesce(func.sum(case((ledger_subquery.c.direction == "ADJUST", 1), else_=0)), 0).label("adjust_count"), func.coalesce( func.sum(case((ledger_subquery.c.direction == "IN", func.abs(ledger_subquery.c.qty_change)), else_=0)), 0, ).label("in_qty"), func.coalesce( func.sum(case((ledger_subquery.c.direction == "OUT", func.abs(ledger_subquery.c.qty_change)), else_=0)), 0, ).label("out_qty"), func.coalesce( func.sum(case((ledger_subquery.c.direction == "IN", func.abs(ledger_subquery.c.weight_change_kg)), else_=0)), 0, ).label("in_weight_kg"), func.coalesce( func.sum(case((ledger_subquery.c.direction == "OUT", func.abs(ledger_subquery.c.weight_change_kg)), else_=0)), 0, ).label("out_weight_kg"), func.max(ledger_subquery.c.biz_time).label("latest_biz_time"), ) ).mappings().one() return { "in_count": int(row["in_count"] or 0), "out_count": int(row["out_count"] or 0), "adjust_count": int(row["adjust_count"] or 0), "in_qty": to_decimal(row["in_qty"]), "out_qty": to_decimal(row["out_qty"]), "in_weight_kg": to_decimal(row["in_weight_kg"]), "out_weight_kg": to_decimal(row["out_weight_kg"]), "latest_biz_time": row["latest_biz_time"], } def get_inventory_txn_query(limit: int = 200) -> Select: source_material_lot = aliased(StockLot) archive_document_type_expr = inventory_txn_archive_document_type_expression().label("archive_document_type") archive_business_id_expr = inventory_txn_archive_business_id_expression().label("archive_business_id") latest_archive_subquery = latest_inventory_archive_subquery() return ( select( InventoryTxn.id.label("inventory_txn_id"), InventoryTxn.txn_no.label("txn_no"), InventoryTxn.txn_type.label("txn_type"), InventoryTxn.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), InventoryTxn.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), Warehouse.warehouse_type.label("warehouse_type"), InventoryTxn.location_id.label("location_id"), WarehouseLocation.location_name.label("location_name"), InventoryTxn.lot_id.label("lot_id"), StockLot.lot_no.label("lot_no"), case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=InventoryTxn.qty_change).label("qty_change"), InventoryTxn.weight_change_kg.label("weight_change_kg"), InventoryTxn.unit_cost.label("unit_cost"), InventoryTxn.amount.label("amount"), InventoryTxn.source_doc_type.label("source_doc_type"), InventoryTxn.source_doc_id.label("source_doc_id"), InventoryTxn.source_line_id.label("source_line_id"), StockLot.source_material_lot_id.label("source_material_lot_id"), func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( "source_material_lot_no" ), StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), StockLot.source_material_summary.label("source_material_summary"), InventoryTxn.biz_time.label("biz_time"), InventoryTxn.logistics_waybill_no.label("logistics_waybill_no"), InventoryTxn.logistics_freight_amount.label("logistics_freight_amount"), InventoryTxn.logistics_photo_url.label("logistics_photo_url"), InventoryTxn.remark.label("remark"), archive_document_type_expr, archive_business_id_expr, func.coalesce(latest_archive_subquery.c.archive_status, ARCHIVE_STATUS_MISSING).label("archive_status"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ) .join(Item, Item.id == InventoryTxn.item_id) .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) .outerjoin( latest_archive_subquery, and_( latest_archive_subquery.c.document_type == archive_document_type_expr, latest_archive_subquery.c.business_id == archive_business_id_expr, latest_archive_subquery.c.row_no == 1, ), ) .order_by(InventoryTxn.biz_time.desc(), InventoryTxn.id.desc()) .limit(limit) ) def get_inventory_settlements(db: Session, limit: int = 200) -> list[dict[str, object]]: payable_amount = func.coalesce( func.sum( PurchaseReceiptItem.received_weight_kg * PurchaseReceiptItem.unit_cost ), 0, ) payable_rows = db.execute( select( PurchaseReceipt.id.label("source_doc_id"), PurchaseReceipt.receipt_no.label("doc_no"), Supplier.supplier_name.label("counterparty_name"), PurchaseReceipt.receipt_date.label("biz_date"), payable_amount.label("amount"), PurchaseReceipt.status.label("status"), PurchaseReceipt.remark.label("remark"), ) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .join(PurchaseReceiptItem, PurchaseReceiptItem.receipt_id == PurchaseReceipt.id) .group_by( PurchaseReceipt.id, PurchaseReceipt.receipt_no, Supplier.supplier_name, PurchaseReceipt.receipt_date, PurchaseReceipt.status, PurchaseReceipt.remark, ) .order_by(PurchaseReceipt.receipt_date.desc(), PurchaseReceipt.id.desc()) .limit(limit) ).mappings().all() order_customer = aliased(Customer) direct_customer = aliased(Customer) receivable_rows = db.execute( select( Delivery.id.label("source_doc_id"), Delivery.delivery_no.label("doc_no"), func.coalesce(direct_customer.customer_name, order_customer.customer_name).label("counterparty_name"), Delivery.delivery_date.label("biz_date"), func.coalesce(func.sum(DeliveryItem.line_amount), 0).label("amount"), Delivery.status.label("status"), Delivery.remark.label("remark"), ) .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) .outerjoin(order_customer, order_customer.id == SalesOrder.customer_id) .outerjoin(direct_customer, direct_customer.id == Delivery.customer_id) .join(DeliveryItem, DeliveryItem.delivery_id == Delivery.id) .group_by( Delivery.id, Delivery.delivery_no, order_customer.customer_name, direct_customer.customer_name, Delivery.delivery_date, Delivery.status, Delivery.remark, ) .order_by(Delivery.delivery_date.desc(), Delivery.id.desc()) .limit(limit) ).mappings().all() rows: list[dict[str, object]] = [] for row in payable_rows: amount = to_decimal(row["amount"], "0.01") rows.append( { "settlement_type": "PAYABLE", "source_doc_type": "PURCHASE_RECEIPT", "source_doc_id": row["source_doc_id"], "doc_no": row["doc_no"], "counterparty_name": row["counterparty_name"], "biz_date": row["biz_date"], "amount": float(amount), "settled_amount": 0, "open_amount": float(amount), "status": row["status"], "remark": row["remark"], } ) for row in receivable_rows: amount = to_decimal(row["amount"], "0.01") rows.append( { "settlement_type": "RECEIVABLE", "source_doc_type": "DELIVERY", "source_doc_id": row["source_doc_id"], "doc_no": row["doc_no"], "counterparty_name": row["counterparty_name"], "biz_date": row["biz_date"], "amount": float(amount), "settled_amount": 0, "open_amount": float(amount), "status": row["status"], "remark": row["remark"], } ) return sorted(rows, key=lambda item: str(item["biz_date"]), reverse=True)[:limit] def get_work_orders_query(limit: int = 100) -> Select: product_item = aliased(Item) line_subquery = ( select( WorkOrderOperation.work_order_id.label("work_order_id"), func.count(WorkOrderOperation.id).label("operation_count"), func.coalesce(func.sum(WorkOrderOperation.is_abnormal), 0).label("abnormal_operation_count"), func.coalesce( func.sum( (WorkOrderOperation.status == "DONE").cast(Integer) # type: ignore[attr-defined] ), 0, ).label("done_count"), ) .group_by(WorkOrderOperation.work_order_id) .subquery() ) final_operation_seq_subquery = ( select( WorkOrderOperation.work_order_id.label("work_order_id"), func.max(WorkOrderOperation.seq_no).label("max_seq_no"), ) .group_by(WorkOrderOperation.work_order_id) .subquery() ) final_operation = aliased(WorkOrderOperation) active_scrap_record_ids = ( select(func.max(ScrapRecord.id).label("scrap_record_id")) .where(ScrapRecord.status != "OVERRIDDEN") .group_by(ScrapRecord.report_id) .subquery() ) rework_subquery = ( select( ScrapRecord.work_order_id.label("work_order_id"), func.coalesce(func.sum(ScrapRecord.scrap_qty), 0).label("rework_qty"), ) .where(ScrapRecord.id.in_(select(active_scrap_record_ids.c.scrap_record_id))) .where( ScrapRecord.disposal_type == "REWORK", ScrapRecord.status == "CONFIRMED", ) .group_by(ScrapRecord.work_order_id) .subquery() ) receipt_subquery = ( select( CompletionReceipt.work_order_id.label("work_order_id"), func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0).label("received_qty"), ) .join(CompletionReceiptItem, CompletionReceiptItem.completion_receipt_id == CompletionReceipt.id) .group_by(CompletionReceipt.work_order_id) .subquery() ) return ( select( WorkOrder.id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), WorkOrder.work_order_type.label("work_order_type"), WorkOrder.source_sales_order_item_id.label("source_sales_order_item_id"), SalesOrder.order_no.label("source_order_no"), WorkOrder.product_item_id.label("product_item_id"), product_item.item_code.label("product_code"), product_item.item_name.label("product_name"), Product.version_no.label("product_version_no"), WorkOrder.route_id.label("route_id"), ProcessRoute.route_name.label("route_name"), WorkOrder.bom_id.label("bom_id"), WorkOrder.planned_qty.label("planned_qty"), WorkOrder.released_qty.label("released_qty"), WorkOrder.finished_qty.label("finished_qty"), WorkOrder.scrap_qty.label("scrap_qty"), WorkOrder.planned_start_time.label("planned_start_time"), WorkOrder.planned_end_time.label("planned_end_time"), WorkOrder.actual_start_time.label("actual_start_time"), WorkOrder.actual_end_time.label("actual_end_time"), WorkOrder.priority_level.label("priority_level"), WorkOrder.status.label("status"), WorkOrder.remark.label("remark"), func.coalesce(line_subquery.c.operation_count, 0).label("operation_count"), func.coalesce(line_subquery.c.abnormal_operation_count, 0).label("abnormal_operation_count"), ( func.coalesce(final_operation.good_qty, 0) + func.coalesce(rework_subquery.c.rework_qty, 0) ).label("reported_good_qty"), func.coalesce(receipt_subquery.c.received_qty, 0).label("received_qty"), func.greatest( ( func.coalesce(final_operation.good_qty, 0) + func.coalesce(rework_subquery.c.rework_qty, 0) ) - func.coalesce(receipt_subquery.c.received_qty, 0), 0, ).label("max_receivable_qty"), ( (func.coalesce(line_subquery.c.operation_count, 0) > 0) & (func.coalesce(line_subquery.c.done_count, 0) == func.coalesce(line_subquery.c.operation_count, 0)) ).label("all_operations_done"), ) .join(product_item, product_item.id == WorkOrder.product_item_id) .join(ProcessRoute, ProcessRoute.id == WorkOrder.route_id) .outerjoin(Product, Product.item_id == WorkOrder.product_item_id) .outerjoin(SalesOrderItem, SalesOrderItem.id == WorkOrder.source_sales_order_item_id) .outerjoin(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) .outerjoin(line_subquery, line_subquery.c.work_order_id == WorkOrder.id) .outerjoin(final_operation_seq_subquery, final_operation_seq_subquery.c.work_order_id == WorkOrder.id) .outerjoin(rework_subquery, rework_subquery.c.work_order_id == WorkOrder.id) .outerjoin( final_operation, and_( final_operation.work_order_id == WorkOrder.id, final_operation.seq_no == final_operation_seq_subquery.c.max_seq_no, ), ) .outerjoin(receipt_subquery, receipt_subquery.c.work_order_id == WorkOrder.id) .order_by(WorkOrder.id.desc()) .limit(limit) ) def get_work_order_materials_query(limit: int = 200, work_order_id: int | None = None) -> Select: issue_subquery = ( select( WorkOrderMaterialIssue.work_order_material_id.label("work_order_material_id"), func.group_concat(WorkOrderMaterialIssue.material_sub_batch_no).label("material_sub_batch_summary"), func.coalesce(func.sum(WorkOrderMaterialIssue.amount), 0).label("issue_amount"), ) .group_by(WorkOrderMaterialIssue.work_order_material_id) .subquery() ) stmt = ( select( WorkOrderMaterial.id.label("work_order_material_id"), WorkOrderMaterial.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), WorkOrderMaterial.bom_item_id.label("bom_item_id"), WorkOrderMaterial.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), literal(0).label("required_qty"), WorkOrderMaterial.required_weight_kg.label("required_weight_kg"), literal(0).label("issued_qty"), WorkOrderMaterial.issued_weight_kg.label("issued_weight_kg"), literal(0).label("returned_qty"), WorkOrderMaterial.returned_weight_kg.label("returned_weight_kg"), issue_subquery.c.material_sub_batch_summary.label("material_sub_batch_summary"), func.coalesce(issue_subquery.c.issue_amount, 0).label("issue_amount"), WorkOrderMaterial.status.label("status"), WorkOrderMaterial.remark.label("remark"), ) .join(WorkOrder, WorkOrder.id == WorkOrderMaterial.work_order_id) .join(Item, Item.id == WorkOrderMaterial.material_item_id) .outerjoin(issue_subquery, issue_subquery.c.work_order_material_id == WorkOrderMaterial.id) .order_by(WorkOrderMaterial.id.desc()) .limit(limit) ) if work_order_id: stmt = stmt.where(WorkOrderMaterial.work_order_id == work_order_id) return stmt def get_work_order_material_issues_query(limit: int = 300, work_order_id: int | None = None) -> Select: stmt = ( select( WorkOrderMaterialIssue.id.label("material_issue_id"), WorkOrderMaterialIssue.work_order_material_id.label("work_order_material_id"), WorkOrderMaterialIssue.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), WorkOrderMaterialIssue.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), WorkOrderMaterialIssue.source_lot_id.label("source_lot_id"), StockLot.lot_no.label("source_lot_no"), WorkOrderMaterialIssue.material_sub_batch_no.label("material_sub_batch_no"), literal(0).label("issued_qty"), WorkOrderMaterialIssue.issued_weight_kg.label("issued_weight_kg"), WorkOrderMaterialIssue.unit_cost.label("unit_cost"), WorkOrderMaterialIssue.amount.label("amount"), WorkOrderMaterialIssue.issue_time.label("issue_time"), WorkOrderMaterialIssue.remark.label("remark"), ) .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) .join(Item, Item.id == WorkOrderMaterialIssue.material_item_id) .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) .order_by(WorkOrderMaterialIssue.issue_time.desc(), WorkOrderMaterialIssue.id.desc()) .limit(limit) ) if work_order_id: stmt = stmt.where(WorkOrderMaterialIssue.work_order_id == work_order_id) return stmt def get_work_order_operations_query(limit: int = 200, work_order_id: int | None = None) -> Select: stmt = ( select( WorkOrderOperation.id.label("work_order_operation_id"), WorkOrderOperation.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), WorkOrderOperation.route_operation_id.label("route_operation_id"), WorkOrderOperation.seq_no.label("seq_no"), WorkOrderOperation.operation_name.label("operation_name"), WorkOrderOperation.work_center_id.label("work_center_id"), WorkCenter.center_name.label("work_center_name"), WorkOrderOperation.planned_qty.label("planned_qty"), WorkOrderOperation.report_qty.label("report_qty"), WorkOrderOperation.good_qty.label("good_qty"), WorkOrderOperation.scrap_qty.label("scrap_qty"), WorkOrderOperation.rework_qty.label("rework_qty"), WorkOrderOperation.std_labor_cost.label("std_labor_cost"), WorkOrderOperation.std_machine_cost.label("std_machine_cost"), WorkOrderOperation.std_outsource_cost.label("std_outsource_cost"), WorkOrderOperation.allowed_scrap_rate.label("allowed_scrap_rate"), WorkOrderOperation.is_abnormal.label("is_abnormal"), WorkOrderOperation.status.label("status"), WorkOrderOperation.remark.label("remark"), ) .join(WorkOrder, WorkOrder.id == WorkOrderOperation.work_order_id) .join(WorkCenter, WorkCenter.id == WorkOrderOperation.work_center_id) .order_by(WorkOrderOperation.work_order_id.desc(), WorkOrderOperation.seq_no) .limit(limit) ) if work_order_id: stmt = stmt.where(WorkOrderOperation.work_order_id == work_order_id) return stmt def get_operation_reports_query(limit: int = 200, work_order_id: int | None = None) -> Select: employee = aliased(Employee) equipment = aliased(Equipment) stmt = ( select( OperationReport.id.label("report_id"), OperationReport.report_no.label("report_no"), OperationReport.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), OperationReport.work_order_operation_id.label("work_order_operation_id"), WorkOrderOperation.seq_no.label("seq_no"), WorkOrderOperation.operation_name.label("operation_name"), OperationReport.employee_id.label("employee_id"), employee.employee_name.label("employee_name"), OperationReport.equipment_id.label("equipment_id"), equipment.equipment_name.label("equipment_name"), OperationReport.report_source.label("report_source"), OperationReport.shift_code.label("shift_code"), OperationReport.start_time.label("start_time"), OperationReport.end_time.label("end_time"), OperationReport.report_qty.label("report_qty"), OperationReport.good_qty.label("good_qty"), OperationReport.scrap_qty.label("scrap_qty"), OperationReport.rework_qty.label("rework_qty"), OperationReport.downtime_minutes.label("downtime_minutes"), OperationReport.extra_cost_amount.label("extra_cost_amount"), OperationReport.is_abnormal.label("is_abnormal"), WorkOrderOperation.allowed_scrap_rate.label("allowed_scrap_rate"), ProcessRouteOperation.std_cycle_seconds.label("ref_cycle_seconds"), OperationReport.scrap_reason.label("scrap_reason"), OperationReport.remark.label("remark"), ) .join(WorkOrder, WorkOrder.id == OperationReport.work_order_id) .join(WorkOrderOperation, WorkOrderOperation.id == OperationReport.work_order_operation_id) .outerjoin(ProcessRouteOperation, ProcessRouteOperation.id == WorkOrderOperation.route_operation_id) .join(employee, employee.id == OperationReport.employee_id) .outerjoin(equipment, equipment.id == OperationReport.equipment_id) .order_by(OperationReport.id.desc()) .limit(limit) ) if work_order_id: stmt = stmt.where(OperationReport.work_order_id == work_order_id) return stmt def get_scrap_records_query(limit: int = 200) -> Select: return ( select( ScrapRecord.id.label("scrap_record_id"), ScrapRecord.scrap_no.label("scrap_no"), ScrapRecord.report_id.label("report_id"), OperationReport.report_no.label("report_no"), ScrapRecord.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), ScrapRecord.work_order_operation_id.label("work_order_operation_id"), WorkOrderOperation.operation_name.label("operation_name"), ScrapRecord.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), ScrapRecord.scrap_qty.label("scrap_qty"), ScrapRecord.scrap_weight_kg.label("scrap_weight_kg"), ScrapRecord.scrap_reason.label("scrap_reason"), ScrapRecord.disposal_type.label("disposal_type"), ScrapRecord.saleable_scrap_weight_kg.label("saleable_scrap_weight_kg"), ScrapRecord.scrap_cost_amount.label("scrap_cost_amount"), ScrapRecord.status.label("status"), ScrapRecord.created_at.label("created_at"), ) .join(OperationReport, OperationReport.id == ScrapRecord.report_id) .join(WorkOrder, WorkOrder.id == ScrapRecord.work_order_id) .join(WorkOrderOperation, WorkOrderOperation.id == ScrapRecord.work_order_operation_id) .join(Item, Item.id == ScrapRecord.item_id) .order_by(ScrapRecord.id.desc()) .limit(limit) ) def get_completion_receipts_query(limit: int = 100) -> Select: line_subquery = ( select( CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), func.count(CompletionReceiptItem.id).label("line_count"), func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0).label("total_receipt_qty"), func.coalesce(func.sum(CompletionReceiptItem.receipt_weight_kg), 0).label("total_receipt_weight_kg"), ) .group_by(CompletionReceiptItem.completion_receipt_id) .subquery() ) first_item_subquery = ( select( CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), func.min(CompletionReceiptItem.product_item_id).label("product_item_id"), ) .group_by(CompletionReceiptItem.completion_receipt_id) .subquery() ) receiver = aliased(Employee) return ( select( CompletionReceipt.id.label("completion_receipt_id"), CompletionReceipt.receipt_no.label("receipt_no"), CompletionReceipt.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), first_item_subquery.c.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), CompletionReceipt.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), CompletionReceipt.receipt_time.label("receipt_time"), CompletionReceipt.receiver_employee_id.label("receiver_employee_id"), receiver.employee_name.label("receiver_name"), CompletionReceipt.status.label("status"), func.coalesce(line_subquery.c.line_count, 0).label("line_count"), func.coalesce(line_subquery.c.total_receipt_qty, 0).label("total_receipt_qty"), func.coalesce(line_subquery.c.total_receipt_weight_kg, 0).label("total_receipt_weight_kg"), CompletionReceipt.remark.label("remark"), ) .join(WorkOrder, WorkOrder.id == CompletionReceipt.work_order_id) .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) .outerjoin(receiver, receiver.id == CompletionReceipt.receiver_employee_id) .outerjoin(line_subquery, line_subquery.c.completion_receipt_id == CompletionReceipt.id) .outerjoin(first_item_subquery, first_item_subquery.c.completion_receipt_id == CompletionReceipt.id) .outerjoin(Item, Item.id == first_item_subquery.c.product_item_id) .order_by(CompletionReceipt.id.desc()) .limit(limit) ) def get_completion_receipt_items_query(limit: int = 200, receipt_id: int | None = None) -> Select: source_material_lot = aliased(StockLot) stmt = ( select( CompletionReceiptItem.id.label("completion_receipt_item_id"), CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), CompletionReceipt.receipt_no.label("receipt_no"), CompletionReceiptItem.line_no.label("line_no"), CompletionReceiptItem.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), CompletionReceipt.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), CompletionReceiptItem.location_id.label("location_id"), WarehouseLocation.location_name.label("location_name"), CompletionReceiptItem.lot_no.label("lot_no"), CompletionReceiptItem.source_material_lot_id.label("source_material_lot_id"), func.coalesce(source_material_lot.lot_no, CompletionReceiptItem.source_material_sub_batch_no).label( "source_material_lot_no" ), func.coalesce(source_material_lot.lot_no, CompletionReceiptItem.source_material_sub_batch_no).label( "source_material_sub_batch_no" ), CompletionReceiptItem.source_material_summary.label("source_material_summary"), CompletionReceiptItem.receipt_qty.label("receipt_qty"), CompletionReceiptItem.receipt_weight_kg.label("receipt_weight_kg"), CompletionReceiptItem.unit_cost.label("unit_cost"), CompletionReceiptItem.status.label("status"), CompletionReceiptItem.remark.label("remark"), ) .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) .join(Item, Item.id == CompletionReceiptItem.product_item_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == CompletionReceiptItem.location_id) .outerjoin(source_material_lot, source_material_lot.id == CompletionReceiptItem.source_material_lot_id) .order_by(CompletionReceiptItem.id.desc()) .limit(limit) ) if receipt_id: stmt = stmt.where(CompletionReceiptItem.completion_receipt_id == receipt_id) return stmt def get_deliveries_query(limit: int = 100) -> Select: line_subquery = ( select( DeliveryItem.delivery_id.label("delivery_id"), func.count(DeliveryItem.id).label("line_count"), func.coalesce(func.sum(DeliveryItem.delivery_qty), 0).label("total_delivery_qty"), func.coalesce(func.sum(DeliveryItem.line_amount), 0).label("total_line_amount"), func.group_concat(func.distinct(Item.item_name)).label("product_names"), ) .join(Item, Item.id == DeliveryItem.product_item_id) .group_by(DeliveryItem.delivery_id) .subquery() ) shipper = aliased(Employee) order_customer = aliased(Customer) direct_customer = aliased(Customer) return ( select( Delivery.id.label("delivery_id"), Delivery.delivery_no.label("delivery_no"), Delivery.sales_order_id.label("sales_order_id"), Delivery.customer_id.label("customer_id"), SalesOrder.order_no.label("order_no"), func.coalesce(direct_customer.customer_name, order_customer.customer_name).label("customer_name"), Delivery.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), Delivery.delivery_date.label("delivery_date"), Delivery.shipper_employee_id.label("shipper_employee_id"), shipper.employee_name.label("shipper_name"), Delivery.consignee_name.label("consignee_name"), Delivery.consignee_phone.label("consignee_phone"), Delivery.delivery_address.label("delivery_address"), Delivery.logistics_waybill_no.label("logistics_waybill_no"), Delivery.logistics_freight_amount.label("logistics_freight_amount"), Delivery.logistics_photo_url.label("logistics_photo_url"), line_subquery.c.product_names.label("product_names"), Delivery.status.label("status"), func.coalesce(line_subquery.c.line_count, 0).label("line_count"), func.coalesce(line_subquery.c.total_delivery_qty, 0).label("total_delivery_qty"), func.coalesce(line_subquery.c.total_line_amount, 0).label("total_line_amount"), Delivery.remark.label("remark"), ) .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) .outerjoin(order_customer, order_customer.id == SalesOrder.customer_id) .outerjoin(direct_customer, direct_customer.id == Delivery.customer_id) .join(Warehouse, Warehouse.id == Delivery.warehouse_id) .outerjoin(shipper, shipper.id == Delivery.shipper_employee_id) .outerjoin(line_subquery, line_subquery.c.delivery_id == Delivery.id) .order_by(Delivery.id.desc()) .limit(limit) ) def get_delivery_items_query(limit: int = 200, delivery_id: int | None = None) -> Select: source_material_lot = aliased(StockLot) source_material_item = aliased(Item) stmt = ( select( DeliveryItem.id.label("delivery_item_id"), DeliveryItem.delivery_id.label("delivery_id"), Delivery.delivery_no.label("delivery_no"), DeliveryItem.line_no.label("line_no"), DeliveryItem.sales_order_item_id.label("sales_order_item_id"), SalesOrder.order_no.label("order_no"), DeliveryItem.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), DeliveryItem.lot_id.label("lot_id"), StockLot.lot_no.label("lot_no"), StockLot.source_material_lot_id.label("source_material_lot_id"), func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label("source_material_lot_no"), source_material_item.item_code.label("source_material_code"), source_material_item.item_name.label("source_material_name"), func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( "source_material_sub_batch_no" ), StockLot.source_material_summary.label("source_material_summary"), DeliveryItem.delivery_qty.label("delivery_qty"), DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), DeliveryItem.unit_price.label("unit_price"), DeliveryItem.line_amount.label("line_amount"), DeliveryItem.status.label("status"), DeliveryItem.remark.label("remark"), ) .join(Delivery, Delivery.id == DeliveryItem.delivery_id) .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) .join(Item, Item.id == DeliveryItem.product_item_id) .outerjoin(StockLot, StockLot.id == DeliveryItem.lot_id) .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) .order_by(DeliveryItem.id.desc()) .limit(limit) ) if delivery_id: stmt = stmt.where(DeliveryItem.delivery_id == delivery_id) return stmt def get_equipment_query(limit: int = 100) -> Select: return ( select( Equipment.id.label("equipment_id"), Equipment.equipment_code.label("equipment_code"), Equipment.equipment_name.label("equipment_name"), Equipment.category_name.label("category_name"), Equipment.work_center_id.label("work_center_id"), WorkCenter.center_name.label("work_center_name"), Equipment.manufacturer.label("manufacturer"), Equipment.model_no.label("model_no"), Equipment.install_date.label("install_date"), Equipment.rated_power_kw.label("rated_power_kw"), Equipment.occupied_area_sqm.label("occupied_area_sqm"), Equipment.maintenance_cycle_hours.label("maintenance_cycle_hours"), Equipment.last_maintenance_at.label("last_maintenance_at"), Equipment.next_maintenance_at.label("next_maintenance_at"), Equipment.status.label("status"), Equipment.remark.label("remark"), ) .join(WorkCenter, WorkCenter.id == Equipment.work_center_id) .order_by(Equipment.id.desc()) .limit(limit) ) def get_maintenance_plans_query(limit: int = 100) -> Select: return ( select( MaintenancePlan.id.label("maintenance_plan_id"), MaintenancePlan.plan_code.label("plan_code"), MaintenancePlan.equipment_id.label("equipment_id"), Equipment.equipment_code.label("equipment_code"), Equipment.equipment_name.label("equipment_name"), MaintenancePlan.plan_name.label("plan_name"), MaintenancePlan.maintenance_type.label("maintenance_type"), MaintenancePlan.cycle_hours.label("cycle_hours"), MaintenancePlan.cycle_days.label("cycle_days"), MaintenancePlan.last_execute_at.label("last_execute_at"), MaintenancePlan.next_execute_at.label("next_execute_at"), MaintenancePlan.status.label("status"), MaintenancePlan.remark.label("remark"), ) .join(Equipment, Equipment.id == MaintenancePlan.equipment_id) .order_by(MaintenancePlan.id.desc()) .limit(limit) ) def get_maintenance_orders_query(limit: int = 100) -> Select: owner = aliased(Employee) return ( select( MaintenanceOrder.id.label("maintenance_order_id"), MaintenanceOrder.order_no.label("order_no"), MaintenanceOrder.equipment_id.label("equipment_id"), Equipment.equipment_code.label("equipment_code"), Equipment.equipment_name.label("equipment_name"), MaintenanceOrder.plan_id.label("plan_id"), MaintenancePlan.plan_name.label("plan_name"), MaintenanceOrder.maintenance_type.label("maintenance_type"), MaintenanceOrder.start_time.label("start_time"), MaintenanceOrder.end_time.label("end_time"), MaintenanceOrder.downtime_minutes.label("downtime_minutes"), MaintenanceOrder.owner_employee_id.label("owner_employee_id"), owner.employee_name.label("owner_name"), MaintenanceOrder.result_summary.label("result_summary"), MaintenanceOrder.cost_amount.label("cost_amount"), MaintenanceOrder.status.label("status"), ) .join(Equipment, Equipment.id == MaintenanceOrder.equipment_id) .outerjoin(MaintenancePlan, MaintenancePlan.id == MaintenanceOrder.plan_id) .outerjoin(owner, owner.id == MaintenanceOrder.owner_employee_id) .order_by(MaintenanceOrder.id.desc()) .limit(limit) ) def get_quality_inspections_query(limit: int = 100, pending_only: bool = False) -> Select: latest_archive_version_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.archive_version).label("archive_version"), ) .where(DocumentArchive.document_type == "质量校验单", DocumentArchive.file_format == "PDF") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_id_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.id).label("archive_id"), ) .join( latest_archive_version_subquery, and_( latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, ), ) .where(DocumentArchive.document_type == "质量校验单", DocumentArchive.file_format == "PDF") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_subquery = ( select( DocumentArchive.business_id.label("business_id"), DocumentArchive.status.label("archive_status"), DocumentArchive.archive_version.label("archive_version"), DocumentArchive.created_at.label("archive_created_at"), DocumentArchive.error_message.label("archive_error_message"), ) .join( latest_archive_id_subquery, latest_archive_id_subquery.c.archive_id == DocumentArchive.id, ) .where(DocumentArchive.document_type == "质量校验单", DocumentArchive.file_format == "PDF") .subquery() ) stmt = ( select( PurchaseReceiptItem.id.label("receipt_item_id"), PurchaseReceiptItem.receipt_id.label("receipt_id"), PurchaseReceipt.receipt_no.label("receipt_no"), PurchaseOrder.po_no.label("po_no"), Supplier.supplier_name.label("supplier_name"), PurchaseOrder.target_warehouse_type.label("target_warehouse_type"), PurchaseReceiptItem.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), Warehouse.warehouse_name.label("warehouse_name"), WarehouseLocation.location_name.label("location_name"), PurchaseReceiptItem.lot_no.label("lot_no"), PurchaseReceiptItem.received_qty.label("received_qty"), PurchaseReceiptItem.received_weight_kg.label("received_weight_kg"), PurchaseReceiptItem.accepted_qty.label("accepted_qty"), PurchaseReceiptItem.accepted_weight_kg.label("accepted_weight_kg"), PurchaseReceiptItem.status.label("status"), case( (PurchaseReceiptItem.status == "PENDING_QC", "PENDING_QC"), (PurchaseReceiptItem.status == "PASSED", "PASS"), (PurchaseReceiptItem.status == "REJECTED", "REJECT"), else_=func.coalesce(StockLot.quality_status, "PENDING_QC"), ).label("quality_status"), StockLot.remark.label("inspector_name"), PurchaseReceipt.receipt_date.label("receipt_date"), PurchaseReceiptItem.remark.label("remark"), func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), latest_archive_subquery.c.archive_version.label("archive_version"), latest_archive_subquery.c.archive_created_at.label("archive_created_at"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .join(Item, Item.id == PurchaseReceiptItem.material_item_id) .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == PurchaseReceiptItem.location_id) .outerjoin( StockLot, or_( and_( StockLot.source_doc_type == "PURCHASE_RECEIPT", StockLot.source_doc_id == PurchaseReceipt.id, StockLot.source_line_id == PurchaseReceiptItem.id, ), StockLot.id == PurchaseReceiptItem.merge_to_lot_id, ), ) .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == PurchaseReceiptItem.id) .order_by(PurchaseReceiptItem.id.desc()) .limit(limit) ) if pending_only: stmt = stmt.where(PurchaseReceiptItem.status == "PENDING_QC") return stmt def get_periods_query(limit: int = 50) -> Select: return ( select( AccountingPeriod.id.label("period_id"), AccountingPeriod.period_code.label("period_code"), AccountingPeriod.year_no.label("year_no"), AccountingPeriod.month_no.label("month_no"), AccountingPeriod.start_date.label("start_date"), AccountingPeriod.end_date.label("end_date"), AccountingPeriod.close_status.label("close_status"), AccountingPeriod.closed_at.label("closed_at"), ) .order_by(AccountingPeriod.year_no.desc(), AccountingPeriod.month_no.desc()) .limit(limit) ) def get_overheads_query(limit: int = 100) -> Select: return ( select( OverheadEntry.id.label("overhead_entry_id"), OverheadEntry.period_id.label("period_id"), AccountingPeriod.period_code.label("period_code"), AccountingPeriod.year_no.label("year_no"), AccountingPeriod.month_no.label("month_no"), OverheadEntry.overhead_type.label("overhead_type"), OverheadEntry.dept_id.label("dept_id"), Department.dept_name.label("dept_name"), OverheadEntry.amount.label("amount"), OverheadEntry.allocation_basis.label("allocation_basis"), OverheadEntry.description.label("description"), OverheadEntry.created_at.label("created_at"), ) .join(AccountingPeriod, AccountingPeriod.id == OverheadEntry.period_id) .outerjoin(Department, Department.id == OverheadEntry.dept_id) .order_by(OverheadEntry.id.desc()) .limit(limit) ) def get_statement_snapshots_query(limit: int = 100) -> Select: return ( select( StatementSnapshot.id.label("statement_snapshot_id"), StatementSnapshot.snapshot_no.label("snapshot_no"), StatementSnapshot.period_id.label("period_id"), AccountingPeriod.period_code.label("period_code"), StatementSnapshot.statement_type.label("statement_type"), StatementSnapshot.report_date.label("report_date"), StatementSnapshot.summary_json.label("summary_json"), StatementSnapshot.created_at.label("created_at"), ) .join(AccountingPeriod, AccountingPeriod.id == StatementSnapshot.period_id) .order_by(StatementSnapshot.id.desc()) .limit(limit) ) def get_return_orders_query(limit: int = 100) -> Select: line_subquery = ( select( ReturnItem.return_order_id.label("return_order_id"), func.count(ReturnItem.id).label("line_count"), func.coalesce(func.sum(ReturnItem.return_qty), 0).label("total_return_qty"), func.group_concat(func.distinct(Item.item_name)).label("product_names"), ) .join(Item, Item.id == ReturnItem.product_item_id) .group_by(ReturnItem.return_order_id) .subquery() ) handler = aliased(Employee) return ( select( ReturnOrder.id.label("return_order_id"), ReturnOrder.return_no.label("return_no"), ReturnOrder.customer_id.label("customer_id"), Customer.customer_name.label("customer_name"), ReturnOrder.sales_order_id.label("sales_order_id"), SalesOrder.order_no.label("order_no"), ReturnOrder.delivery_id.label("delivery_id"), Delivery.delivery_no.label("delivery_no"), ReturnOrder.return_date.label("return_date"), ReturnOrder.return_reason.label("return_reason"), ReturnOrder.handler_employee_id.label("handler_employee_id"), handler.employee_name.label("handler_name"), line_subquery.c.product_names.label("product_names"), ReturnOrder.status.label("status"), func.coalesce(line_subquery.c.line_count, 0).label("line_count"), func.coalesce(line_subquery.c.total_return_qty, 0).label("total_return_qty"), ReturnOrder.remark.label("remark"), ) .join(Customer, Customer.id == ReturnOrder.customer_id) .outerjoin(SalesOrder, SalesOrder.id == ReturnOrder.sales_order_id) .outerjoin(Delivery, Delivery.id == ReturnOrder.delivery_id) .outerjoin(handler, handler.id == ReturnOrder.handler_employee_id) .outerjoin(line_subquery, line_subquery.c.return_order_id == ReturnOrder.id) .order_by(ReturnOrder.id.desc()) .limit(limit) ) def get_return_items_query(limit: int = 200, return_order_id: int | None = None) -> Select: source_lot = aliased(StockLot) source_material_lot = aliased(StockLot) source_material_item = aliased(Item) stmt = ( select( ReturnItem.id.label("return_item_id"), ReturnItem.return_order_id.label("return_order_id"), ReturnOrder.return_no.label("return_no"), ReturnItem.line_no.label("line_no"), ReturnItem.sales_order_item_id.label("sales_order_item_id"), ReturnItem.delivery_item_id.label("delivery_item_id"), Delivery.delivery_no.label("delivery_no"), ReturnItem.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), ReturnItem.source_lot_id.label("source_lot_id"), source_lot.lot_no.label("source_lot_no"), source_lot.source_material_lot_id.label("source_material_lot_id"), func.coalesce(source_material_lot.lot_no, source_lot.source_material_sub_batch_no).label("source_material_lot_no"), source_material_item.item_code.label("source_material_code"), source_material_item.item_name.label("source_material_name"), func.coalesce(source_material_lot.lot_no, source_lot.source_material_sub_batch_no).label( "source_material_sub_batch_no" ), source_lot.source_material_summary.label("source_material_summary"), ReturnItem.return_qty.label("return_qty"), ReturnItem.return_weight_kg.label("return_weight_kg"), ReturnItem.disposition_status.label("disposition_status"), ReturnItem.responsibility_type.label("responsibility_type"), ReturnItem.remark.label("remark"), ) .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) .join(Item, Item.id == ReturnItem.product_item_id) .outerjoin(DeliveryItem, DeliveryItem.id == ReturnItem.delivery_item_id) .outerjoin(Delivery, Delivery.id == DeliveryItem.delivery_id) .outerjoin(source_lot, source_lot.id == ReturnItem.source_lot_id) .outerjoin(source_material_lot, source_material_lot.id == source_lot.source_material_lot_id) .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) .order_by(ReturnItem.id.desc()) .limit(limit) ) if return_order_id: stmt = stmt.where(ReturnItem.return_order_id == return_order_id) return stmt def get_return_dispositions_query(limit: int = 100) -> Select: rework_work_order = aliased(WorkOrder) report_subquery = ( select( OperationReport.work_order_id.label("work_order_id"), func.coalesce(func.sum(OperationReport.good_qty), 0).label("rework_report_good_qty"), func.coalesce(func.sum(OperationReport.scrap_qty), 0).label("rework_report_scrap_qty"), ) .group_by(OperationReport.work_order_id) .subquery() ) finished_inbound_subquery = ( select( InventoryTxn.source_doc_id.label("work_order_id"), func.coalesce(func.sum(InventoryTxn.qty_change), 0).label("rework_finished_inbound_qty"), ) .where( InventoryTxn.txn_type == "REWORK_FINISHED_IN", InventoryTxn.source_doc_type == "WORK_ORDER", ) .group_by(InventoryTxn.source_doc_id) .subquery() ) scrap_inbound_subquery = ( select( StockLot.source_doc_id.label("work_order_id"), func.coalesce(func.sum(StockLot.inbound_qty), 0).label("rework_scrap_inbound_qty"), ) .where( StockLot.lot_role == "REWORK_SCRAP", StockLot.source_doc_type == "WORK_ORDER", ) .group_by(StockLot.source_doc_id) .subquery() ) report_good_qty = func.coalesce(report_subquery.c.rework_report_good_qty, 0) report_scrap_qty = func.coalesce(report_subquery.c.rework_report_scrap_qty, 0) finished_inbound_qty = func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0) scrap_inbound_qty = func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0) remaining_finished_qty = case((report_good_qty - finished_inbound_qty < 0, 0), else_=report_good_qty - finished_inbound_qty) remaining_scrap_qty = case((report_scrap_qty - scrap_inbound_qty < 0, 0), else_=report_scrap_qty - scrap_inbound_qty) processed_qty = finished_inbound_qty + scrap_inbound_qty return ( select( ReturnDisposition.id.label("return_disposition_id"), ReturnDisposition.disposition_no.label("disposition_no"), ReturnDisposition.return_item_id.label("return_item_id"), ReturnOrder.return_no.label("return_no"), ReturnItem.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), ReturnItem.return_qty.label("return_qty"), ReturnItem.return_weight_kg.label("return_weight_kg"), ReturnDisposition.disposition_type.label("disposition_type"), ReturnDisposition.rework_work_order_id.label("rework_work_order_id"), rework_work_order.work_order_no.label("rework_work_order_no"), report_good_qty.label("rework_report_good_qty"), report_scrap_qty.label("rework_report_scrap_qty"), finished_inbound_qty.label("rework_finished_inbound_qty"), scrap_inbound_qty.label("rework_scrap_inbound_qty"), remaining_finished_qty.label("remaining_rework_finished_inbound_qty"), remaining_scrap_qty.label("remaining_rework_scrap_inbound_qty"), case((processed_qty >= ReturnItem.return_qty, True), else_=False).label("rework_close_ready"), case( (func.upper(func.coalesce(rework_work_order.status, "")) == "SETTLED", "已结单"), (processed_qty >= ReturnItem.return_qty, "可结单"), (processed_qty > 0, "部分处理"), else_="待处理", ).label("rework_close_status"), ReturnDisposition.scrap_weight_kg.label("scrap_weight_kg"), ReturnDisposition.extra_cost_amount.label("extra_cost_amount"), ReturnDisposition.scrap_sale_amount.label("scrap_sale_amount"), ReturnDisposition.closed_at.label("closed_at"), ReturnDisposition.status.label("status"), ReturnDisposition.remark.label("remark"), ) .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id) .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) .join(Item, Item.id == ReturnItem.product_item_id) .outerjoin(rework_work_order, rework_work_order.id == ReturnDisposition.rework_work_order_id) .outerjoin(report_subquery, report_subquery.c.work_order_id == ReturnDisposition.rework_work_order_id) .outerjoin(finished_inbound_subquery, finished_inbound_subquery.c.work_order_id == ReturnDisposition.rework_work_order_id) .outerjoin(scrap_inbound_subquery, scrap_inbound_subquery.c.work_order_id == ReturnDisposition.rework_work_order_id) .order_by(ReturnDisposition.id.desc()) .limit(limit) )