Files
geMoldInsight/src/inventory/services/purchase_demand_service.py
T
cjw 0e6b3b1811 后端设计治理:批次 0-4 全部完成(安全/部署/一致性/结构/架构)
按 ROADMAP §3.1 治理批次推进的后端设计审查整改:

- 批次 0(安全):/api/status/{task_id} 补 JWT 鉴权与任务归属校验;
  pythonocc_available 真实探测;bcrypt 超 72 字节显式拒绝;
  SECRET_KEY/RUSTFS_* 惰性校验,代码侧弱默认移除
- 批次 1(部署正确性):主处理链路改走 RustFS(分派入参 stp_file_id 化,
  worker 按 object_key 下载);AUTO_MIGRATE 开关 + 迁移目录 alembic/→migrations/
  修复包遮蔽(自动迁移此前从未真正生效);OCC 镜像改 conda 原生执行 +
  基础镜像 tag 锁定;compose 关键项改 ${VAR:?} 强制显式配置
- 批次 2(任务一致性):删除 Redis 进程内存回退,PG 为任务状态单一事实源;
  批量元数据入库(processing_tasks.batch_id,迁移 a3f8c2d91e47);
  型腔失败任务标 failed 不再静默 completed;事务边界收口
  (数据本体写 flush-only、失败先回滚再置 failed、进度更新保留即时 commit)
- 批次 3(API 与代码结构):592 行 advanced_router 拆为 design/cost/machining/
  export 四子路由,请求体全量 Pydantic 化;ROUTE_MODULES + route_registry
  (/api/health 呈现 degraded,DEBUG fail fast);纯计算端点统一 to_thread;
  StorageIntegrationService 按职责三拆;MAX_FILE_SIZE 接线生效、
  celery 复用 Settings.redis_url;管理员重置密码改 JSON body(端到端断裂修复);
  openapi.json 重导出(76 paths)+ 前端 gen:api
- 批次 4(架构演进):共享 ORM 按模块拆分(shared/models/base.py + identity.py、
  moldinsight/models/、inventory/models/,删除三条无使用方的跨模块
  relationship,跨模块桥接收敛为裸 FK 硬规则,无兼容 facade);
  OCC executor 重建补 cancel_futures=True(消除旧队列被慢恢复线程
  并行消化的数据竞争);OCC 吞吐方案设计先行
  (docs/topics/performance/OCC_THROUGHPUT.md);顺手清偿 D15
  (vite.config.ts 未用参数致 npm run build 失败)

测试基线:125 passed, 2 skipped(pytest + sqlite+aiosqlite;归属边界、
路由契约、配置治理、鉴权回归等随批新增)
文档同步:STATUS / TECH_DEBT / ROADMAP / ARCHITECTURE / API_CONTRACT /
OPERATIONS / AGENTS

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:15:49 +08:00

