From 64dc85bd14e2689cfc382ac72d30a3d727cd91ea Mon Sep 17 00:00:00 2001 From: chenjw28 <792430652@qq.com> Date: Tue, 22 Sep 2026 16:13:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E6=AC=A15=EF=BC=9Ainventory=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=B8=8B=E6=B2=89=E6=94=B6=E5=8F=A3=20+=20Pydantic=20?= =?UTF-8?q?v2=20/=20datetime=E5=BC=83=E7=94=A8=E6=B8=85=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 2 +- docs/ROADMAP.md | 4 + docs/STATUS.md | 12 +- docs/TECH_DEBT.md | 14 +- src/inventory/api/customer_routes.py | 44 +-- src/inventory/api/dashboard_routes.py | 56 +--- src/inventory/api/material_routes.py | 254 +------------- src/inventory/api/product_routes.py | 258 +------------- src/inventory/api/supplier_routes.py | 44 +-- src/inventory/api/warehouse_routes.py | 19 +- src/inventory/schemas/__init__.py | 5 +- src/inventory/schemas/customer_schemas.py | 5 +- src/inventory/schemas/finance_schemas.py | 8 +- src/inventory/schemas/inventory_schemas.py | 5 +- src/inventory/schemas/material_schemas.py | 12 +- src/inventory/schemas/product_schemas.py | 5 +- .../schemas/purchase_order_schemas.py | 5 +- src/inventory/schemas/sales_order_schemas.py | 5 +- .../schemas/stock_movement_schemas.py | 5 +- src/inventory/schemas/supplier_schemas.py | 5 +- src/inventory/schemas/warehouse_schemas.py | 5 +- src/inventory/services/dashboard_service.py | 71 ++++ src/inventory/services/master_data_service.py | 193 +++++++++++ src/inventory/services/material_service.py | 284 ++++++++++++++++ src/inventory/services/product_service.py | 317 ++++++++++++++++++ src/shared/services/auth_routes.py | 11 +- src/shared/services/auth_service.py | 8 +- tests/conftest.py | 34 +- tests/test_api_dashboard_service.py | 39 +++ tests/test_api_inventory_master_data.py | 180 ++++++++++ tests/test_api_material_service.py | 100 ++++++ tests/test_api_product_service.py | 176 ++++++++++ 32 files changed, 1495 insertions(+), 690 deletions(-) create mode 100644 src/inventory/services/dashboard_service.py create mode 100644 src/inventory/services/master_data_service.py create mode 100644 src/inventory/services/material_service.py create mode 100644 src/inventory/services/product_service.py create mode 100644 tests/test_api_dashboard_service.py create mode 100644 tests/test_api_inventory_master_data.py create mode 100644 tests/test_api_material_service.py create mode 100644 tests/test_api_product_service.py diff --git a/AGENTS.md b/AGENTS.md index 81d0d20..3fd625d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 72e3b91..0ac3410 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 主线四:部署与运维一致性 diff --git a/docs/STATUS.md b/docs/STATUS.md index ce07c4e..f22e7e6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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。) diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 8d11cf4..23244f1 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -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) --- diff --git a/src/inventory/api/customer_routes.py b/src/inventory/api/customer_routes.py index 7765a2f..bb29a35 100644 --- a/src/inventory/api/customer_routes.py +++ b/src/inventory/api/customer_routes.py @@ -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) diff --git a/src/inventory/api/dashboard_routes.py b/src/inventory/api/dashboard_routes.py index 90faec0..733bb28 100644 --- a/src/inventory/api/dashboard_routes.py +++ b/src/inventory/api/dashboard_routes.py @@ -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) diff --git a/src/inventory/api/material_routes.py b/src/inventory/api/material_routes.py index 1e16e02..4c53570 100644 --- a/src/inventory/api/material_routes.py +++ b/src/inventory/api/material_routes.py @@ -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) diff --git a/src/inventory/api/product_routes.py b/src/inventory/api/product_routes.py index 098909f..098f742 100644 --- a/src/inventory/api/product_routes.py +++ b/src/inventory/api/product_routes.py @@ -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) diff --git a/src/inventory/api/supplier_routes.py b/src/inventory/api/supplier_routes.py index 1529c31..e413c4b 100644 --- a/src/inventory/api/supplier_routes.py +++ b/src/inventory/api/supplier_routes.py @@ -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) diff --git a/src/inventory/api/warehouse_routes.py b/src/inventory/api/warehouse_routes.py index 890f389..9a76398 100644 --- a/src/inventory/api/warehouse_routes.py +++ b/src/inventory/api/warehouse_routes.py @@ -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) diff --git a/src/inventory/schemas/__init__.py b/src/inventory/schemas/__init__.py index ca08400..7695dd6 100644 --- a/src/inventory/schemas/__init__.py +++ b/src/inventory/schemas/__init__.py @@ -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", ] diff --git a/src/inventory/schemas/customer_schemas.py b/src/inventory/schemas/customer_schemas.py index 3fa4624..344a9d6 100644 --- a/src/inventory/schemas/customer_schemas.py +++ b/src/inventory/schemas/customer_schemas.py @@ -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) diff --git a/src/inventory/schemas/finance_schemas.py b/src/inventory/schemas/finance_schemas.py index 32d220c..59dbfc0 100644 --- a/src/inventory/schemas/finance_schemas.py +++ b/src/inventory/schemas/finance_schemas.py @@ -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): diff --git a/src/inventory/schemas/inventory_schemas.py b/src/inventory/schemas/inventory_schemas.py index 033984d..13094cb 100644 --- a/src/inventory/schemas/inventory_schemas.py +++ b/src/inventory/schemas/inventory_schemas.py @@ -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): diff --git a/src/inventory/schemas/material_schemas.py b/src/inventory/schemas/material_schemas.py index f5dec3c..c12ad47 100644 --- a/src/inventory/schemas/material_schemas.py +++ b/src/inventory/schemas/material_schemas.py @@ -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): diff --git a/src/inventory/schemas/product_schemas.py b/src/inventory/schemas/product_schemas.py index be1bdf0..db25856 100644 --- a/src/inventory/schemas/product_schemas.py +++ b/src/inventory/schemas/product_schemas.py @@ -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): diff --git a/src/inventory/schemas/purchase_order_schemas.py b/src/inventory/schemas/purchase_order_schemas.py index b896b1e..4f3e5fb 100644 --- a/src/inventory/schemas/purchase_order_schemas.py +++ b/src/inventory/schemas/purchase_order_schemas.py @@ -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): diff --git a/src/inventory/schemas/sales_order_schemas.py b/src/inventory/schemas/sales_order_schemas.py index ff5441a..73657df 100644 --- a/src/inventory/schemas/sales_order_schemas.py +++ b/src/inventory/schemas/sales_order_schemas.py @@ -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): diff --git a/src/inventory/schemas/stock_movement_schemas.py b/src/inventory/schemas/stock_movement_schemas.py index c438510..0090564 100644 --- a/src/inventory/schemas/stock_movement_schemas.py +++ b/src/inventory/schemas/stock_movement_schemas.py @@ -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) diff --git a/src/inventory/schemas/supplier_schemas.py b/src/inventory/schemas/supplier_schemas.py index 8b4a917..c9f0542 100644 --- a/src/inventory/schemas/supplier_schemas.py +++ b/src/inventory/schemas/supplier_schemas.py @@ -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) diff --git a/src/inventory/schemas/warehouse_schemas.py b/src/inventory/schemas/warehouse_schemas.py index 90db9d7..c6782e3 100644 --- a/src/inventory/schemas/warehouse_schemas.py +++ b/src/inventory/schemas/warehouse_schemas.py @@ -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) diff --git a/src/inventory/services/dashboard_service.py b/src/inventory/services/dashboard_service.py new file mode 100644 index 0000000..c88cbc1 --- /dev/null +++ b/src/inventory/services/dashboard_service.py @@ -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() diff --git a/src/inventory/services/master_data_service.py b/src/inventory/services/master_data_service.py new file mode 100644 index 0000000..43d3a7a --- /dev/null +++ b/src/inventory/services/master_data_service.py @@ -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() diff --git a/src/inventory/services/material_service.py b/src/inventory/services/material_service.py new file mode 100644 index 0000000..d8c6773 --- /dev/null +++ b/src/inventory/services/material_service.py @@ -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() diff --git a/src/inventory/services/product_service.py b/src/inventory/services/product_service.py new file mode 100644 index 0000000..bd37b33 --- /dev/null +++ b/src/inventory/services/product_service.py @@ -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() diff --git a/src/shared/services/auth_routes.py b/src/shared/services/auth_routes.py index bf3b691..ee02e4b 100644 --- a/src/shared/services/auth_routes.py +++ b/src/shared/services/auth_routes.py @@ -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): diff --git a/src/shared/services/auth_service.py b/src/shared/services/auth_service.py index 0b23714..f652c30 100644 --- a/src/shared/services/auth_service.py +++ b/src/shared/services/auth_service.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 5e2a120..b7bd408 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/test_api_dashboard_service.py b/tests/test_api_dashboard_service.py new file mode 100644 index 0000000..07e7028 --- /dev/null +++ b/tests/test_api_dashboard_service.py @@ -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 diff --git a/tests/test_api_inventory_master_data.py b/tests/test_api_inventory_master_data.py new file mode 100644 index 0000000..a99a4d2 --- /dev/null +++ b/tests/test_api_inventory_master_data.py @@ -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"] == "自动编码仓库" diff --git a/tests/test_api_material_service.py b/tests/test_api_material_service.py new file mode 100644 index 0000000..fbdc458 --- /dev/null +++ b/tests/test_api_material_service.py @@ -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 diff --git a/tests/test_api_product_service.py b/tests/test_api_product_service.py new file mode 100644 index 0000000..735ecf4 --- /dev/null +++ b/tests/test_api_product_service.py @@ -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