0e6b3b1811
按 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>
447 lines
18 KiB
Python
447 lines
18 KiB
Python
"""采购订单业务服务层
|
|
|
|
将原 purchase_order_routes 中的业务编排(建单/改单/删单、收货入库、状态流转)下沉到此,
|
|
路由层只做参数校验与响应组装。
|
|
"""
|
|
from typing import Optional, Tuple
|
|
from decimal import Decimal
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, update
|
|
|
|
from shared.models.identity import User
|
|
from inventory.models import Supplier, Product, Warehouse, Inventory, StockMovement, PurchaseOrder, PurchaseOrderItem
|
|
from ..schemas import (
|
|
PurchaseOrderCreate,
|
|
PurchaseOrderResponse,
|
|
PurchaseOrderDetailResponse,
|
|
PurchaseOrderItemResponse,
|
|
PurchaseOrderReceiveRequest,
|
|
PaginatedResponse,
|
|
)
|
|
from ..utils import generate_order_no
|
|
|
|
|
|
def _compute_receipt_status(order: PurchaseOrder) -> str:
|
|
"""收货状态:已下单 / 部分收货 / 已收货 / 已作废"""
|
|
if order.status == "cancelled":
|
|
return "cancelled"
|
|
if order.status in ("received", "paid"):
|
|
return "received"
|
|
if order.status == "partial_received":
|
|
return "partial_received"
|
|
return "pending"
|
|
|
|
|
|
def _compute_payment_status(order: PurchaseOrder) -> str:
|
|
"""付款状态:未付款 / 已付款"""
|
|
if order.status == "paid" or order.paid_date:
|
|
return "paid"
|
|
return "unpaid"
|
|
|
|
|
|
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
|
|
return PurchaseOrderResponse(
|
|
id=order.id,
|
|
order_no=order.order_no,
|
|
supplier_name=supplier_name,
|
|
order_date=order.order_date,
|
|
expected_date=order.expected_date,
|
|
status=order.status,
|
|
receipt_status=_compute_receipt_status(order),
|
|
payment_status=_compute_payment_status(order),
|
|
total_amount=order.total_amount,
|
|
paid_amount=order.paid_amount,
|
|
remark=order.remark,
|
|
created_at=order.created_at,
|
|
received_date=order.received_date,
|
|
paid_date=order.paid_date
|
|
)
|
|
|
|
|
|
async def _get_order_with_supplier(
|
|
db_session: AsyncSession,
|
|
order_id: int,
|
|
) -> Tuple[PurchaseOrder, Supplier]:
|
|
result = await db_session.execute(
|
|
select(PurchaseOrder, Supplier)
|
|
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
|
|
.where(PurchaseOrder.id == order_id)
|
|
)
|
|
row = result.first()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="采购订单不存在")
|
|
return row[0], row[1]
|
|
|
|
|
|
async def _build_purchase_order_detail(
|
|
db_session: AsyncSession,
|
|
order: PurchaseOrder,
|
|
supplier_name: str,
|
|
) -> PurchaseOrderDetailResponse:
|
|
item_result = await db_session.execute(
|
|
select(PurchaseOrderItem, Product)
|
|
.join(Product, PurchaseOrderItem.product_id == Product.id)
|
|
.where(PurchaseOrderItem.order_id == order.id)
|
|
.order_by(PurchaseOrderItem.id.asc())
|
|
)
|
|
item_rows = item_result.all()
|
|
|
|
return PurchaseOrderDetailResponse(
|
|
id=order.id,
|
|
order_no=order.order_no,
|
|
supplier_id=order.supplier_id,
|
|
supplier_name=supplier_name,
|
|
order_date=order.order_date,
|
|
expected_date=order.expected_date,
|
|
status=order.status,
|
|
receipt_status=_compute_receipt_status(order),
|
|
payment_status=_compute_payment_status(order),
|
|
total_amount=order.total_amount,
|
|
paid_amount=order.paid_amount,
|
|
remark=order.remark,
|
|
created_at=order.created_at,
|
|
received_date=order.received_date,
|
|
paid_date=order.paid_date,
|
|
items=[
|
|
PurchaseOrderItemResponse(
|
|
id=item.id,
|
|
product_id=item.product_id,
|
|
product_sku=product.sku,
|
|
product_name=product.name,
|
|
quantity=item.quantity,
|
|
received_quantity=item.received_quantity,
|
|
unit_price=item.unit_price,
|
|
amount=item.amount,
|
|
remark=item.remark
|
|
) for item, product in item_rows
|
|
]
|
|
)
|
|
|
|
|
|
async def _resolve_receive_warehouse(
|
|
db_session: AsyncSession,
|
|
warehouse_id: Optional[int],
|
|
) -> Warehouse:
|
|
if warehouse_id:
|
|
result = await db_session.execute(
|
|
select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True)
|
|
)
|
|
warehouse = result.scalar_one_or_none()
|
|
if not warehouse:
|
|
raise HTTPException(status_code=404, detail="仓库不存在")
|
|
return warehouse
|
|
|
|
result = await db_session.execute(
|
|
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
|
|
)
|
|
warehouse = result.scalars().first()
|
|
if not warehouse:
|
|
raise HTTPException(status_code=400, detail="未配置可用仓库")
|
|
return warehouse
|
|
|
|
|
|
async def _apply_order_items(
|
|
db_session: AsyncSession,
|
|
order: PurchaseOrder,
|
|
order_data: PurchaseOrderCreate,
|
|
) -> Decimal:
|
|
total_amount = Decimal("0")
|
|
for item_data in order_data.items:
|
|
product_result = await db_session.execute(
|
|
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
|
|
)
|
|
product = product_result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
|
|
if product.item_type != "material":
|
|
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
|
|
|
|
# 优先使用前端传入的单价,否则使用物料成本价
|
|
if item_data.unit_price and item_data.unit_price > 0:
|
|
unit_price = float(item_data.unit_price)
|
|
else:
|
|
unit_price = product.cost_price or 0
|
|
if unit_price <= 0:
|
|
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置价格,请在物料管理界面设置成本价或在采购明细中填写单价")
|
|
|
|
try:
|
|
item = PurchaseOrderItem(
|
|
order_id=order.id,
|
|
product_id=item_data.product_id,
|
|
quantity=int(item_data.quantity),
|
|
unit_price=Decimal(str(unit_price)),
|
|
amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)),
|
|
remark=item_data.remark or None
|
|
)
|
|
db_session.add(item)
|
|
total_amount += item.amount
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
|
|
return total_amount
|
|
|
|
|
|
class PurchaseOrderService:
|
|
"""采购订单业务服务"""
|
|
|
|
@staticmethod
|
|
async def list_orders(
|
|
db_session: AsyncSession,
|
|
status: Optional[str],
|
|
skip: int,
|
|
limit: int,
|
|
) -> PaginatedResponse:
|
|
base_query = (
|
|
select(PurchaseOrder, Supplier)
|
|
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
|
|
.order_by(PurchaseOrder.created_at.desc())
|
|
)
|
|
|
|
if status:
|
|
base_query = base_query.where(PurchaseOrder.status == status)
|
|
|
|
count_query = select(func.count()).select_from(base_query.subquery())
|
|
total = await db_session.scalar(count_query) or 0
|
|
|
|
query = base_query.offset(skip).limit(limit)
|
|
result = await db_session.execute(query)
|
|
|
|
orders = []
|
|
for order, supplier in result.all():
|
|
orders.append(_build_purchase_order_response(order, supplier.name))
|
|
|
|
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
|
|
|
|
@staticmethod
|
|
async def create_order(
|
|
db_session: AsyncSession,
|
|
order_data: PurchaseOrderCreate,
|
|
current_user: User,
|
|
) -> PurchaseOrderResponse:
|
|
order = PurchaseOrder(
|
|
order_no=generate_order_no("PO"),
|
|
supplier_id=order_data.supplier_id,
|
|
expected_date=order_data.expected_date,
|
|
remark=order_data.remark,
|
|
operator_id=current_user.id,
|
|
status="pending"
|
|
)
|
|
db_session.add(order)
|
|
await db_session.flush()
|
|
|
|
try:
|
|
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
|
await db_session.commit()
|
|
except (HTTPException, Exception):
|
|
await db_session.rollback()
|
|
raise
|
|
await db_session.refresh(order)
|
|
|
|
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
|
supplier = supplier_result.scalar_one_or_none()
|
|
if not supplier:
|
|
raise HTTPException(status_code=404, detail="供应商不存在")
|
|
|
|
return _build_purchase_order_response(order, supplier.name)
|
|
|
|
@staticmethod
|
|
async def get_detail(db_session: AsyncSession, order_id: int) -> PurchaseOrderDetailResponse:
|
|
order, supplier = await _get_order_with_supplier(db_session, order_id)
|
|
return await _build_purchase_order_detail(db_session, order, supplier.name)
|
|
|
|
@staticmethod
|
|
async def update_order(
|
|
db_session: AsyncSession,
|
|
order_id: int,
|
|
order_data: PurchaseOrderCreate,
|
|
current_user: User,
|
|
) -> PurchaseOrderResponse:
|
|
order, _ = await _get_order_with_supplier(db_session, order_id)
|
|
if order.paid_amount and order.paid_amount > 0:
|
|
raise HTTPException(status_code=400, detail="已付款采购单不允许修改")
|
|
|
|
item_result = await db_session.execute(
|
|
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
|
)
|
|
existing_items = item_result.scalars().all()
|
|
if any((item.received_quantity or 0) > 0 for item in existing_items):
|
|
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改")
|
|
|
|
for item in existing_items:
|
|
await db_session.delete(item)
|
|
|
|
try:
|
|
order.supplier_id = order_data.supplier_id
|
|
order.expected_date = order_data.expected_date
|
|
order.remark = order_data.remark
|
|
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
|
order.status = "pending"
|
|
|
|
await db_session.commit()
|
|
except (HTTPException, Exception):
|
|
await db_session.rollback()
|
|
raise
|
|
await db_session.refresh(order)
|
|
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
|
supplier = supplier_result.scalar_one_or_none()
|
|
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
|
|
|
|
@staticmethod
|
|
async def delete_order(db_session: AsyncSession, order_id: int, current_user: User) -> dict:
|
|
order, _ = await _get_order_with_supplier(db_session, order_id)
|
|
if order.paid_amount and order.paid_amount > 0:
|
|
raise HTTPException(status_code=400, detail="已付款采购单不允许删除")
|
|
item_result = await db_session.execute(
|
|
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
|
)
|
|
if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()):
|
|
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除")
|
|
await db_session.delete(order)
|
|
await db_session.commit()
|
|
return {"message": "采购订单已删除"}
|
|
|
|
@staticmethod
|
|
async def update_status(
|
|
db_session: AsyncSession,
|
|
order_id: int,
|
|
status: dict,
|
|
current_user: User,
|
|
) -> PurchaseOrderResponse:
|
|
order, _ = await _get_order_with_supplier(db_session, order_id)
|
|
|
|
new_status = status.get("status")
|
|
if not new_status:
|
|
raise HTTPException(status_code=400, detail="状态不能为空")
|
|
|
|
valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"]
|
|
if new_status not in valid_statuses:
|
|
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
|
|
|
|
# 收货状态(partial_received/received)只能由收货端点驱动,禁止手动设置,避免与实际入库脱钩
|
|
if new_status in ("partial_received", "received"):
|
|
raise HTTPException(status_code=400, detail="收货状态只能通过收货入库操作自动变更,不能手动设置")
|
|
|
|
# 状态转换逻辑
|
|
if order.status == "paid":
|
|
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
|
|
if order.status == "cancelled":
|
|
raise HTTPException(status_code=400, detail="已作废的采购订单禁止修改状态")
|
|
if order.status == "received" and new_status not in ("paid", "cancelled"):
|
|
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款或已作废")
|
|
if order.status == "partial_received" and new_status not in ("received", "paid", "cancelled"):
|
|
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货、已付款或已作废")
|
|
if new_status == "cancelled" and order.status == "paid":
|
|
raise HTTPException(status_code=400, detail="已付款的订单不能作废")
|
|
|
|
# 更新状态和对应时间
|
|
order.status = new_status
|
|
if new_status == "received":
|
|
order.received_date = func.now()
|
|
elif new_status == "paid":
|
|
order.paid_date = func.now()
|
|
|
|
await db_session.commit()
|
|
await db_session.refresh(order)
|
|
|
|
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
|
supplier = supplier_result.scalar_one_or_none()
|
|
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
|
|
|
|
@staticmethod
|
|
async def receive_order(
|
|
db_session: AsyncSession,
|
|
order_id: int,
|
|
payload: PurchaseOrderReceiveRequest,
|
|
current_user: User,
|
|
) -> PurchaseOrderDetailResponse:
|
|
order, supplier = await _get_order_with_supplier(db_session, order_id)
|
|
if order.status in ("cancelled", "paid"):
|
|
raise HTTPException(status_code=400, detail=f"当前采购单状态为 {order.status},不允许收货")
|
|
warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id)
|
|
|
|
item_result = await db_session.execute(
|
|
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
|
)
|
|
item_map = {item.id: item for item in item_result.scalars().all()}
|
|
if not item_map:
|
|
raise HTTPException(status_code=400, detail="采购单无明细,无法入库")
|
|
|
|
if not payload.items:
|
|
raise HTTPException(status_code=400, detail="请提供本次入库明细")
|
|
|
|
for receive_item in payload.items:
|
|
item = item_map.get(receive_item.item_id)
|
|
if not item:
|
|
raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}")
|
|
if receive_item.receive_quantity <= 0:
|
|
raise HTTPException(status_code=400, detail="入库数量必须大于0")
|
|
remaining_qty = (item.quantity or 0) - (item.received_quantity or 0)
|
|
if receive_item.receive_quantity > remaining_qty:
|
|
raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}")
|
|
|
|
for receive_item in payload.items:
|
|
item = item_map[receive_item.item_id]
|
|
product_result = await db_session.execute(
|
|
select(Product).where(Product.id == item.product_id)
|
|
)
|
|
product = product_result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}")
|
|
|
|
upd_result = await db_session.execute(
|
|
update(Inventory)
|
|
.where(Inventory.product_id == item.product_id)
|
|
.where(Inventory.warehouse_id == warehouse.id)
|
|
.values(quantity=Inventory.quantity + receive_item.receive_quantity)
|
|
.returning(Inventory.quantity)
|
|
)
|
|
after_qty = upd_result.scalar_one_or_none()
|
|
if after_qty is None:
|
|
inventory = Inventory(
|
|
product_id=item.product_id,
|
|
warehouse_id=warehouse.id,
|
|
quantity=receive_item.receive_quantity,
|
|
locked_quantity=0
|
|
)
|
|
db_session.add(inventory)
|
|
await db_session.flush()
|
|
before_qty = 0
|
|
after_qty = receive_item.receive_quantity
|
|
else:
|
|
after_qty = int(after_qty)
|
|
before_qty = after_qty - receive_item.receive_quantity
|
|
item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity
|
|
|
|
movement = StockMovement(
|
|
product_id=item.product_id,
|
|
warehouse_id=warehouse.id,
|
|
movement_type="purchase_in",
|
|
quantity=receive_item.receive_quantity,
|
|
before_quantity=before_qty,
|
|
after_quantity=after_qty,
|
|
reference_type="purchase_order",
|
|
reference_id=order.id,
|
|
reference_no=order.order_no,
|
|
unit_price=item.unit_price,
|
|
total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)),
|
|
remark=payload.remark or f"采购单{order.order_no}到货入库",
|
|
operator_id=current_user.id
|
|
)
|
|
db_session.add(movement)
|
|
|
|
all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values())
|
|
any_received = any((item.received_quantity or 0) > 0 for item in item_map.values())
|
|
if all_received:
|
|
order.status = "received"
|
|
order.received_date = func.now()
|
|
elif any_received:
|
|
order.status = "partial_received"
|
|
|
|
await db_session.commit()
|
|
await db_session.refresh(order)
|
|
return await _build_purchase_order_detail(db_session, order, supplier.name)
|
|
|
|
|
|
purchase_order_service = PurchaseOrderService()
|