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

398 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]