批次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:
@@ -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/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"] == "自动编码仓库"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user