398 lines
18 KiB
Python
398 lines
18 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from decimal import Decimal, ROUND_UP
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import Select, and_, case, delete, func, select
|
||
from sqlalchemy.orm import Session, aliased
|
||
|
||
from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance
|
||
from app.models.document_archive import DocumentArchive
|
||
from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, WorkOrder
|
||
from app.models.org import Employee
|
||
from app.models.planning import MaterialDemand
|
||
from app.models.sales import Customer, SalesOrder, SalesOrderItem
|
||
from app.schemas.database import MaterialDemandRead
|
||
|
||
|
||
def build_serial(prefix: str) -> str:
|
||
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')[-12:]}"
|
||
|
||
|
||
def code_token(value: str | None, fallback: str, max_length: int = 12) -> str:
|
||
token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
|
||
return (token or fallback)[-max_length:]
|
||
|
||
|
||
def next_doc_no(db: Session, model, column_name: str, prefix: str) -> str:
|
||
column = getattr(model, column_name)
|
||
count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0
|
||
return f"{prefix}-{count + 1:03d}"
|
||
|
||
|
||
def decimalize(value: float | Decimal, places: str = "0.000001") -> Decimal:
|
||
return Decimal(str(value)).quantize(Decimal(places))
|
||
|
||
|
||
def round_up_by_multiple(value: Decimal, multiple: Decimal) -> Decimal:
|
||
if multiple <= 0:
|
||
return decimalize(value)
|
||
return ((value / multiple).quantize(Decimal("1"), rounding=ROUND_UP) * multiple).quantize(Decimal("0.000001"))
|
||
|
||
|
||
def create_customer_code(db: Session) -> str:
|
||
date_part = date.today().strftime("%Y%m%d")
|
||
return next_doc_no(db, Customer, "customer_code", f"CUS-NB-{date_part}")
|
||
|
||
|
||
def build_mrp_demand_no(db: Session, sales_order: SalesOrder, order_item: SalesOrderItem, material_item: Item) -> str:
|
||
date_part = date.today().strftime("%Y%m%d")
|
||
order_part = code_token(sales_order.order_no, f"SO{sales_order.id:04d}", 12)
|
||
material_part = code_token(material_item.item_code or material_item.item_name, f"RM{material_item.id:04d}", 10)
|
||
prefix = f"MRP-NB-{date_part}-{order_part}-L{int(order_item.line_no or 0):02d}-{material_part}"
|
||
return next_doc_no(db, MaterialDemand, "demand_no", prefix)
|
||
|
||
|
||
def get_sales_order_query(limit: int = 50) -> Select:
|
||
sales_employee = aliased(Employee)
|
||
line_count_subquery = (
|
||
select(
|
||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||
func.count(SalesOrderItem.id).label("line_count"),
|
||
)
|
||
.group_by(SalesOrderItem.sales_order_id)
|
||
.subquery()
|
||
)
|
||
mrp_count_subquery = (
|
||
select(
|
||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||
func.count(MaterialDemand.id).label("mrp_count"),
|
||
)
|
||
.join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
|
||
.where(MaterialDemand.status != "SUPERSEDED")
|
||
.group_by(SalesOrderItem.sales_order_id)
|
||
.subquery()
|
||
)
|
||
purchase_count_subquery = (
|
||
select(
|
||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||
func.count(func.distinct(PurchaseOrderItem.purchase_order_id)).label("purchase_order_count"),
|
||
)
|
||
.join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
|
||
.join(PurchaseOrderItem, PurchaseOrderItem.source_demand_id == MaterialDemand.id)
|
||
.group_by(SalesOrderItem.sales_order_id)
|
||
.subquery()
|
||
)
|
||
delivery_count_subquery = (
|
||
select(
|
||
Delivery.sales_order_id.label("sales_order_id"),
|
||
func.count(Delivery.id).label("delivery_count"),
|
||
)
|
||
.where(Delivery.sales_order_id.is_not(None))
|
||
.group_by(Delivery.sales_order_id)
|
||
.subquery()
|
||
)
|
||
delivery_item_count_subquery = (
|
||
select(
|
||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||
func.count(DeliveryItem.id).label("delivery_item_count"),
|
||
)
|
||
.join(DeliveryItem, DeliveryItem.sales_order_item_id == SalesOrderItem.id)
|
||
.group_by(SalesOrderItem.sales_order_id)
|
||
.subquery()
|
||
)
|
||
work_order_count_subquery = (
|
||
select(
|
||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||
func.count(WorkOrder.id).label("work_order_count"),
|
||
)
|
||
.join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id)
|
||
.group_by(SalesOrderItem.sales_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()
|
||
)
|
||
|
||
return (
|
||
select(
|
||
SalesOrder.id.label("order_id"),
|
||
SalesOrder.order_no.label("order_no"),
|
||
SalesOrder.customer_id.label("customer_id"),
|
||
Customer.customer_code.label("customer_code"),
|
||
Customer.customer_name.label("customer_name"),
|
||
SalesOrder.sales_employee_id.label("sales_employee_id"),
|
||
sales_employee.employee_name.label("sales_employee_name"),
|
||
SalesOrder.order_date.label("order_date"),
|
||
SalesOrder.promised_date.label("promised_date"),
|
||
SalesOrder.delivery_address.label("delivery_address"),
|
||
SalesOrder.total_amount.label("total_amount"),
|
||
SalesOrder.status.label("status"),
|
||
func.coalesce(line_count_subquery.c.line_count, 0).label("line_count"),
|
||
func.coalesce(mrp_count_subquery.c.mrp_count, 0).label("mrp_count"),
|
||
func.coalesce(purchase_count_subquery.c.purchase_order_count, 0).label("purchase_order_count"),
|
||
func.coalesce(delivery_count_subquery.c.delivery_count, 0).label("delivery_count"),
|
||
func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0).label("delivery_item_count"),
|
||
func.coalesce(work_order_count_subquery.c.work_order_count, 0).label("work_order_count"),
|
||
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"),
|
||
case(
|
||
(
|
||
and_(
|
||
func.coalesce(purchase_count_subquery.c.purchase_order_count, 0) == 0,
|
||
func.coalesce(delivery_count_subquery.c.delivery_count, 0) == 0,
|
||
func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0) == 0,
|
||
func.coalesce(work_order_count_subquery.c.work_order_count, 0) == 0,
|
||
),
|
||
True,
|
||
),
|
||
else_=False,
|
||
).label("can_delete"),
|
||
)
|
||
.join(Customer, Customer.id == SalesOrder.customer_id)
|
||
.outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id)
|
||
.outerjoin(line_count_subquery, line_count_subquery.c.sales_order_id == SalesOrder.id)
|
||
.outerjoin(mrp_count_subquery, mrp_count_subquery.c.sales_order_id == SalesOrder.id)
|
||
.outerjoin(purchase_count_subquery, purchase_count_subquery.c.sales_order_id == SalesOrder.id)
|
||
.outerjoin(delivery_count_subquery, delivery_count_subquery.c.sales_order_id == SalesOrder.id)
|
||
.outerjoin(delivery_item_count_subquery, delivery_item_count_subquery.c.sales_order_id == SalesOrder.id)
|
||
.outerjoin(work_order_count_subquery, work_order_count_subquery.c.sales_order_id == SalesOrder.id)
|
||
.outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == SalesOrder.id)
|
||
.order_by(SalesOrder.id.desc())
|
||
.limit(limit)
|
||
)
|
||
|
||
|
||
def get_sales_order_items_query(limit: int = 50, sales_order_id: int | None = None) -> Select:
|
||
stmt = (
|
||
select(
|
||
SalesOrderItem.id.label("sales_order_item_id"),
|
||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||
SalesOrder.order_no.label("order_no"),
|
||
SalesOrderItem.line_no.label("line_no"),
|
||
SalesOrderItem.product_item_id.label("product_item_id"),
|
||
Item.item_code.label("product_code"),
|
||
Item.item_name.label("product_name"),
|
||
SalesOrderItem.customer_part_no.label("customer_part_no"),
|
||
SalesOrderItem.order_qty.label("order_qty"),
|
||
SalesOrderItem.delivered_qty.label("delivered_qty"),
|
||
SalesOrderItem.unit_price.label("unit_price"),
|
||
SalesOrderItem.line_amount.label("line_amount"),
|
||
SalesOrderItem.promised_date.label("promised_date"),
|
||
SalesOrderItem.status.label("status"),
|
||
)
|
||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||
.join(Item, Item.id == SalesOrderItem.product_item_id)
|
||
.order_by(SalesOrderItem.id.desc())
|
||
.limit(limit)
|
||
)
|
||
if sales_order_id:
|
||
stmt = stmt.where(SalesOrderItem.sales_order_id == sales_order_id)
|
||
return stmt
|
||
|
||
|
||
def get_mrp_demands_query(limit: int = 50, sales_order_id: int | None = None) -> Select:
|
||
product_item = aliased(Item)
|
||
material_item = aliased(Item)
|
||
material_ext = aliased(Material)
|
||
|
||
stmt = (
|
||
select(
|
||
MaterialDemand.id.label("demand_id"),
|
||
MaterialDemand.demand_no.label("demand_no"),
|
||
SalesOrder.order_no.label("order_no"),
|
||
MaterialDemand.material_item_id.label("material_item_id"),
|
||
product_item.item_code.label("product_code"),
|
||
product_item.item_name.label("product_name"),
|
||
material_item.item_code.label("material_code"),
|
||
material_item.item_name.label("material_name"),
|
||
material_ext.purchase_calc_mode.label("purchase_calc_mode"),
|
||
MaterialDemand.order_qty.label("order_qty"),
|
||
MaterialDemand.theoretical_weight_kg.label("theoretical_weight_kg"),
|
||
MaterialDemand.planned_weight_kg.label("planned_weight_kg"),
|
||
MaterialDemand.shortage_weight_kg.label("shortage_weight_kg"),
|
||
MaterialDemand.suggested_purchase_qty.label("suggested_purchase_qty"),
|
||
MaterialDemand.suggested_purchase_weight_kg.label("suggested_purchase_weight_kg"),
|
||
MaterialDemand.status.label("status"),
|
||
MaterialDemand.generated_at.label("generated_at"),
|
||
)
|
||
.join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id)
|
||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||
.join(product_item, product_item.id == MaterialDemand.product_item_id)
|
||
.join(material_item, material_item.id == MaterialDemand.material_item_id)
|
||
.outerjoin(material_ext, material_ext.item_id == MaterialDemand.material_item_id)
|
||
.where(MaterialDemand.status != "SUPERSEDED")
|
||
.order_by(MaterialDemand.id.desc())
|
||
.limit(limit)
|
||
)
|
||
if sales_order_id:
|
||
stmt = stmt.where(SalesOrder.id == sales_order_id)
|
||
return stmt
|
||
|
||
|
||
def generate_mrp_for_order(db: Session, sales_order_id: int) -> tuple[SalesOrder, list[MaterialDemandRead]]:
|
||
sales_order = db.get(SalesOrder, sales_order_id)
|
||
if not sales_order:
|
||
raise HTTPException(status_code=404, detail="订单不存在")
|
||
|
||
order_items = db.scalars(
|
||
select(SalesOrderItem).where(SalesOrderItem.sales_order_id == sales_order_id).order_by(SalesOrderItem.line_no)
|
||
).all()
|
||
if not order_items:
|
||
raise HTTPException(status_code=400, detail="订单没有明细,无法生成MRP")
|
||
|
||
item_ids = [item.id for item in order_items]
|
||
if item_ids:
|
||
existing_demands = db.scalars(
|
||
select(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids))
|
||
).all()
|
||
existing_demand_ids = [demand.id for demand in existing_demands]
|
||
referenced_demand_ids: set[int] = set()
|
||
if existing_demand_ids:
|
||
referenced_demand_ids = set(
|
||
db.scalars(
|
||
select(PurchaseOrderItem.source_demand_id).where(
|
||
PurchaseOrderItem.source_demand_id.in_(existing_demand_ids)
|
||
)
|
||
).all()
|
||
)
|
||
|
||
deletable_demand_ids = [
|
||
demand_id for demand_id in existing_demand_ids if demand_id not in referenced_demand_ids
|
||
]
|
||
if deletable_demand_ids:
|
||
db.execute(delete(MaterialDemand).where(MaterialDemand.id.in_(deletable_demand_ids)))
|
||
|
||
for demand in existing_demands:
|
||
if demand.id in referenced_demand_ids:
|
||
demand.status = "SUPERSEDED"
|
||
demand.remark = "已被重新生成的MRP替代,因已被采购订单引用而保留追溯"
|
||
db.add(demand)
|
||
|
||
db.flush()
|
||
|
||
generated_at = datetime.now()
|
||
created_rows: list[MaterialDemand] = []
|
||
|
||
for order_item in order_items:
|
||
product = db.get(Item, order_item.product_item_id)
|
||
if not product:
|
||
raise HTTPException(status_code=400, detail=f"产品ID {order_item.product_item_id} 不存在")
|
||
|
||
bom = db.scalar(
|
||
select(Bom).where(Bom.product_item_id == product.id, Bom.is_default == 1, Bom.status == "ACTIVE")
|
||
)
|
||
if not bom:
|
||
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 缺少默认BOM")
|
||
|
||
bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all()
|
||
if not bom_items:
|
||
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的BOM没有明细")
|
||
|
||
order_qty = decimalize(order_item.order_qty)
|
||
|
||
for bom_item in bom_items:
|
||
material_item = db.get(Item, bom_item.material_item_id)
|
||
if not material_item:
|
||
raise HTTPException(status_code=400, detail=f"BOM原材料ID {bom_item.material_item_id} 不存在")
|
||
|
||
material_ext = db.scalar(select(Material).where(Material.item_id == material_item.id))
|
||
if not material_ext:
|
||
raise HTTPException(status_code=400, detail=f"原材料 {material_item.item_name} 缺少采购扩展信息")
|
||
|
||
loss_multiplier = Decimal("1") + decimalize(bom_item.loss_rate or 0, "0.0001")
|
||
material_unit_weight = Decimal(str(material_item.unit_weight_kg or 0))
|
||
|
||
available_stock_weight = Decimal(
|
||
str(
|
||
db.scalar(
|
||
select(func.coalesce(func.sum(StockBalance.weight_available_kg), 0)).where(
|
||
StockBalance.item_id == material_item.id
|
||
)
|
||
)
|
||
or 0
|
||
)
|
||
)
|
||
|
||
gross_weight_per_piece = decimalize(bom_item.gross_weight_per_piece_kg)
|
||
if gross_weight_per_piece <= 0 and material_unit_weight > 0:
|
||
gross_weight_per_piece = decimalize(bom_item.qty_per_piece) * material_unit_weight
|
||
theoretical_weight = order_qty * gross_weight_per_piece
|
||
planned_weight = theoretical_weight * loss_multiplier
|
||
|
||
shortage_weight = max(Decimal("0"), planned_weight - available_stock_weight)
|
||
|
||
suggested_purchase_weight = shortage_weight
|
||
purchase_multiple_weight = Decimal(str(material_ext.purchase_multiple_weight_kg or 0))
|
||
min_purchase_weight = Decimal(str(material_ext.min_purchase_qty or 0))
|
||
if suggested_purchase_weight > 0:
|
||
if purchase_multiple_weight > 0:
|
||
suggested_purchase_weight = round_up_by_multiple(suggested_purchase_weight, purchase_multiple_weight)
|
||
elif min_purchase_weight > 0 and suggested_purchase_weight < min_purchase_weight:
|
||
suggested_purchase_weight = min_purchase_weight.quantize(Decimal("0.000001"))
|
||
|
||
demand = MaterialDemand(
|
||
demand_no=build_mrp_demand_no(db, sales_order, order_item, material_item),
|
||
sales_order_item_id=order_item.id,
|
||
product_item_id=product.id,
|
||
material_item_id=material_item.id,
|
||
bom_item_id=bom_item.id,
|
||
order_qty=order_qty,
|
||
theoretical_weight_kg=theoretical_weight,
|
||
planned_weight_kg=planned_weight,
|
||
available_stock_weight_kg=available_stock_weight,
|
||
shortage_weight_kg=shortage_weight,
|
||
suggested_purchase_qty=Decimal("0"),
|
||
suggested_purchase_weight_kg=suggested_purchase_weight,
|
||
status="NEW",
|
||
generated_at=generated_at,
|
||
remark="由订单自动生成",
|
||
)
|
||
db.add(demand)
|
||
created_rows.append(demand)
|
||
|
||
db.commit()
|
||
|
||
rows = db.execute(get_mrp_demands_query(limit=200, sales_order_id=sales_order_id)).mappings().all()
|
||
return sales_order, [MaterialDemandRead.model_validate(dict(row)) for row in rows]
|