4074 lines
188 KiB
Python
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)
|
|
)
|