266 lines
11 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.
"""采购需求自动推导服务
销售订单确认 → 按 BOM 展开物料需求 → 对比当前库存 → 自动生成采购建议(缺多少、建议供应商、预计金额)
"""
from math import ceil
from decimal import Decimal
from typing import List
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.models.identity import User
from inventory.models import Product, ProductMaterial, SalesOrder, SalesOrderItem, Inventory, MaterialSupplier, Supplier, PurchaseOrder, PurchaseOrderItem
from ..utils import generate_order_no
from ..schemas.purchase_demand_schemas import (
PurchaseDemandItemResponse,
PurchaseDemandResponse,
PurchaseDemandConvertRequest,
PurchaseDemandConvertResponse,
PurchaseOrderCreatedResponse,
)
class PurchaseDemandService:
"""采购需求推导服务"""
@staticmethod
async def calculate_demands(
db_session: AsyncSession,
sales_order_ids: List[int],
) -> PurchaseDemandResponse:
"""
核心算法:
1. 批量查询销售订单 + 明细项
2. 按 BOM 展开所有成品所需的物料(含损耗率)
3. 聚合跨订单的同一物料需求量
4. 对比当前库存,计算缺口
5. 查询 MaterialSupplier 推荐主供应商
"""
# ── 1. 查询销售订单 ──
order_result = await db_session.execute(
select(SalesOrder).where(SalesOrder.id.in_(sales_order_ids))
)
orders = order_result.scalars().all()
if not orders:
raise HTTPException(status_code=404, detail="未找到有效的销售订单")
order_ids_found = [o.id for o in orders]
order_nos = [o.order_no for o in orders]
# ── 2. 查询订单明细(成品列表) ──
item_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id.in_(order_ids_found))
)
order_items = item_result.scalars().all()
if not order_items:
return PurchaseDemandResponse(
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
# ── 3. 按 BOM 展开物料需求 ──
finished_ids = list({int(i.product_id) for i in order_items})
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(finished_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_rows = bom_result.all()
if not bom_rows:
return PurchaseDemandResponse(
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
# 按 finished_product_id 分组 BOM
bom_by_finished: dict = {}
for bom, material in bom_rows:
bom_by_finished.setdefault(int(bom.finished_product_id), []).append((bom, material))
# 聚合需求量:material_id → { material, required_qty }
required_qty_map: dict = {}
for order_item in order_items:
bom_items = bom_by_finished.get(int(order_item.product_id)) or []
for bom, material in bom_items:
qty = (
Decimal(str(order_item.quantity))
* Decimal(str(bom.quantity or 0))
* (1 + Decimal(str(bom.loss_rate or 0)))
)
entry = required_qty_map.setdefault(
material.id,
{"material": material, "required_qty": Decimal("0")},
)
entry["required_qty"] += qty
if not required_qty_map:
return PurchaseDemandResponse(
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
# ── 4. 对比当前库存 ──
material_ids = list(required_qty_map.keys())
stock_result = await db_session.execute(
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
.where(Inventory.product_id.in_(material_ids))
.group_by(Inventory.product_id)
)
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
# ── 5. 查询物料-供应商关联(推荐主供应商) ──
ms_result = await db_session.execute(
select(MaterialSupplier, Supplier)
.join(Supplier, MaterialSupplier.supplier_id == Supplier.id)
.where(MaterialSupplier.product_id.in_(material_ids))
.where(Supplier.is_active == True)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
ms_rows = ms_result.all()
# 每个物料取第一个(优先 is_primary=True)
supplier_map: dict = {}
for ms, supplier in ms_rows:
if ms.product_id not in supplier_map:
supplier_map[ms.product_id] = {
"supplier_id": supplier.id,
"supplier_name": supplier.name,
"lead_time": ms.lead_time,
}
# ── 6. 组装响应 ──
items: List[PurchaseDemandItemResponse] = []
total_estimated_cost = Decimal("0")
shortage_count = 0
for material_id, entry in required_qty_map.items():
material = entry["material"]
required_qty = int(ceil(entry["required_qty"]))
available_qty = stock_map.get(material_id, Decimal("0"))
shortage_qty = max(required_qty - int(available_qty), 0)
unit_cost = Decimal(str(material.cost_price or 0))
estimated_cost = Decimal(str(shortage_qty)) * unit_cost
total_estimated_cost += estimated_cost
if shortage_qty > 0:
shortage_count += 1
suggested = supplier_map.get(material_id)
items.append(
PurchaseDemandItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
required_quantity=Decimal(str(required_qty)),
available_quantity=available_qty,
shortage_quantity=Decimal(str(shortage_qty)),
unit_cost=unit_cost,
estimated_cost=estimated_cost,
suggested_supplier_id=suggested["supplier_id"] if suggested else None,
suggested_supplier_name=suggested["supplier_name"] if suggested else None,
supplier_lead_time=suggested["lead_time"] if suggested else None,
)
)
# 按缺口数量降序排列(最缺的排最前)
items.sort(key=lambda x: (x.shortage_quantity, x.estimated_cost), reverse=True)
return PurchaseDemandResponse(
items=items,
total_estimated_cost=total_estimated_cost,
shortage_count=shortage_count,
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
@staticmethod
async def convert_to_purchase_orders(
db_session: AsyncSession,
payload: PurchaseDemandConvertRequest,
current_user: User,
) -> PurchaseDemandConvertResponse:
"""将采购需求按供应商分组,每组生成一张草稿采购单(status=pending)。
只转换 quantity>0 的条目;物料/供应商需存在且启用。
事务由 get_db_session 统一提交(路由返回成功即 commit,异常即 rollback)。
"""
valid_items = [it for it in payload.items if it.quantity > 0]
skipped = len(payload.items) - len(valid_items)
if not valid_items:
raise HTTPException(status_code=400, detail="没有可转换的有效需求(数量需 > 0)")
material_ids = {it.material_id for it in valid_items}
supplier_ids = {it.supplier_id for it in valid_items}
# 校验物料
mat_result = await db_session.execute(
select(Product)
.where(Product.id.in_(material_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
materials = {m.id: m for m in mat_result.scalars().all()}
if len(materials) != len(material_ids):
raise HTTPException(status_code=400, detail="部分物料不存在或非物料类型")
# 校验供应商
sup_result = await db_session.execute(
select(Supplier)
.where(Supplier.id.in_(supplier_ids))
.where(Supplier.is_active == True)
)
suppliers = {s.id: s for s in sup_result.scalars().all()}
if len(suppliers) != len(supplier_ids):
raise HTTPException(status_code=400, detail="部分供应商不存在或已停用")
# 按供应商分组生成采购单
groups: dict = {}
for it in valid_items:
groups.setdefault(it.supplier_id, []).append(it)
created: List[PurchaseOrderCreatedResponse] = []
for supplier_id, items in groups.items():
po = PurchaseOrder(
order_no=generate_order_no("PO"),
supplier_id=supplier_id,
expected_date=payload.expected_date,
remark=payload.remark or "由采购需求一键生成",
operator_id=current_user.id,
status="pending",
)
db_session.add(po)
await db_session.flush()
total = Decimal("0")
for it in items:
amount = Decimal(str(it.quantity)) * Decimal(str(it.unit_cost))
total += amount
db_session.add(
PurchaseOrderItem(
order_id=po.id,
product_id=it.material_id,
quantity=int(it.quantity),
unit_price=Decimal(str(it.unit_cost)),
amount=amount,
)
)
po.total_amount = total
created.append(
PurchaseOrderCreatedResponse(
purchase_order_id=po.id,
order_no=po.order_no,
supplier_id=supplier_id,
supplier_name=suppliers[supplier_id].name,
item_count=len(items),
total_amount=total,
)
)
await db_session.flush()
return PurchaseDemandConvertResponse(created_orders=created, skipped=skipped)
purchase_demand_service = PurchaseDemandService()