批次5:inventory服务下沉收口 + Pydantic v2 / datetime弃用清零

- inventory业务层下沉(薄路由+service orchestration模式):
  - customer/supplier/warehouse -> master_data_service
  - material_routes(价格历史/趋势/供应商关联)-> material_service
  - product_routes(CRUD/BOM/from-task跨模块桥接)-> product_service
  - dashboard_routes(首页统计/低库存预警)-> dashboard_service
- inventory侧新增service回归覆盖(dashboard 2 / master_data 10 /
  material 10 / product 14),含跨模块桥接测试种子
- Pydantic v2弃用清零:全仓14处 class Config 全部迁移到
  model_config = ConfigDict(from_attributes=True)(含 shared auth)
- datetime.utcnow() 弃用清零:auth_service 3处统一改 datetime.now(timezone.utc)
- 同步文档:STATUS / ROADMAP / TECH_DEBT(D12清偿)/ AGENTS 代码地图

测试基线:126 passed, 4 skipped(无deprecation warning)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-22 16:13:45 +08:00
parent c51e6b793a
commit 64dc85bd14
32 changed files with 1495 additions and 690 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ src/
inventory/ # 【进销存模块】
api/ # 每域一个 routes 文件:product / supplier / customer / warehouse / inventory / stock_movement / purchase_order / sales_order / purchase_demand / finance / dashboard / material
schemas/ # 每域一个 Pydantic schema 文件(与 api 一一对应)
services/ # 领域服务:inventory / purchase_order / sales_order / finance / purchase_demand / stock_movement
services/ # 领域服务:inventory / master_data / material / product / purchase_order / sales_order / finance / purchase_demand / stock_movement / dashboard
models/ # inventory 域 ORM(catalog / warehouse / trading / finance 四文件,共 15 表)
utils.py
shared/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】
+4
View File
@@ -62,6 +62,10 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
- 业务 service 复用强化
- 数据模型归属进一步清晰化
- 前后端契约持续减少手写漂移
- 已完成第一批主数据收口(2026-09-21):`customer / supplier / warehouse` 路由改为薄路由,CRUD 编排下沉至 `master_data_service`
- 已完成物料域第二批收口(2026-09-21):`material_routes` 的价格历史、价格趋势、供应商关联查询/删除编排下沉至 `material_service`
- 已完成产品域第三批收口(2026-09-21):`product_routes` 的常规 CRUD、BOM 与跨模块 `from-task` 编排均已下沉至 `product_service`
- 已完成 dashboard 聚合收口(2026-09-21):`dashboard_routes` 的首页统计/低库存预警编排下沉至 `dashboard_service`
### 2.4 主线四:部署与运维一致性
+11 -1
View File
@@ -3,7 +3,17 @@
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
> 2026-09-18(**D3 剩余收敛:app_factory 平台层去模块化 + 路由单点聚合**:① 平台工厂 [app_factory.py](../src/shared/app_factory.py) 只做纯平台引导——`connect_rustfs` 参数移除,模块专属启动接线改经 `startup_hooks` 注入;RustFS 连接收敛为 [init_storage.py](../src/moldinsight/storage/init_storage.py) 的 `rustfs_startup_hook`(moldinsight/unified 入口注入)。② 路由挂载收敛——[moldinsight/api/__init__.py](../src/moldinsight/api/__init__.py) 新增 `register_moldinsight_routers` 单点聚合(/api 聚合路由 + HTML 报告根路径挂载),moldinsight/unified 入口不再各自重复 include html_report,退化为纯组装。③ 边界语义入文档:ARCHITECTURE §6.2/§2 说明更新(shared = 平台层,模块专属接线归属模块层);TECH_DEBT D3 剩余仅 identity/platform 注释口径。**接口面零变化**(openapi 无漂移已验)。**测试基线**:**144 passed, 0 skipped**(新增 [test_html_report_router.py](../tests/test_html_report_router.py) 单点聚合注册 1 项)。)
> 2026-09-22(**Pydantic v2 schema 配置升级 + `datetime.utcnow()` 弃用清零**:① 全仓 14 处 `class Config`([src/inventory/schemas](../src/inventory/schemas/))+ [src/shared/services/auth_routes.py](../src/shared/services/auth_routes.py) 三处全部迁移到 `model_config = ConfigDict(from_attributes=True)`;② [src/shared/services/auth_service.py](../src/shared/services/auth_service.py) 中 `datetime.utcnow()` 改用 `datetime.now(timezone.utc)`,消除遗留 `DeprecationWarning`;③ 一次跑通 `pytest tests/ -q` 全量无 deprecation 警告,全仓 `from_attributes=True` 语义保持不变,未触发 OpenAPI 漂移。**测试基线**:**126 passed, 4 skipped**(与上一批次一致,无回归)。)
>
> 2026-09-21(**inventory 仪表盘聚合服务下沉完成:dashboard 薄路由化**:① 新增 [dashboard_service.py](../src/inventory/services/dashboard_service.py),将仪表盘首页所需的基础主数据统计、物料库存总量/总值、待处理采购/销售单数、低库存预警列表等聚合查询从路由层下沉到 service;② [dashboard_routes.py](../src/inventory/api/dashboard_routes.py) 改为单行委托薄路由,inventory 主要业务域路由已基本完成 service orchestration 收口;③ 新增 [test_api_dashboard_service.py](../tests/test_api_dashboard_service.py),覆盖 seeded summary 与低库存预警两条 API 回归。**接口面零变化**(无 openapi 漂移)。**测试基线**:**126 passed, 4 skipped**;新增 dashboard 回归 **2 passed**。)
>
> 2026-09-21(**inventory 产品域跨模块桥接收口完成:`/api/products/from-task/{task_id}` 下沉至 `product_service`**:① [product_service.py](../src/inventory/services/product_service.py) 新增 `create_product_from_task`,将 ProcessingTask / STPFile 查询、已绑定成品幂等返回、`MI{stp_file_id}` SKU 冲突递增、分析结果摘要拼装、成品创建与 `stp_files.product_id` 回写从路由层下沉到 service;② [product_routes.py](../src/inventory/api/product_routes.py) 现已全量薄路由化,产品域 CRUD / BOM / from-task 三类接口统一改为 service orchestration;③ 扩展 [test_api_product_service.py](../tests/test_api_product_service.py) 与 [tests/conftest.py](../tests/conftest.py),补 `STPFile` / `ProcessingTask` 种子及 from-task 创建、重复调用幂等、任务不存在 404 回归。**接口面零变化**(无 openapi 漂移)。**测试基线**:**124 passed, 4 skipped**;product 域回归现为 **14 passed**。)
>
> 2026-09-21(**inventory 产品域第二批服务下沉完成:product CRUD / BOM 薄路由化,`from-task` 保持独立**:① 新增 [product_service.py](../src/inventory/services/product_service.py),将产品列表、创建、更新、软删除、BOM 查询与 BOM 替换编排从 [product_routes.py](../src/inventory/api/product_routes.py) 下沉到 service 层;② `product_routes` 中除跨模块的 `/api/products/from-task/{task_id}` 仍保留在路由层外,其余端点已改为薄路由委托,inventory 侧形成 `master_data / material / product / purchase_order / sales_order` 一致的 service orchestration 结构;③ 新增 [test_api_product_service.py](../tests/test_api_product_service.py) 覆盖成品物料成本聚合、创建成品库存上下限归零、重复 SKU 校验、软删除、BOM 明细/重建及多条语义校验路径。**接口面零变化**(无 openapi 漂移)。**测试基线**:**122 passed, 4 skipped**;新增 product 回归 **12 passed**。下一刀再处理 `from-task` 与更深的 moldinsight/BOM 交叉编排。)
>
> 2026-09-21(**inventory 物料域服务下沉完成:price-history / price-trend / material-supplier 薄路由化**:① 新增 [material_service.py](../src/inventory/services/material_service.py),将物料价格历史、价格趋势、物料-供应商关联与按供应商反查物料的业务编排从路由层下沉到 service 层;② [material_routes.py](../src/inventory/api/material_routes.py) 改为薄路由,仅保留依赖注入、参数校验与 service 调用,inventory 侧继续延续 `inventory_service` / `master_data_service` / `purchase_order_service` / `sales_order_service` 的结构收口方向;③ 新增 [test_api_material_service.py](../tests/test_api_material_service.py) 覆盖价格历史新增、趋势汇总、缺历史 404、供应商关联查询/删除、重复关联与非法物料/供应商校验等回归;④ 顺手修复该链路的两个既有结构问题:`PriceHistoryItem` 未从 [inventory.schemas](../src/inventory/schemas/__init__.py) 导出导致 service 导入失败;异步 ORM 读路径原本依赖 `ph.supplier` / `ms.supplier` / `ms.product` 懒加载,测试环境下触发 `MissingGreenlet`,现统一改为显式 join 构造响应。**接口面零变化**(无 openapi 漂移)。**测试基线**:**110 passed, 4 skipped**;新增物料域回归 **10 passed**。)
>
> 2026-09-21(**inventory 主数据第一批服务下沉完成:customer / supplier / warehouse 薄路由化**:① 新增 [master_data_service.py](../src/inventory/services/master_data_service.py),将客户/供应商/仓库的列表查询、自动编码(`C`/`S`/`W`)、更新、软删除等 CRUD 编排从路由层下沉到 service 层;② [customer_routes.py](../src/inventory/api/customer_routes.py)、[supplier_routes.py](../src/inventory/api/supplier_routes.py)、[warehouse_routes.py](../src/inventory/api/warehouse_routes.py) 改为薄路由,仅保留依赖注入、参数校验与 service 调用,inventory 侧延续既有 `inventory_service` / `purchase_order_service` / `sales_order_service` 的结构收口方向;③ 新增 [test_api_inventory_master_data.py](../tests/test_api_inventory_master_data.py) 覆盖 customer/supplier/warehouse 的搜索、自动编码、更新回包、软删除与默认仓排序回归;④ 顺手修复 inventory 主数据链路两个既有问题:此前 create/update/delete 只 `flush` 不 `commit`,跨请求 session 下后续读写看不到刚创建实体;时间戳到秒的自动编码在同秒连续创建时会撞唯一约束,现改为微秒粒度编码。**接口面零变化**(无 openapi 漂移)。**测试基线**:**100 passed, 4 skipped**;新增主数据回归 **10 passed**。)
> 2026-09-18(**批次 4 后续专项五项完成:D11 清偿 + 部署参数 + D2 诚实标注 + CI 门禁 + OCC 方案 B 实施**:① **D11 清偿**(TECH_DEBT P2)——可视化报告 RustFS 单源化:写侧 HTMLGenerator 每任务写临时目录,`.html`/`_summary.json`/`_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`(文件名寻址),`/html` StaticFiles 本地挂载删除,新增 [html_report_router.py](../src/moldinsight/api/html_report_router.py) 根路径代理(报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404;URL 形状 `/html/{filename}` 不变,持久化 cavity JSON 与前端 iframe 引用零迁移);celery 服务摘除 `html_data` 卷,Dockerfile.moldinsight 删除 `COPY html_output/`(构建机陈旧报告不再进镜像);已知约束:报告路由不做认证(iframe 无法携带 Authorization 头,沿用 StaticFiles 时代既定姿态,文档已声明);顺带删除 `get_stp_file_with_data` 的死数据块(`html_content` 组装零消费方,此前每次完成任务查询白下载数 MB 正文)。② **OCC 方案 A 参数落地**——`CELERY_CONCURRENCY`/`CELERY_MAX_TASKS_PER_CHILD` 进 [Dockerfile.celery](../deploy/Dockerfile.celery) ENV + compose 透传 + `.env.example`。③ **D2 清偿**——铝价响应带 `source: "simulated"`,[HomeView.vue](../frontend/src/modules/home/HomeView.vue) 按来源渲染"模拟数据 · 参考走势"标注(原硬编码"上海期货交易所"属虚假声明),死代码 `getAluminumPrice` 删除。④ **CI 门禁**——[.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) 三 job:pytest 全量 / 前端构建(含 vue-tsc)/ openapi 漂移检测(conda pythonocc 环境重导出比对;已实测 pytest 与导出均不依赖 .env)。⑤ **OCC 方案 B 实施**(TECH_DEBT D10 清偿)——`run_occ(fn, *args)` → `run_occ(op_name, payload)`,执行器由线程池替换为常驻工作进程池 [occ_process_pool.py](../src/moldinsight/services/occ_process_pool.py) + 操作注册表 [occ_worker.py](../src/moldinsight/core/occ_worker.py):超时/崩溃 terminate 换新补位、任务级超时 recover 整体重建,**残留线程泄漏根治**(进程边界回收 C++ 栈);TopoDS 形状不跨进程(`generate_cavity` 分模 + 方案 STEP 持久化导出全在子进程内,返回 export_manifest);调用点全量迁移(解析/网格/型腔/分析/倒扣/STEP 转换),删除内存形状缓存链(`_cache_export_shapes`/`get_export_shapes`/`_persist_step_exports`)、`CADExporter.export_mold_results`(零调用方)、shape_loader(→ [stp_materializer.py](../src/moldinsight/services/stp_materializer.py));回归测试 [test_occ_process_pool.py](../tests/test_occ_process_pool.py)(OCC-gated,6 例含真实盒体 STP 解析/分模端到端)。**接口变更三件套随批完成**:openapi.json 重导出(76→77 paths,新增 `/html/{filename}`)+ 前端 `gen:api` 再生 + 前端构建通过(方案 B 接口面零变化,无路由/schema 变更)。**测试基线**:**143 passed, 0 skipped**(D11 8 项 + 铝价 2 项 + OCC 进程池 6 项;基线 129 中原 2 个 skip 已随本地环境补齐 celery/alembic 转为执行)。**下一步**:回到 §3 主线 P2/P3 长期方向——D3 剩余收敛(app_factory 参数收敛、identity/platform 语义)、inventory 服务下沉、D13 pip 锁文件随下次镜像构建补齐,见 [ROADMAP.md](ROADMAP.md) §3。)
+13 -1
View File
@@ -8,9 +8,10 @@
## 1. 当前技术债概览
当前最主要的技术债集中在两个区域:
当前最主要的技术债集中在三个区域:
- **moldinsight API 与处理链路的结构收口**
- **inventory 复杂业务域的 service 继续下沉**
- **文档 / 部署 / 历史语义与当前代码现状未完全一致**
已经完成的高优先级治理不再作为持续待办反复展开,当前重点聚焦在“还没完成、且值得继续推进”的部分。
@@ -211,6 +212,16 @@
1. 迁移目录 `alembic/` 与 alembic 包重名——应用内 `import alembic` 命中本地目录(namespace package)遮蔽真实包,启动期迁移异常被 `init_database` 吞掉只打日志;已改名 `migrations/`(alembic.ini `script_location` 与 4 处文档引用同步)
2. 镜像未打包迁移脚本与 alembic.ini,容器内迁移必然失败——Dockerfile.base / Dockerfile.moldinsight 已补 `COPY migrations/` + `COPY alembic.ini`
### D12. Pydantic v2 弃用项清理 —— 已清偿(2026-09-22)
修复内容(保留编号以维持引用稳定):
- 全仓 14 处 `class Config:` + `from_attributes = True` 统一迁移为 `model_config = ConfigDict(from_attributes=True)`:
- [src/inventory/schemas/customer_schemas.py](../src/inventory/schemas/customer_schemas.py) / [supplier_schemas.py](../src/inventory/schemas/supplier_schemas.py) / [warehouse_schemas.py](../src/inventory/schemas/warehouse_schemas.py) / [inventory_schemas.py](../src/inventory/schemas/inventory_schemas.py) / [stock_movement_schemas.py](../src/inventory/schemas/stock_movement_schemas.py) / [product_schemas.py](../src/inventory/schemas/product_schemas.py) / [material_schemas.py](../src/inventory/schemas/material_schemas.py) / [purchase_order_schemas.py](../src/inventory/schemas/purchase_order_schemas.py) / [sales_order_schemas.py](../src/inventory/schemas/sales_order_schemas.py) / [finance_schemas.py](../src/inventory/schemas/finance_schemas.py)
- [src/shared/services/auth_routes.py](../src/shared/services/auth_routes.py) UserResponse / RoleResponse / PermissionResponse
- 同步收掉 [src/shared/services/auth_service.py](../src/shared/services/auth_service.py) 中 `datetime.utcnow()` 的遗留 deprecation:3 处 token / last_login 写入改用 `datetime.now(timezone.utc)`,与 Pydantic 无关但同属“现代化弃用清理”范畴
- 语义保持:仅切换 Pydantic v2 配置语法 + UTC 时区语义,字段 / OpenAPI / JWT 行为零变化
- 验证:`pytest tests/ -q` **126 passed, 4 skipped**,deprecation warning 全部清零
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(主体已清偿,锁文件遗留)
现状:
@@ -250,6 +261,7 @@
4. 铝价模拟数据来源显式化
5. 部署历史文档归档
6. shared/platform 语义继续收敛(共享 ORM 归属已于批次 4 清偿,剩余为 app_factory 组合职责等,见 D3)
7. inventory 服务继续下沉(2026-09-21 已完成第一批主数据 CRUD 收口:customer / supplier / warehouse → `master_data_service`;剩余复杂域如 product / material / dashboard)
---
+6 -38
View File
@@ -9,17 +9,15 @@
路由前缀: /api/customers
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from inventory.models import Customer
from ..schemas import CustomerCreate, CustomerResponse
from ..services.master_data_service import master_data_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
@@ -32,12 +30,7 @@ async def list_customers(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Customer).where(Customer.is_active == True)
if search:
query = query.where(Customer.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
result = await db_session.execute(query)
return [CustomerResponse.from_orm(c) for c in result.scalars().all()]
return await master_data_service.list_customers(db_session, skip, limit, search)
@router.post("", response_model=CustomerResponse, status_code=201)
@@ -46,15 +39,7 @@ async def create_customer(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
data = customer_data.dict()
if not data.get("code"):
data["code"] = f"C{datetime.now().strftime('%Y%m%d%H%M%S')}"
customer = Customer(**data)
db_session.add(customer)
await db_session.flush()
await db_session.refresh(customer)
return CustomerResponse.from_orm(customer)
return await master_data_service.create_customer(db_session, customer_data, current_user)
@router.put("/{customer_id}", response_model=CustomerResponse)
@@ -64,17 +49,7 @@ async def update_customer(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
for key, value in customer_data.dict().items():
setattr(customer, key, value)
await db_session.flush()
await db_session.refresh(customer)
return CustomerResponse.from_orm(customer)
return await master_data_service.update_customer(db_session, customer_id, customer_data, current_user)
@router.delete("/{customer_id}")
@@ -83,11 +58,4 @@ async def delete_customer(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
customer.is_active = False
await db_session.flush()
return {"message": "客户已删除"}
return await master_data_service.delete_customer(db_session, customer_id, current_user)
+2 -54
View File
@@ -11,12 +11,11 @@
"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder
from ..services.dashboard_service import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
@@ -26,55 +25,4 @@ async def get_dashboard(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
material_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
) or 0
finished_product_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
) or 0
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True))
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True))
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True))
total_stock = await db_session.scalar(
select(func.sum(Inventory.quantity))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
total_value = await db_session.scalar(
select(func.sum(Inventory.quantity * Product.cost_price))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
pending_purchase = await db_session.scalar(
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
)
pending_sales = await db_session.scalar(
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
)
low_stock_products = await db_session.execute(
select(Product, Inventory)
.join(Inventory, Product.id == Inventory.product_id)
.where(Product.item_type == "material")
.where(Inventory.quantity <= Product.min_stock)
.limit(10)
)
low_stock = [
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
for p, i in low_stock_products.all()
]
return {
"finished_product_count": finished_product_count,
"material_count": material_count,
"supplier_count": supplier_count,
"customer_count": customer_count,
"warehouse_count": warehouse_count,
"total_stock": total_stock,
"total_value": round(total_value, 2),
"pending_purchase": pending_purchase,
"pending_sales": pending_sales,
"low_stock_products": low_stock
}
return await dashboard_service.get_dashboard(db_session)
+10 -244
View File
@@ -8,15 +8,13 @@
路由前缀: /api/materials
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional, List
from typing import List
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
@@ -24,22 +22,11 @@ from ..schemas import (
MaterialSupplierResponse,
MaterialPriceTrendResponse
)
from ..services.material_service import material_service
router = APIRouter(prefix="/materials", tags=["物料管理"])
async def _get_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
return product
@router.post("/{product_id}/price-history", response_model=MaterialPriceHistoryResponse, status_code=201)
async def add_material_price_history(
product_id: int,
@@ -47,42 +34,7 @@ async def add_material_price_history(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
if price_data.supplier_id:
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == price_data.supplier_id, Supplier.is_active == True)
)
if not supplier_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="供应商不存在")
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark
)
db_session.add(price_history)
await db_session.flush()
await db_session.refresh(price_history)
# 更新产品的成本价格为最新价格
product.cost_price = price_data.price
await db_session.flush()
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=price_history.supplier.name if price_history.supplier else None,
remark=price_history.remark,
created_at=price_history.created_at
)
return await material_service.add_price_history(db_session, product_id, price_data, current_user)
@router.get("/{product_id}/price-history", response_model=List[MaterialPriceHistoryResponse])
@@ -92,31 +44,7 @@ async def get_material_price_history(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
price_history_list = result.scalars().all()
return [
MaterialPriceHistoryResponse(
id=ph.id,
product_id=ph.product_id,
product_sku=product.sku,
product_name=product.name,
price=ph.price,
effective_date=ph.effective_date,
supplier_id=ph.supplier_id,
supplier_name=ph.supplier.name if ph.supplier else None,
remark=ph.remark,
created_at=ph.created_at
)
for ph in price_history_list
]
return await material_service.get_price_history(db_session, product_id, limit)
@router.get("/{product_id}/price-trend", response_model=MaterialPriceTrendResponse)
@@ -126,45 +54,7 @@ async def get_material_price_trend(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 计算价格趋势
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
price_history_list = result.scalars().all()
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
prices = [ph.price for ph in reversed(price_history_list)]
dates = [ph.effective_date for ph in reversed(price_history_list)]
# 计算价格变化
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
{
"date": ph.effective_date,
"price": ph.price,
"supplier_name": ph.supplier.name if ph.supplier else None
}
for ph in price_history_list
]
)
return await material_service.get_price_trend(db_session, product_id, months)
@router.post("/{product_id}/suppliers", response_model=MaterialSupplierResponse, status_code=201)
@@ -174,63 +64,7 @@ async def add_material_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_data.supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400, detail="供应商不存在")
# 检查是否已存在关联
existing = await db_session.execute(
select(MaterialSupplier)
.where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
# 如果设置为主要供应商,将其他供应商设置为非主要
if supplier_data.is_primary:
await db_session.execute(
MaterialSupplier.__table__.update()
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity
)
db_session.add(material_supplier)
await db_session.flush()
await db_session.refresh(material_supplier)
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier.name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at
)
return await material_service.add_material_supplier(db_session, product_id, supplier_data, current_user)
@router.get("/{product_id}/suppliers", response_model=List[MaterialSupplierResponse])
@@ -239,33 +73,7 @@ async def get_material_suppliers(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
supplier_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=ms.supplier_id,
supplier_name=ms.supplier.name if ms.supplier else None,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in supplier_list
]
return await material_service.get_material_suppliers(db_session, product_id)
@router.delete("/suppliers/{supplier_id}")
@@ -274,17 +82,7 @@ async def remove_material_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.flush()
return {"message": "物料供应商关联已删除"}
return await material_service.remove_material_supplier(db_session, supplier_id, current_user)
@router.get("/suppliers/{supplier_id}/materials", response_model=List[MaterialSupplierResponse])
@@ -293,36 +91,4 @@ async def get_supplier_materials(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
material_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=ms.product.sku if ms.product else None,
product_name=ms.product.name if ms.product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in material_list
]
return await material_service.get_supplier_materials(db_session, supplier_id)
+12 -246
View File
@@ -9,67 +9,20 @@
路由前缀: /api/products
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, func, delete
from typing import Optional, List, Dict
from decimal import Decimal
from pathlib import Path
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from moldinsight.models import STPFile, ProcessingTask
from inventory.models import Product, ProductMaterial
from ..schemas import (
ProductCreate,
ProductResponse,
ProductBOMUpdate,
ProductBOMResponse,
ProductMaterialItemResponse
)
from ..schemas import ProductBOMResponse, ProductBOMUpdate, ProductCreate, ProductResponse
from ..services.product_service import product_service
router = APIRouter(prefix="/products", tags=["产品管理"])
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
if not product_ids:
return {}
result = await db_session.execute(
select(
ProductMaterial.finished_product_id,
func.coalesce(
func.sum(
Product.cost_price * ProductMaterial.quantity
),
0
)
)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(product_ids))
.group_by(ProductMaterial.finished_product_id)
)
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
return ProductResponse(
id=product.id,
sku=product.sku,
name=product.name,
description=product.description,
category=product.category,
unit=product.unit,
item_type=product.item_type,
cost_price=Decimal(str(product.cost_price or 0)),
sale_price=Decimal(str(product.sale_price or 0)),
min_stock=product.min_stock,
max_stock=product.max_stock,
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
is_active=product.is_active,
created_at=product.created_at,
)
@router.get("", response_model=List[ProductResponse])
async def list_products(
@@ -81,21 +34,7 @@ async def list_products(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Product).where(Product.is_active == True)
if search:
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
if category:
query = query.where(Product.category == category)
if item_type:
query = query.where(Product.item_type == item_type)
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db_session.execute(query)
products = result.scalars().all()
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
return await product_service.list_products(db_session, skip, limit, search, category, item_type)
@router.post("", response_model=ProductResponse, status_code=201)
@@ -104,21 +43,7 @@ async def create_product(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
if product_data.item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.dict()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
product = Product(**product_dict)
db_session.add(product)
await db_session.flush()
await db_session.refresh(product)
return _build_product_response(product, 0)
return await product_service.create_product(db_session, product_data, current_user)
@router.post("/from-task/{task_id}", response_model=ProductResponse, status_code=201)
@@ -127,62 +52,7 @@ async def create_product_from_task(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""从模具分析任务创建进销存成品,回写 stp_files.product_id(P2-1)"""
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="分析任务不存在")
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
stp_file = stp_result.scalar_one_or_none()
if not stp_file:
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
# 已关联成品则直接返回(幂等)
if stp_file.product_id:
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
product = existed.scalar_one_or_none()
if product:
return _build_product_response(product, 0)
# 生成唯一 SKU:MI{stp_file_id},冲突则追加序号
base_sku = f"MI{stp_file_id}"
sku = base_sku
n = 1
while True:
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
if not conflict.scalar_one_or_none():
break
n += 1
sku = f"{base_sku}-{n}"
name = Path(stp_file.original_filename or f"mold_{stp_file_id}").stem or f"模具分析-{stp_file_id}"
desc_parts = []
if stp_file.volume:
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
if stp_file.product_weight:
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
if stp_file.surface_area:
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
product = Product(
sku=sku,
name=name,
description=description,
category="模具成品",
unit="件",
item_type="finished",
cost_price=0,
sale_price=0,
min_stock=0,
max_stock=0,
)
db_session.add(product)
await db_session.flush()
stp_file.product_id = product.id
await db_session.flush()
await db_session.refresh(product)
return _build_product_response(product, 0)
return await product_service.create_product_from_task(db_session, task_id, current_user)
@router.put("/{product_id}", response_model=ProductResponse)
@@ -192,25 +62,7 @@ async def update_product(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Product).where(Product.id == product_id))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product_data.item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
product_dict = product_data.dict()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
for key, value in product_dict.items():
setattr(product, key, value)
await db_session.flush()
await db_session.refresh(product)
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
return _build_product_response(product, material_cost_map.get(product.id, 0))
return await product_service.update_product(db_session, product_id, product_data, current_user)
@router.delete("/{product_id}")
@@ -219,14 +71,7 @@ async def delete_product(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Product).where(Product.id == product_id))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
product.is_active = False
await db_session.flush()
return {"message": "产品已删除"}
return await product_service.delete_product(db_session, product_id, current_user)
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
@@ -235,44 +80,7 @@ async def get_product_bom(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product_result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id == product_id)
.order_by(ProductMaterial.id.asc())
)
items: List[ProductMaterialItemResponse] = []
total_material_cost = Decimal("0")
for bom, material in bom_result.all():
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
total_material_cost += line_cost
items.append(
ProductMaterialItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
quantity=Decimal(str(bom.quantity)),
unit_cost=Decimal(str(material.cost_price or 0)),
line_cost=line_cost,
)
)
return ProductBOMResponse(
product_id=product.id,
product_name=product.name,
total_material_cost=total_material_cost,
items=items,
)
return await product_service.get_product_bom(db_session, product_id)
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
@@ -282,46 +90,4 @@ async def replace_product_bom(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product_result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
material_ids = [item.material_id for item in payload.items]
if len(material_ids) != len(set(material_ids)):
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
if material_ids:
material_result = await db_session.execute(
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
)
materials = material_result.scalars().all()
material_map = {m.id: m for m in materials}
if len(material_map) != len(material_ids):
raise HTTPException(status_code=400, detail="存在无效物料")
invalid_materials = [m.name for m in materials if m.item_type != "material"]
if invalid_materials:
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
else:
material_map = {}
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
for item in payload.items:
if item.quantity <= 0:
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
db_session.add(
ProductMaterial(
finished_product_id=product_id,
material_product_id=item.material_id,
quantity=item.quantity,
loss_rate=item.loss_rate,
)
)
await db_session.flush()
return await get_product_bom(product_id, db_session, current_user)
return await product_service.replace_product_bom(db_session, product_id, payload, current_user)
+6 -38
View File
@@ -9,17 +9,15 @@
路由前缀: /api/suppliers
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from inventory.models import Supplier
from ..schemas import SupplierCreate, SupplierResponse
from ..services.master_data_service import master_data_service
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
@@ -32,12 +30,7 @@ async def list_suppliers(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Supplier).where(Supplier.is_active == True)
if search:
query = query.where(Supplier.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
result = await db_session.execute(query)
return [SupplierResponse.from_orm(s) for s in result.scalars().all()]
return await master_data_service.list_suppliers(db_session, skip, limit, search)
@router.post("", response_model=SupplierResponse, status_code=201)
@@ -46,15 +39,7 @@ async def create_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
data = supplier_data.dict()
if not data.get("code"):
data["code"] = f"S{datetime.now().strftime('%Y%m%d%H%M%S')}"
supplier = Supplier(**data)
db_session.add(supplier)
await db_session.flush()
await db_session.refresh(supplier)
return SupplierResponse.from_orm(supplier)
return await master_data_service.create_supplier(db_session, supplier_data, current_user)
@router.put("/{supplier_id}", response_model=SupplierResponse)
@@ -64,17 +49,7 @@ async def update_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
for key, value in supplier_data.dict().items():
setattr(supplier, key, value)
await db_session.flush()
await db_session.refresh(supplier)
return SupplierResponse.from_orm(supplier)
return await master_data_service.update_supplier(db_session, supplier_id, supplier_data, current_user)
@router.delete("/{supplier_id}")
@@ -83,11 +58,4 @@ async def delete_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
supplier.is_active = False
await db_session.flush()
return {"message": "供应商已删除"}
return await master_data_service.delete_supplier(db_session, supplier_id, current_user)
+3 -16
View File
@@ -9,15 +9,13 @@
"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from datetime import datetime
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Warehouse
from ..schemas import WarehouseCreate, WarehouseResponse
from ..services.master_data_service import master_data_service
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
@@ -27,10 +25,7 @@ async def list_warehouses(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
)
return [WarehouseResponse.from_orm(w) for w in result.scalars().all()]
return await master_data_service.list_warehouses(db_session)
@router.post("", response_model=WarehouseResponse, status_code=201)
@@ -39,12 +34,4 @@ async def create_warehouse(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
data = warehouse_data.dict()
if not data.get("code"):
data["code"] = f"W{datetime.now().strftime('%Y%m%d%H%M%S')}"
warehouse = Warehouse(**data)
db_session.add(warehouse)
await db_session.flush()
await db_session.refresh(warehouse)
return WarehouseResponse.from_orm(warehouse)
return await master_data_service.create_warehouse(db_session, warehouse_data, current_user)
+3 -2
View File
@@ -54,7 +54,8 @@ from .material_schemas import (
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
MaterialPriceTrendResponse
MaterialPriceTrendResponse,
PriceHistoryItem
)
from .purchase_demand_schemas import (
PurchaseDemandCalculateRequest,
@@ -89,6 +90,6 @@ __all__ = [
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse", "PriceHistoryItem",
"PurchaseDemandCalculateRequest", "PurchaseDemandItemResponse", "PurchaseDemandResponse",
]
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -20,5 +20,4 @@ class CustomerResponse(BaseModel):
email: Optional[str]
is_active: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+3 -5
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List, Literal
from datetime import datetime
from decimal import Decimal
@@ -39,8 +39,7 @@ class FinanceAllocationResponse(BaseModel):
order_id: int
allocated_amount: Decimal
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class FinanceTransactionResponse(BaseModel):
@@ -59,8 +58,7 @@ class FinanceTransactionResponse(BaseModel):
created_at: datetime
allocations: List[FinanceAllocationResponse] = []
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class FinanceSummaryResponse(BaseModel):
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
from decimal import Decimal
@@ -14,8 +14,7 @@ class InventoryResponse(BaseModel):
locked_quantity: Decimal
available_quantity: Decimal
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class InventoryCreate(BaseModel):
+5 -7
View File
@@ -3,7 +3,7 @@
定义物料价格历史和物料供应商关联的数据结构
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Optional, List, Dict
@@ -27,9 +27,8 @@ class MaterialPriceHistoryResponse(BaseModel):
supplier_name: Optional[str]
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class MaterialSupplierCreate(BaseModel):
@@ -57,9 +56,8 @@ class MaterialSupplierResponse(BaseModel):
min_order_quantity: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PriceHistoryItem(BaseModel):
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
@@ -33,8 +33,7 @@ class ProductResponse(BaseModel):
is_active: bool
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class ProductMaterialItemUpdate(BaseModel):
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import datetime, date
from decimal import Decimal
@@ -34,8 +34,7 @@ class PurchaseOrderResponse(BaseModel):
received_date: Optional[datetime]
paid_date: Optional[datetime]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PurchaseOrderItemResponse(BaseModel):
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import datetime, date
from decimal import Decimal
@@ -43,8 +43,7 @@ class SalesOrderResponse(BaseModel):
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class SalesOrderItemResponse(BaseModel):
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from decimal import Decimal
@@ -27,5 +27,4 @@ class StockMovementResponse(BaseModel):
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -20,5 +20,4 @@ class SupplierResponse(BaseModel):
email: Optional[str]
is_active: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -19,5 +19,4 @@ class WarehouseResponse(BaseModel):
is_active: bool
is_default: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,71 @@
"""仪表盘聚合业务服务层
将 dashboard_routes 中的聚合查询与统计编排下沉到此,
路由层只做依赖注入与响应返回。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from inventory.models import Customer, Inventory, Product, PurchaseOrder, SalesOrder, Supplier, Warehouse
class DashboardService:
"""仪表盘统计服务"""
@staticmethod
async def get_dashboard(db_session: AsyncSession) -> dict:
material_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
) or 0
finished_product_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
) or 0
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) or 0
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) or 0
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) or 0
total_stock = await db_session.scalar(
select(func.sum(Inventory.quantity))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
total_value = await db_session.scalar(
select(func.sum(Inventory.quantity * Product.cost_price))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
pending_purchase = await db_session.scalar(
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
) or 0
pending_sales = await db_session.scalar(
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
) or 0
low_stock_products = await db_session.execute(
select(Product, Inventory)
.join(Inventory, Product.id == Inventory.product_id)
.where(Product.item_type == "material")
.where(Inventory.quantity <= Product.min_stock)
.limit(10)
)
low_stock = [
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
for p, i in low_stock_products.all()
]
return {
"finished_product_count": finished_product_count,
"material_count": material_count,
"supplier_count": supplier_count,
"customer_count": customer_count,
"warehouse_count": warehouse_count,
"total_stock": total_stock,
"total_value": round(total_value, 2),
"pending_purchase": pending_purchase,
"pending_sales": pending_sales,
"low_stock_products": low_stock,
}
dashboard_service = DashboardService()
@@ -0,0 +1,193 @@
"""进销存主数据业务服务层
将 customer / supplier / warehouse 这类主数据 CRUD 编排从路由层下沉到此,
路由层只做参数校验与响应组装。
"""
from datetime import datetime
from typing import List, Optional, Type
from fastapi import HTTPException
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from inventory.models import Customer, Supplier, Warehouse
from ..schemas import (
CustomerCreate,
CustomerResponse,
SupplierCreate,
SupplierResponse,
WarehouseCreate,
WarehouseResponse,
)
def _generate_code(prefix: str) -> str:
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
async def _get_entity_or_404(
db_session: AsyncSession,
model: Type[Customer] | Type[Supplier] | Type[Warehouse],
entity_id: int,
detail: str,
):
result = await db_session.execute(select(model).where(model.id == entity_id))
entity = result.scalar_one_or_none()
if not entity:
raise HTTPException(status_code=404, detail=detail)
return entity
def _build_customer_response(customer: Customer) -> CustomerResponse:
return CustomerResponse.model_validate(customer)
def _build_supplier_response(supplier: Supplier) -> SupplierResponse:
return SupplierResponse.model_validate(supplier)
def _build_warehouse_response(warehouse: Warehouse) -> WarehouseResponse:
return WarehouseResponse.model_validate(warehouse)
class MasterDataService:
"""客户 / 供应商 / 仓库主数据服务"""
@staticmethod
async def list_customers(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
) -> List[CustomerResponse]:
query: Select = select(Customer).where(Customer.is_active == True)
if search:
query = query.where(Customer.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
result = await db_session.execute(query)
return [_build_customer_response(customer) for customer in result.scalars().all()]
@staticmethod
async def create_customer(
db_session: AsyncSession,
customer_data: CustomerCreate,
current_user: User,
) -> CustomerResponse:
data = customer_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("C")
customer = Customer(**data)
db_session.add(customer)
await db_session.commit()
await db_session.refresh(customer)
return _build_customer_response(customer)
@staticmethod
async def update_customer(
db_session: AsyncSession,
customer_id: int,
customer_data: CustomerCreate,
current_user: User,
) -> CustomerResponse:
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
for key, value in customer_data.model_dump().items():
setattr(customer, key, value)
await db_session.commit()
await db_session.refresh(customer)
return _build_customer_response(customer)
@staticmethod
async def delete_customer(
db_session: AsyncSession,
customer_id: int,
current_user: User,
) -> dict:
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
customer.is_active = False
await db_session.commit()
return {"message": "客户已删除"}
@staticmethod
async def list_suppliers(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
) -> List[SupplierResponse]:
query: Select = select(Supplier).where(Supplier.is_active == True)
if search:
query = query.where(Supplier.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
result = await db_session.execute(query)
return [_build_supplier_response(supplier) for supplier in result.scalars().all()]
@staticmethod
async def create_supplier(
db_session: AsyncSession,
supplier_data: SupplierCreate,
current_user: User,
) -> SupplierResponse:
data = supplier_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("S")
supplier = Supplier(**data)
db_session.add(supplier)
await db_session.commit()
await db_session.refresh(supplier)
return _build_supplier_response(supplier)
@staticmethod
async def update_supplier(
db_session: AsyncSession,
supplier_id: int,
supplier_data: SupplierCreate,
current_user: User,
) -> SupplierResponse:
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
for key, value in supplier_data.model_dump().items():
setattr(supplier, key, value)
await db_session.commit()
await db_session.refresh(supplier)
return _build_supplier_response(supplier)
@staticmethod
async def delete_supplier(
db_session: AsyncSession,
supplier_id: int,
current_user: User,
) -> dict:
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
supplier.is_active = False
await db_session.commit()
return {"message": "供应商已删除"}
@staticmethod
async def list_warehouses(
db_session: AsyncSession,
) -> List[WarehouseResponse]:
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
)
return [_build_warehouse_response(warehouse) for warehouse in result.scalars().all()]
@staticmethod
async def create_warehouse(
db_session: AsyncSession,
warehouse_data: WarehouseCreate,
current_user: User,
) -> WarehouseResponse:
data = warehouse_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("W")
warehouse = Warehouse(**data)
db_session.add(warehouse)
await db_session.commit()
await db_session.refresh(warehouse)
return _build_warehouse_response(warehouse)
master_data_service = MasterDataService()
+284
View File
@@ -0,0 +1,284 @@
"""物料管理业务服务层
将 material_routes 中的价格历史、价格趋势、物料供应商关联等业务编排下沉到此,
路由层只做参数校验与响应组装。
"""
from typing import List
from fastapi import HTTPException
from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from inventory.models import MaterialPriceHistory, MaterialSupplier, Product, Supplier
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialPriceTrendResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
PriceHistoryItem,
)
async def _get_material_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
return product
async def _get_active_supplier(db_session: AsyncSession, supplier_id: int, detail: str = "供应商不存在") -> Supplier:
result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400 if detail == "供应商不存在" else 404, detail=detail)
return supplier
def _build_price_history_response(
product: Product,
price_history: MaterialPriceHistory,
supplier_name: str | None,
) -> MaterialPriceHistoryResponse:
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=supplier_name,
remark=price_history.remark,
created_at=price_history.created_at,
)
def _build_material_supplier_response(
product: Product,
material_supplier: MaterialSupplier,
supplier_name: str | None,
) -> MaterialSupplierResponse:
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier_name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at,
)
class MaterialService:
"""物料价格与供应商关联服务"""
@staticmethod
async def add_price_history(
db_session: AsyncSession,
product_id: int,
price_data: MaterialPriceHistoryCreate,
current_user: User,
) -> MaterialPriceHistoryResponse:
product = await _get_material_product(db_session, product_id)
supplier_name = None
if price_data.supplier_id:
supplier = await _get_active_supplier(db_session, price_data.supplier_id)
supplier_name = supplier.name
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark,
)
db_session.add(price_history)
product.cost_price = price_data.price
await db_session.commit()
await db_session.refresh(price_history)
return _build_price_history_response(product, price_history, supplier_name)
@staticmethod
async def get_price_history(
db_session: AsyncSession,
product_id: int,
limit: int,
) -> List[MaterialPriceHistoryResponse]:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory, Supplier)
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
return [
_build_price_history_response(product, ph, supplier.name if supplier else None)
for ph, supplier in result.all()
]
@staticmethod
async def get_price_trend(
db_session: AsyncSession,
product_id: int,
months: int,
) -> MaterialPriceTrendResponse:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory, Supplier)
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
rows = result.all()
price_history_list = [ph for ph, _supplier in rows]
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
PriceHistoryItem(
date=ph.effective_date,
price=ph.price,
supplier_name=supplier.name if supplier else None,
)
for ph, supplier in rows
],
)
@staticmethod
async def add_material_supplier(
db_session: AsyncSession,
product_id: int,
supplier_data: MaterialSupplierCreate,
current_user: User,
) -> MaterialSupplierResponse:
product = await _get_material_product(db_session, product_id)
supplier = await _get_active_supplier(db_session, supplier_data.supplier_id)
existing = await db_session.execute(
select(MaterialSupplier).where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
if supplier_data.is_primary:
await db_session.execute(
update(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity,
)
db_session.add(material_supplier)
await db_session.commit()
await db_session.refresh(material_supplier)
return _build_material_supplier_response(product, material_supplier, supplier.name)
@staticmethod
async def get_material_suppliers(
db_session: AsyncSession,
product_id: int,
) -> List[MaterialSupplierResponse]:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier, Supplier)
.outerjoin(Supplier, MaterialSupplier.supplier_id == Supplier.id)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
return [
_build_material_supplier_response(product, ms, supplier.name if supplier else None)
for ms, supplier in result.all()
]
@staticmethod
async def remove_material_supplier(
db_session: AsyncSession,
supplier_id: int,
current_user: User,
) -> dict:
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.commit()
return {"message": "物料供应商关联已删除"}
@staticmethod
async def get_supplier_materials(
db_session: AsyncSession,
supplier_id: int,
) -> List[MaterialSupplierResponse]:
supplier = await _get_active_supplier(db_session, supplier_id, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier, Product)
.outerjoin(Product, MaterialSupplier.product_id == Product.id)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku if product else None,
product_name=product.name if product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at,
)
for ms, product in result.all()
]
material_service = MaterialService()
+317
View File
@@ -0,0 +1,317 @@
"""产品管理业务服务层
将 product_routes 中不跨模块的产品 CRUD / BOM 编排下沉到此,
路由层只做参数校验与响应组装。
"""
from decimal import Decimal
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from moldinsight.models import ProcessingTask, STPFile
from inventory.models import Product, ProductMaterial
from ..schemas import (
ProductBOMResponse,
ProductBOMUpdate,
ProductCreate,
ProductMaterialItemResponse,
ProductResponse,
)
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, Decimal]:
if not product_ids:
return {}
result = await db_session.execute(
select(
ProductMaterial.finished_product_id,
func.coalesce(
func.sum(Product.cost_price * ProductMaterial.quantity),
0,
),
)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(product_ids))
.group_by(ProductMaterial.finished_product_id)
)
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
return ProductResponse(
id=product.id,
sku=product.sku,
name=product.name,
description=product.description,
category=product.category,
unit=product.unit,
item_type=product.item_type,
cost_price=Decimal(str(product.cost_price or 0)),
sale_price=Decimal(str(product.sale_price or 0)),
min_stock=product.min_stock,
max_stock=product.max_stock,
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
is_active=product.is_active,
created_at=product.created_at,
)
async def _get_product_or_404(db_session: AsyncSession, product_id: int, active_only: bool = False) -> Product:
query = select(Product).where(Product.id == product_id)
if active_only:
query = query.where(Product.is_active == True)
result = await db_session.execute(query)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
return product
def _validate_item_type(item_type: str) -> None:
if item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
async def _build_bom_response(db_session: AsyncSession, product: Product) -> ProductBOMResponse:
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id == product.id)
.order_by(ProductMaterial.id.asc())
)
items: List[ProductMaterialItemResponse] = []
total_material_cost = Decimal("0")
for bom, material in bom_result.all():
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
total_material_cost += line_cost
items.append(
ProductMaterialItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
quantity=Decimal(str(bom.quantity)),
unit_cost=Decimal(str(material.cost_price or 0)),
line_cost=line_cost,
)
)
return ProductBOMResponse(
product_id=product.id,
product_name=product.name,
total_material_cost=total_material_cost,
items=items,
)
class ProductService:
"""产品 CRUD 与 BOM 服务"""
@staticmethod
async def list_products(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
category: Optional[str],
item_type: Optional[str],
) -> List[ProductResponse]:
query = select(Product).where(Product.is_active == True)
if search:
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
if category:
query = query.where(Product.category == category)
if item_type:
query = query.where(Product.item_type == item_type)
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db_session.execute(query)
products = result.scalars().all()
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
@staticmethod
async def create_product(
db_session: AsyncSession,
product_data: ProductCreate,
current_user: User,
) -> ProductResponse:
_validate_item_type(product_data.item_type)
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.model_dump()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
product = Product(**product_dict)
db_session.add(product)
await db_session.commit()
await db_session.refresh(product)
return _build_product_response(product, 0)
@staticmethod
async def create_product_from_task(
db_session: AsyncSession,
task_id: str,
current_user: User,
) -> ProductResponse:
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="分析任务不存在")
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
stp_file = stp_result.scalar_one_or_none()
if not stp_file:
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
if stp_file.product_id:
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
product = existed.scalar_one_or_none()
if product:
return _build_product_response(product, 0)
base_sku = f"MI{stp_file.id}"
sku = base_sku
n = 1
while True:
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
if not conflict.scalar_one_or_none():
break
n += 1
sku = f"{base_sku}-{n}"
name = Path(stp_file.original_filename or f"mold_{stp_file.id}").stem or f"模具分析-{stp_file.id}"
desc_parts = []
if stp_file.volume:
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
if stp_file.product_weight:
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
if stp_file.surface_area:
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
product = Product(
sku=sku,
name=name,
description=description,
category="模具成品",
unit="件",
item_type="finished",
cost_price=0,
sale_price=0,
min_stock=0,
max_stock=0,
)
db_session.add(product)
await db_session.flush()
stp_file.product_id = product.id
await db_session.commit()
await db_session.refresh(product)
return _build_product_response(product, 0)
@staticmethod
async def update_product(
db_session: AsyncSession,
product_id: int,
product_data: ProductCreate,
current_user: User,
) -> ProductResponse:
product = await _get_product_or_404(db_session, product_id)
_validate_item_type(product_data.item_type)
conflict = await db_session.execute(
select(Product).where(Product.sku == product_data.sku, Product.id != product_id)
)
if conflict.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.model_dump()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
for key, value in product_dict.items():
setattr(product, key, value)
await db_session.commit()
await db_session.refresh(product)
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
return _build_product_response(product, material_cost_map.get(product.id, 0))
@staticmethod
async def delete_product(
db_session: AsyncSession,
product_id: int,
current_user: User,
) -> dict:
product = await _get_product_or_404(db_session, product_id)
product.is_active = False
await db_session.commit()
return {"message": "产品已删除"}
@staticmethod
async def get_product_bom(
db_session: AsyncSession,
product_id: int,
) -> ProductBOMResponse:
product = await _get_product_or_404(db_session, product_id, active_only=True)
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
return await _build_bom_response(db_session, product)
@staticmethod
async def replace_product_bom(
db_session: AsyncSession,
product_id: int,
payload: ProductBOMUpdate,
current_user: User,
) -> ProductBOMResponse:
product = await _get_product_or_404(db_session, product_id, active_only=True)
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
material_ids = [item.material_id for item in payload.items]
if len(material_ids) != len(set(material_ids)):
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
if material_ids:
material_result = await db_session.execute(
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
)
materials = material_result.scalars().all()
material_map = {m.id: m for m in materials}
if len(material_map) != len(material_ids):
raise HTTPException(status_code=400, detail="存在无效物料")
invalid_materials = [m.name for m in materials if m.item_type != "material"]
if invalid_materials:
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
for item in payload.items:
if item.quantity <= 0:
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
for item in payload.items:
db_session.add(
ProductMaterial(
finished_product_id=product_id,
material_product_id=item.material_id,
quantity=item.quantity,
loss_rate=item.loss_rate,
)
)
await db_session.commit()
return await _build_bom_response(db_session, product)
product_service = ProductService()
+4 -7
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import timedelta
from sqlalchemy import select
@@ -31,8 +31,7 @@ class UserResponse(BaseModel):
is_superuser: bool = False
roles: List[str]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
@@ -60,8 +59,7 @@ class RoleResponse(BaseModel):
is_system: bool
permissions: List[str]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PermissionCreate(BaseModel):
@@ -78,8 +76,7 @@ class PermissionResponse(BaseModel):
module: Optional[str]
description: Optional[str]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class UserCreate(BaseModel):
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, ExpiredSignatureError, jwt
import bcrypt
@@ -48,9 +48,9 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, _require_secret_key(), algorithm=settings.ALGORITHM)
return encoded_jwt
@@ -142,7 +142,7 @@ async def authenticate_user(db_session: AsyncSession, username: str, password: s
if not verify_password(password, user.hashed_password):
return None
user.last_login = datetime.utcnow()
user.last_login = datetime.now(timezone.utc)
await db_session.commit()
return user
+32 -2
View File
@@ -20,9 +20,10 @@ from inventory.api import inventory_router
from shared.models.base import Base
from shared.models.identity import User
from inventory.models import Customer, Warehouse, Supplier, Product, ProductMaterial, Inventory, MaterialSupplier, SalesOrder, SalesOrderItem
from moldinsight.models import STPFile, ProcessingTask
import moldinsight.models # noqa: F401 # 全量注册:create_all 需含 moldinsight 表(stp_files 等)
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.services.auth_service import get_current_active_user, get_current_admin_user
@pytest.fixture(scope="session")
@@ -133,7 +134,30 @@ async def seeded_db(async_engine):
lead_time=7,
)
session.add_all([user, customer, supplier, warehouse, material, finished, finished_no_bom, bom, inv, ms])
stp_file = STPFile(
id=1,
original_filename="demo-mold.stp",
object_key="uploads/demo.stp",
storage_bucket="test-bucket",
file_size=123,
file_hash="hash-demo-1",
mime_type="application/step",
user_id=user.id,
volume=1000.0,
surface_area=200.0,
product_weight=50.0,
)
task = ProcessingTask(
id=1,
task_id="task-demo-1",
status="completed",
task_type="stp_parsing",
progress=100,
current_step="done",
stp_file_id=stp_file.id,
)
session.add_all([user, customer, supplier, warehouse, material, finished, finished_no_bom, bom, inv, ms, stp_file, task])
await session.commit()
async with session_factory() as session:
@@ -156,8 +180,14 @@ async def client(async_engine, seeded_db):
result = await session.execute(select(User).where(User.username == "tester"))
return result.scalar_one()
async def override_get_current_admin_user():
async with session_factory() as session:
result = await session.execute(select(User).where(User.username == "tester"))
return result.scalar_one()
test_app.dependency_overrides[get_db_session] = override_get_db_session
test_app.dependency_overrides[get_current_active_user] = override_get_current_active_user
test_app.dependency_overrides[get_current_admin_user] = override_get_current_admin_user
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
+39
View File
@@ -0,0 +1,39 @@
import pytest
@pytest.mark.anyio
async def test_dashboard_returns_seeded_summary(client):
resp = await client.get("/api/dashboard")
assert resp.status_code == 200
body = resp.json()
assert body["material_count"] == 1
assert body["finished_product_count"] == 2
assert body["supplier_count"] == 1
assert body["customer_count"] == 1
assert body["warehouse_count"] == 1
assert body["total_stock"] == 1000
assert float(body["total_value"]) == 10000.0
assert body["pending_purchase"] == 0
assert body["pending_sales"] == 0
assert body["low_stock_products"] == []
@pytest.mark.anyio
async def test_dashboard_low_stock_products_are_listed(client):
inventory = await client.get("/api/inventory")
assert inventory.status_code == 200
inv_id = next(row["id"] for row in inventory.json()["items"] if row["product_sku"] == "MAT-001")
updated = await client.put(
f"/api/inventory/{inv_id}",
json={"quantity": 0, "locked_quantity": 0},
)
assert updated.status_code == 200
resp = await client.get("/api/dashboard")
assert resp.status_code == 200
low_stock = resp.json()["low_stock_products"]
assert len(low_stock) == 1
assert low_stock[0]["sku"] == "MAT-001"
assert low_stock[0]["quantity"] == 0
assert low_stock[0]["min_stock"] == 0
+180
View File
@@ -0,0 +1,180 @@
import pytest
@pytest.mark.anyio
async def test_customer_list_search_filters_active_rows(client):
create_resp = await client.post(
"/api/customers",
json={"name": "目标客户Alpha", "contact_person": "张三"},
)
assert create_resp.status_code == 201
customer_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/customers/{customer_id}")
assert delete_resp.status_code == 200
kept_resp = await client.post(
"/api/customers",
json={"name": "目标客户Beta", "contact_person": "李四"},
)
assert kept_resp.status_code == 201
resp = await client.get("/api/customers", params={"search": "目标客户"})
assert resp.status_code == 200
rows = resp.json()
names = [row["name"] for row in rows]
assert "目标客户Beta" in names
assert "目标客户Alpha" not in names
@pytest.mark.anyio
async def test_customer_create_auto_generates_code(client):
resp = await client.post(
"/api/customers",
json={"name": "自动编码客户", "phone": "123456"},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"].startswith("C")
assert body["name"] == "自动编码客户"
@pytest.mark.anyio
async def test_customer_update_returns_latest_fields(client):
create_resp = await client.post(
"/api/customers",
json={"name": "旧客户名", "email": "old@example.com"},
)
assert create_resp.status_code == 201
customer_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/customers/{customer_id}",
json={"code": "C-CUSTOM", "name": "新客户名", "email": "new@example.com"},
)
assert update_resp.status_code == 200
body = update_resp.json()
assert body["code"] == "C-CUSTOM"
assert body["name"] == "新客户名"
assert body["email"] == "new@example.com"
@pytest.mark.anyio
async def test_customer_delete_soft_deletes_row(client):
create_resp = await client.post(
"/api/customers",
json={"name": "待删除客户"},
)
assert create_resp.status_code == 201
customer_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/customers/{customer_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["message"] == "客户已删除"
list_resp = await client.get("/api/customers", params={"search": "待删除客户"})
assert list_resp.status_code == 200
assert all(row["id"] != customer_id for row in list_resp.json())
@pytest.mark.anyio
async def test_supplier_list_search_filters_active_rows(client):
create_resp = await client.post(
"/api/suppliers",
json={"name": "目标供应商Alpha", "contact_person": "王五"},
)
assert create_resp.status_code == 201
supplier_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/suppliers/{supplier_id}")
assert delete_resp.status_code == 200
kept_resp = await client.post(
"/api/suppliers",
json={"name": "目标供应商Beta", "contact_person": "赵六"},
)
assert kept_resp.status_code == 201
resp = await client.get("/api/suppliers", params={"search": "目标供应商"})
assert resp.status_code == 200
rows = resp.json()
names = [row["name"] for row in rows]
assert "目标供应商Beta" in names
assert "目标供应商Alpha" not in names
@pytest.mark.anyio
async def test_supplier_create_auto_generates_code(client):
resp = await client.post(
"/api/suppliers",
json={"name": "自动编码供应商", "phone": "123456"},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"].startswith("S")
assert body["name"] == "自动编码供应商"
@pytest.mark.anyio
async def test_supplier_update_returns_latest_fields(client):
create_resp = await client.post(
"/api/suppliers",
json={"name": "旧供应商名", "email": "old-s@example.com"},
)
assert create_resp.status_code == 201
supplier_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/suppliers/{supplier_id}",
json={"code": "S-CUSTOM", "name": "新供应商名", "email": "new-s@example.com"},
)
assert update_resp.status_code == 200
body = update_resp.json()
assert body["code"] == "S-CUSTOM"
assert body["name"] == "新供应商名"
assert body["email"] == "new-s@example.com"
@pytest.mark.anyio
async def test_supplier_delete_soft_deletes_row(client):
create_resp = await client.post(
"/api/suppliers",
json={"name": "待删除供应商"},
)
assert create_resp.status_code == 201
supplier_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/suppliers/{supplier_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["message"] == "供应商已删除"
list_resp = await client.get("/api/suppliers", params={"search": "待删除供应商"})
assert list_resp.status_code == 200
assert all(row["id"] != supplier_id for row in list_resp.json())
@pytest.mark.anyio
async def test_warehouse_list_orders_default_first(client):
create_resp = await client.post(
"/api/warehouses",
json={"code": "W002", "name": "普通仓库"},
)
assert create_resp.status_code == 201
resp = await client.get("/api/warehouses")
assert resp.status_code == 200
rows = resp.json()
assert rows[0]["is_default"] is True
assert rows[0]["name"] == "默认仓库"
@pytest.mark.anyio
async def test_warehouse_create_auto_generates_code(client):
resp = await client.post(
"/api/warehouses",
json={"name": "自动编码仓库", "manager": "管理员A"},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"].startswith("W")
assert body["name"] == "自动编码仓库"
+100
View File
@@ -0,0 +1,100 @@
import pytest
@pytest.mark.anyio
async def test_material_price_history_create_updates_latest_cost(client):
resp = await client.post(
"/api/materials/1/price-history",
json={"price": 12.5, "supplier_id": 1, "remark": "latest"},
)
assert resp.status_code == 201
body = resp.json()
assert body["product_id"] == 1
assert body["price"] == 12.5
assert body["supplier_name"] == "供应商A"
history = await client.get("/api/materials/1/price-history")
assert history.status_code == 200
assert history.json()[0]["price"] == 12.5
@pytest.mark.anyio
async def test_material_price_trend_returns_change_summary(client):
resp1 = await client.post(
"/api/materials/1/price-history",
json={"price": 10.0, "supplier_id": 1},
)
assert resp1.status_code == 201
resp2 = await client.post(
"/api/materials/1/price-history",
json={"price": 15.0, "supplier_id": 1},
)
assert resp2.status_code == 201
trend = await client.get("/api/materials/1/price-trend", params={"months": 6})
assert trend.status_code == 200
body = trend.json()
prices = [item["price"] for item in body["price_history"]]
assert sorted(prices) == [10.0, 15.0]
assert body["current_price"] in {10.0, 15.0}
assert body["price_change"] in {5.0, -5.0}
assert body["price_change_percent"] in {50.0, -33.33}
assert len(body["price_history"]) == 2
@pytest.mark.anyio
async def test_material_price_trend_requires_history(client):
resp = await client.get("/api/materials/1/price-trend")
assert resp.status_code == 404
@pytest.mark.anyio
async def test_add_material_supplier_and_list_by_material(client):
resp = await client.post(
"/api/materials/1/suppliers",
json={"supplier_id": 1, "is_primary": True, "lead_time": 7, "min_order_quantity": 10},
)
assert resp.status_code == 400 # seeded_db 已存在主关联
@pytest.mark.anyio
async def test_get_material_suppliers_and_supplier_materials(client):
by_material = await client.get("/api/materials/1/suppliers")
assert by_material.status_code == 200
material_rows = by_material.json()
assert len(material_rows) == 1
assert material_rows[0]["supplier_id"] == 1
assert material_rows[0]["supplier_name"] == "供应商A"
by_supplier = await client.get("/api/materials/suppliers/1/materials")
assert by_supplier.status_code == 200
supplier_rows = by_supplier.json()
assert len(supplier_rows) == 1
assert supplier_rows[0]["product_id"] == 1
assert supplier_rows[0]["product_sku"] == "MAT-001"
@pytest.mark.anyio
async def test_remove_material_supplier_deletes_association(client):
resp = await client.delete("/api/materials/suppliers/1")
assert resp.status_code == 200
assert resp.json()["message"] == "物料供应商关联已删除"
by_material = await client.get("/api/materials/1/suppliers")
assert by_material.status_code == 200
assert by_material.json() == []
@pytest.mark.anyio
@pytest.mark.parametrize(
"url,payload,expected",
[
("/api/materials/2/price-history", {"price": 10.0}, 400),
("/api/materials/99999/price-history", {"price": 10.0}, 404),
("/api/materials/1/price-history", {"price": 10.0, "supplier_id": 99999}, 400),
("/api/materials/1/suppliers", {"supplier_id": 99999}, 400),
],
)
async def test_material_endpoints_validation(url, payload, expected, client):
resp = await client.post(url, json=payload)
assert resp.status_code == expected
+176
View File
@@ -0,0 +1,176 @@
import pytest
@pytest.mark.anyio
async def test_list_products_returns_material_cost_for_finished_goods(client):
resp = await client.get("/api/products")
assert resp.status_code == 200
rows = resp.json()
finished = next(row for row in rows if row["sku"] == "MOLD-STD")
assert float(finished["material_cost"]) == 20.0
@pytest.mark.anyio
async def test_create_product_finished_resets_stock_bounds(client):
resp = await client.post(
"/api/products",
json={
"sku": "FG-NEW",
"name": "新成品",
"item_type": "finished",
"unit": "件",
"min_stock": 10,
"max_stock": 99,
"cost_price": 12,
"sale_price": 20,
},
)
assert resp.status_code == 201
body = resp.json()
assert body["sku"] == "FG-NEW"
assert body["min_stock"] == 0
assert body["max_stock"] == 0
@pytest.mark.anyio
async def test_update_product_rejects_duplicate_sku(client):
created = await client.post(
"/api/products",
json={
"sku": "FG-2",
"name": "成品2",
"item_type": "finished",
"unit": "件",
"cost_price": 0,
"sale_price": 1,
},
)
assert created.status_code == 201
product_id = created.json()["id"]
resp = await client.put(
f"/api/products/{product_id}",
json={
"sku": "MOLD-STD",
"name": "改名",
"item_type": "finished",
"unit": "件",
"cost_price": 0,
"sale_price": 1,
},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "SKU已存在"
@pytest.mark.anyio
async def test_delete_product_soft_deletes_record(client):
created = await client.post(
"/api/products",
json={
"sku": "MAT-DEL",
"name": "待删物料",
"item_type": "material",
"unit": "kg",
"cost_price": 2,
"sale_price": 0,
},
)
assert created.status_code == 201
product_id = created.json()["id"]
deleted = await client.delete(f"/api/products/{product_id}")
assert deleted.status_code == 200
assert deleted.json()["message"] == "产品已删除"
listed = await client.get("/api/products")
assert listed.status_code == 200
assert all(row["id"] != product_id for row in listed.json())
@pytest.mark.anyio
async def test_get_product_bom_returns_line_costs(client):
resp = await client.get("/api/products/2/materials")
assert resp.status_code == 200
body = resp.json()
assert body["product_id"] == 2
assert float(body["total_material_cost"]) == 20.0
assert len(body["items"]) == 1
assert body["items"][0]["material_sku"] == "MAT-001"
@pytest.mark.anyio
async def test_replace_product_bom_rewrites_items(client):
created = await client.post(
"/api/products",
json={
"sku": "MAT-002",
"name": "铝材",
"item_type": "material",
"unit": "kg",
"cost_price": 5,
"sale_price": 0,
},
)
assert created.status_code == 201
material_id = created.json()["id"]
replaced = await client.put(
"/api/products/2/materials",
json={
"items": [
{"material_id": 1, "quantity": 1, "loss_rate": 0},
{"material_id": material_id, "quantity": 2, "loss_rate": 0.1},
]
},
)
assert replaced.status_code == 200
body = replaced.json()
assert len(body["items"]) == 2
assert float(body["total_material_cost"]) == 20.0
@pytest.mark.anyio
@pytest.mark.parametrize(
"url,payload,expected",
[
("/api/products", {"sku": "BAD", "name": "bad", "item_type": "unknown", "unit": "件", "cost_price": 0, "sale_price": 0}, 400),
("/api/products/2/materials", {"items": [{"material_id": 1, "quantity": 1}, {"material_id": 1, "quantity": 2}]}, 400),
("/api/products/2/materials", {"items": [{"material_id": 99999, "quantity": 1}]}, 400),
("/api/products/2/materials", {"items": [{"material_id": 2, "quantity": 1}]}, 400),
("/api/products/2/materials", {"items": [{"material_id": 1, "quantity": 0}]}, 400),
("/api/products/1/materials", {"items": []}, 400),
],
)
async def test_product_service_validation_paths(url, payload, expected, client):
method = client.post if url == "/api/products" else client.put
resp = await method(url, json=payload)
assert resp.status_code == expected
@pytest.mark.anyio
async def test_create_product_from_task_creates_and_binds_product(client):
resp = await client.post("/api/products/from-task/task-demo-1")
assert resp.status_code == 201
body = resp.json()
assert body["sku"] == "MI1"
assert body["name"] == "demo-mold"
assert body["category"] == "模具成品"
assert body["item_type"] == "finished"
assert body["min_stock"] == 0
assert body["max_stock"] == 0
assert "由模具分析创建" in body["description"]
again = await client.post("/api/products/from-task/task-demo-1")
assert again.status_code == 201
assert again.json()["id"] == body["id"]
@pytest.mark.anyio
@pytest.mark.parametrize(
"task_id,expected",
[("missing-task", 404)],
)
async def test_create_product_from_task_validation(task_id, expected, client):
resp = await client.post(f"/api/products/from-task/{task_id}")
assert resp.status_code == expected