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

4074 lines
188 KiB
Python

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)
)