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]