Compare commits
35 Commits
e728dcd226
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf65ceed1c | |||
| f87cd8a68e | |||
| eb0a4005e1 | |||
| 73de137779 | |||
| aaaac95b53 | |||
| 4a5dc61a02 | |||
| 021bf311c1 | |||
| f1d6a78f8a | |||
| 9e52c95400 | |||
| 0c69d4c347 | |||
| 62fd22cb31 | |||
| d8d4deffd1 | |||
| 65b33609d6 | |||
| 3615902e8d | |||
| f0110df164 | |||
| d7f92f1816 | |||
| a889671bb5 | |||
| aaf887507f | |||
| b934b737e8 | |||
| cf465d28e2 | |||
| e65dcc2d39 | |||
| 0dfb3c63b1 | |||
| 8f8a7ea00a | |||
| b03431b511 | |||
| 7717c9fa14 | |||
| 2fd1b3da21 | |||
| 4373fafd55 | |||
| a548623ea5 | |||
| 6baa6b0d0a | |||
| 483f158424 | |||
| 505f3591ab | |||
| 2c9ba9d6b3 | |||
| 79441a8a87 | |||
| 64dc85bd14 | |||
| c51e6b793a |
+2
-1
@@ -53,7 +53,8 @@ tmp/
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
deploy/
|
||||
# 注意:deploy/ 不能排除——Dockerfile.moldinsight COPY deploy/requirements-*.txt、
|
||||
# Dockerfile.frontend COPY deploy/nginx/frontend.conf,排除会让干净机器首次构建必挂
|
||||
|
||||
# 其他
|
||||
.trae/
|
||||
|
||||
@@ -7,7 +7,13 @@ HOST=0.0.0.0
|
||||
MOLDINSIGHT_PORT=10003
|
||||
# inventory API 对外端口
|
||||
INVENTORY_PORT=10004
|
||||
# 应用内部监听端口(通常无需修改)
|
||||
# unified 模式下前端 Nginx 对外端口(浏览器入口)
|
||||
FRONTEND_PORT=10003
|
||||
# unified 模式 backend 是否暴露宿主端口:留空 = 不暴露(仅经前端 /api 反代)
|
||||
BACKEND_PORT=
|
||||
# 应用内部监听端口(通常无需修改;uvicorn 命令硬编码 8000/8001)
|
||||
# PORT 与 HOST 仅在直跑 uvicorn 时生效,compose 容器内 uvicorn 不读这两个 env
|
||||
HOST=0.0.0.0
|
||||
PORT=10003
|
||||
# ================================
|
||||
|
||||
@@ -25,15 +31,16 @@ POINTCLOUD_SAMPLE_COUNT=10000
|
||||
MESH_QUALITY=high
|
||||
PARALLEL_PROCESSING=true
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=szcjw
|
||||
# 数据库配置——以部署机实测可达 IP 为准(之前 192.168.3.10 → 192.168.0.11),
|
||||
# 改 IP 后必须重启 backend 才生效
|
||||
DB_HOST=192.168.0.11
|
||||
DB_PORT=5432
|
||||
DB_NAME=moldinsight
|
||||
DB_USER=moldinsight
|
||||
DB_PASSWORD=Qqs1996
|
||||
|
||||
# RustFS 对象存储配置 (S3v4 API)
|
||||
RUSTFS_ENDPOINT=http://szcjw:8010
|
||||
RUSTFS_ENDPOINT=http://192.168.0.11:8010
|
||||
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
|
||||
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
|
||||
RUSTFS_TIMEOUT=30
|
||||
@@ -44,8 +51,8 @@ SECRET_KEY=vGLxbDGj4I3LnWZYQqRrchlVBSWpM73IZ8fT7ldwpsXpDYB82ghkr7sRkO7D-BiR
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=szcjw
|
||||
# Redis配置(与 DB 同 IP)
|
||||
REDIS_HOST=192.168.0.11
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Qqs1996
|
||||
REDIS_DB=0
|
||||
|
||||
+11
-16
@@ -1,22 +1,17 @@
|
||||
# 服务配置
|
||||
HOST=0.0.0.0
|
||||
|
||||
# ================================
|
||||
# 模块 API 对外端口
|
||||
# 端口配置 — DEPLOYMENT.md §1.2 选择 A 为默认(前端独占宿主端口,backend 不暴露)
|
||||
# ================================
|
||||
# 前端 Nginx 对外端口
|
||||
FRONTEND_PORT=80
|
||||
# unified backend 对外端口
|
||||
BACKEND_PORT=8000
|
||||
# gemold(moldinsight)API 对外端口(独立部署时使用)
|
||||
MOLDINSIGHT_PORT=8000
|
||||
# inventory API 对外端口(独立部署时使用)
|
||||
INVENTORY_PORT=8001
|
||||
# 容器内部端口统一 8000(frontend=8000 / backend=8000 / inventory=8001);
|
||||
# 服务间通过 docker 网络 gemold_network 上的服务名(如 backend:8000)互通。
|
||||
# 宿主机端口由本文件强制配置,compose 无默认值兜底:
|
||||
FRONTEND_PORT=10003 # 浏览器入口(必填,缺则 compose 启动期 fail-fast)
|
||||
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署(必填)
|
||||
INVENTORY_PORT=10004 # inventory-only 独立部署(必填)
|
||||
# unified 模式 backend 是否暴露宿主端口:留空 = 不暴露(仅经前端 /api 反代),
|
||||
# 设值(如 10005)= 直接暴露(调试 / 压测用,注意 10003 已被 frontend 占用)
|
||||
BACKEND_PORT=
|
||||
# ================================
|
||||
|
||||
# 应用内部监听端口(通常无需修改;compose 内已固定为 8000/8001)
|
||||
PORT=8000
|
||||
|
||||
DEBUG=false
|
||||
|
||||
# 日志配置
|
||||
@@ -89,7 +84,7 @@ LLM_MODEL=gpt-4o-mini
|
||||
LLM_TIMEOUT=60
|
||||
LLM_MAX_TOKENS=2000
|
||||
|
||||
# Celery/OCC 吞吐调优(可选,默认值见 deploy/Dockerfile.celery;
|
||||
# Celery/OCC 吞吐调优(可选,默认值在 compose 的 ${CELERY_CONCURRENCY:-2};
|
||||
# concurrency 即 OCC 并行分析数,见 docs/topics/performance/OCC_THROUGHPUT.md)
|
||||
# CELERY_CONCURRENCY=2
|
||||
# CELERY_MAX_TASKS_PER_CHILD=50
|
||||
|
||||
@@ -28,19 +28,20 @@
|
||||
- **单数据库是刻意设计**:moldinsight 与 inventory 共享同一 PostgreSQL(如 `STPFile.product_id -> Product.id` 桥接),不拆库。
|
||||
- **接口变更三件套**:优先用 Pydantic 请求模型(少用手写 `request.json()` 解析)→ 重新导出根目录 `openapi.json` → 前端 `npm run gen:api` 重新生成类型。三步缺一即契约漂移。
|
||||
- **历史材料统一进 [docs/archive/](docs/archive/README.md)**,不与当前权威文档混放。
|
||||
- **历史批次详细流水账 / 早段 STATUS**:见 [docs/archive/2026-09_governance_batches.md](docs/archive/2026-09_governance_batches.md) 与 [docs/archive/2026-09_status_history.md](docs/archive/2026-09_status_history.md);主骨架权威文档(TECH_DEBT §2 / STATUS 顶部)只保留摘要。
|
||||
- **配置只走 `.env`**(参照 [.env.example](.env.example) 全键说明):`DB_*`、`SECRET_KEY` 等关键项不设代码兜底(惰性校验,缺失即报),不在代码里给 localhost/弱口令默认值。
|
||||
|
||||
## 3. 代码地图
|
||||
|
||||
```
|
||||
src/
|
||||
entrypoints/ # 独立部署入口(均为 create_app 组装,含 sys.path 修正)
|
||||
entrypoints/ # 独立部署入口(纯组装:sys.path 修正 + create_app + startup_hooks/register_routers)
|
||||
moldinsight.py # moldinsight-only 入口:/api 前缀挂 moldinsight router,端口 8000
|
||||
inventory.py # inventory-only 入口:inventory_router,端口 8001
|
||||
unified.py # 双模块统一入口:/api 挂 moldinsight + inventory,当前推荐后端
|
||||
moldinsight/ # 【模具分析模块】
|
||||
api/
|
||||
__init__.py # router 聚合:ROUTE_MODULES 清单 + _safe_include 挂载,失败登记 route_registry(/api/health 呈现 degraded,DEBUG 下 fail fast);debug_router 仅 settings.DEBUG 挂载
|
||||
__init__.py # router 聚合:ROUTE_MODULES 清单 + _safe_include 挂载,失败登记 route_registry(/api/health 呈现 degraded,DEBUG 下 fail fast);register_moldinsight_routers 入口单点调用(/api 聚合 + HTML 报告根路径挂载);debug_router 仅 settings.DEBUG 挂载
|
||||
route_registry.py # 路由装载注册表(loaded / failed / disabled,health_router 引用)
|
||||
health_router.py # /api/health 模块健康检查(含真实 pythonocc 探测与路由装载状态)
|
||||
upload_router.py # /api/upload STEP/STP 上传
|
||||
@@ -93,15 +94,15 @@ src/
|
||||
models/ # moldinsight 域 ORM(stp_analysis.py:stp_files 及各阶段产物 + processing_tasks,共 9 表)
|
||||
storage/
|
||||
rustfs_storage.py # RustFS/MinIO 客户端封装
|
||||
init_storage.py # 存储初始化
|
||||
init_storage.py # RustFS 连接初始化 + rustfs_startup_hook(入口经 startup_hooks 注入)
|
||||
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/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】
|
||||
app_factory.py # create_app:request_id 日志中间件 / auth_router / /health / SPA fallback / connect_rustfs 开关(D11 后 /html 由 moldinsight 代理路由提供,不再挂本地 StaticFiles)
|
||||
app_factory.py # create_app:纯平台引导(CORS / 请求日志 / /health / SPA fallback / db+Redis 启动);模块专属接线经 startup_hooks 注入(D3 收敛,原 connect_rustfs 已移除)
|
||||
config/settings.py # Settings 单例:dotenv + os.getenv;DB_*/SECRET_KEY 惰性校验无默认
|
||||
database/database.py # async engine / session / get_db_session
|
||||
database/init_db.py # 建表与管理员种子
|
||||
@@ -120,7 +121,7 @@ frontend/ # Vue 3 独立工程:src/modules 按域组织
|
||||
migrations/ # 数据库迁移
|
||||
scripts/ # 一次性迁移与工具脚本(migrations/ 数据迁移、db/ 索引与审计 SQL、tools/ 检查工具),非运行时代码
|
||||
tests/ # pytest:sqlite+aiosqlite 临时库;pythonocc 缺失时 OCC 契约测试自动 skip
|
||||
deploy/ # Dockerfile.* / nginx / build 脚本
|
||||
deploy/ # Dockerfile.* / nginx / build 脚本 / generate_lockfiles.{sh,bat}(D13 锁文件生成入口)
|
||||
docs/ # 权威文档(本文件 §5 导航)
|
||||
```
|
||||
|
||||
|
||||
@@ -63,12 +63,21 @@ npm install
|
||||
推荐先查看部署入口:
|
||||
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
|
||||
|
||||
本地常见方式:
|
||||
本地常见方式(按模式对应不同 compose 文件):
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
# 默认:unified(前端 + 后端 + Celery)
|
||||
docker compose up -d
|
||||
|
||||
# 仅模具分析
|
||||
docker compose -f docker-compose.moldinsight.yml up -d
|
||||
|
||||
# 仅进销存
|
||||
docker compose -f docker-compose.inventory.yml up -d
|
||||
```
|
||||
|
||||
> 镜像首次构建:`bash deploy/build.sh`(build base → backend → frontend 3 个 tag,celery 复用 backend);更新代码后用 `docker compose up -d --build` 重建(裸 `up -d` 不会重建已有镜像)。
|
||||
|
||||
如需直接运行:
|
||||
|
||||
```bash
|
||||
@@ -170,9 +179,15 @@ geMoldInsight/
|
||||
|
||||
## 当前代码入口
|
||||
|
||||
- unified: [src/entrypoints/unified.py](src/entrypoints/unified.py)
|
||||
- moldinsight-only: [src/entrypoints/moldinsight.py](src/entrypoints/moldinsight.py)
|
||||
- inventory-only: [src/entrypoints/inventory.py](src/entrypoints/inventory.py)
|
||||
- 当前 Compose 入口: [docker-compose.yml](docker-compose.yml)
|
||||
|
||||
当前 Compose 入口(一键命令对应文件名):
|
||||
|
||||
- unified: [docker-compose.yml](docker-compose.yml) → `docker compose up -d`
|
||||
- moldinsight-only: [docker-compose.moldinsight.yml](docker-compose.moldinsight.yml) → `docker compose -f docker-compose.moldinsight.yml up -d`
|
||||
- inventory-only: [docker-compose.inventory.yml](docker-compose.inventory.yml) → `docker compose -f docker-compose.inventory.yml up -d`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Celery Worker 与 unified 后端共用同一运行时镜像(自包含,批次 1 起)。
|
||||
# 此前 FROM gemold-moldinsight:latest 与 compose/build.sh 构建的
|
||||
# gemold-backend:latest 不一致,干净环境下 celery 镜像构建必然失败。
|
||||
FROM gemold-backend:latest
|
||||
|
||||
# OCC 并行度伸缩(OCC_THROUGHPUT 方案 A,见 docs/topics/performance/OCC_THROUGHPUT.md):
|
||||
# 每个 prefork 子进程各持一个串行 OCC 通道,concurrency 即并行分析数
|
||||
# (调大时预算好每子进程内存与 PG 连接数);max-tasks-per-child 让子进程
|
||||
# 定期重启,兜底回收 OCC 超时后滞留的线程。可在 compose/.env 覆盖。
|
||||
ENV CELERY_CONCURRENCY=2 \
|
||||
CELERY_MAX_TASKS_PER_CHILD=50
|
||||
|
||||
# sh -c + exec:既支持环境变量替换,又让 celery exec 接管 PID 1 正确接收 SIGTERM
|
||||
CMD ["sh", "-c", "exec celery -A celery_app worker --workdir=/app/src --concurrency=${CELERY_CONCURRENCY:-2} --max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD:-50} --loglevel=info"]
|
||||
@@ -7,7 +7,8 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
# 注意:容器内 nginx listen 改为 8000(避免占用宿主 80;1024+ 无 root 限制)
|
||||
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
# 旧方式(conda 环境装好后把 site-packages 拷入 python:slim 系统 python)依赖
|
||||
# 两侧 Python ABI 恰好兼容,属脆弱做法(TECH_DEBT D13);现改为直接以同一
|
||||
# conda 运行时作为最终镜像的执行环境,自带全部动态库。
|
||||
FROM continuumio/miniconda3:24.7.1-0
|
||||
# Base 用 Miniforge:conda-forge 默认且唯一渠道(无 defaults 渠道,无 Anaconda
|
||||
# ToS 顾虑),与 CI 的 Miniforge 安装(.gitea/workflows/ci.yml)同源;tag 锁定。
|
||||
FROM condaforge/miniforge3:24.7.1-2
|
||||
|
||||
# 锁定几何栈核心版本;pip 侧全量版本锁待首次镜像构建成功后由
|
||||
# `pip freeze > deploy/requirements-moldinsight.lock.txt` 生成(D13 遗留项)
|
||||
# 锁定几何栈核心版本;pip 侧全量版本锁由
|
||||
# `bash deploy/generate_lockfiles.sh` 在 moldinsight conda 环境内执行后生成
|
||||
# (D13,参见 deploy/generate_lockfiles.sh / .bat 与 docs/OPERATIONS.md §2)
|
||||
# 落盘产物为 deploy/requirements-{base,moldinsight}.lock.txt,CI / 离线构建可直接锁定安装
|
||||
RUN conda create -n moldinsight -c conda-forge -y \
|
||||
python=3.12 \
|
||||
pythonocc-core=7.9.0 \
|
||||
|
||||
+5
-18
@@ -11,11 +11,6 @@ echo.
|
||||
echo === 构建统一后端镜像 ===
|
||||
docker build -t gemold-backend:latest -f deploy\Dockerfile.moldinsight .
|
||||
|
||||
echo.
|
||||
echo === 构建 Celery Worker 镜像 ===
|
||||
docker build -t gemold-celery:latest -f deploy\Dockerfile.celery .
|
||||
|
||||
echo.
|
||||
echo.
|
||||
echo === 构建前端镜像 (Nginx 静态站点) ===
|
||||
docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
|
||||
@@ -23,17 +18,9 @@ docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
|
||||
echo.
|
||||
echo === 全部构建完成 ===
|
||||
echo.
|
||||
echo 启动完整系统(前端 + unified backend + celery):
|
||||
echo docker compose --profile full up -d
|
||||
echo 启动 unified 默认栈(前端 + backend + celery):
|
||||
echo docker compose up -d
|
||||
echo.
|
||||
echo 仅启动 unified backend:
|
||||
echo docker compose --profile unified up -d
|
||||
echo.
|
||||
echo 仅启动前端入口:
|
||||
echo docker compose --profile frontend up -d
|
||||
echo.
|
||||
echo 仅启动进销存:
|
||||
echo docker compose --profile inventory up -d
|
||||
echo.
|
||||
echo 仅启动模具分析:
|
||||
echo docker compose --profile moldinsight up -d
|
||||
echo 按文件名切换模式(旧 --profile 写法已失效):
|
||||
echo docker compose -f docker-compose.moldinsight.yml up -d
|
||||
echo docker compose -f docker-compose.inventory.yml up -d
|
||||
|
||||
+5
-17
@@ -13,10 +13,6 @@ echo ""
|
||||
echo "=== 构建统一后端镜像 ==="
|
||||
docker build -t gemold-backend:latest -f deploy/Dockerfile.moldinsight .
|
||||
|
||||
echo ""
|
||||
echo "=== 构建 Celery Worker 镜像 ==="
|
||||
docker build -t gemold-celery:latest -f deploy/Dockerfile.celery .
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "=== 构建前端镜像 (Nginx 静态站点) ==="
|
||||
@@ -25,17 +21,9 @@ docker build -t gemold-frontend:latest -f deploy/Dockerfile.frontend .
|
||||
echo ""
|
||||
echo "=== 全部构建完成 ==="
|
||||
echo ""
|
||||
echo "启动完整系统(前端 + unified backend + celery):"
|
||||
echo " docker compose --profile full up -d"
|
||||
echo "启动 unified 默认栈(前端 + backend + celery):"
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo "仅启动 unified backend:"
|
||||
echo " docker compose --profile unified up -d"
|
||||
echo ""
|
||||
echo "仅启动前端入口:"
|
||||
echo " docker compose --profile frontend up -d"
|
||||
echo ""
|
||||
echo "仅启动进销存:"
|
||||
echo " docker compose --profile inventory up -d"
|
||||
echo ""
|
||||
echo "仅启动模具分析:"
|
||||
echo " docker compose --profile moldinsight up -d"
|
||||
echo "按文件名切换模式(旧 --profile 写法已失效):"
|
||||
echo " docker compose -f docker-compose.moldinsight.yml up -d"
|
||||
echo " docker compose -f docker-compose.inventory.yml up -d"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13,Windows 版)
|
||||
REM ============================================================
|
||||
REM
|
||||
REM 用法:在 gemold conda 环境内执行 deploy\generate_lockfiles.bat
|
||||
REM
|
||||
REM 产物:
|
||||
REM deploy\requirements-base.lock.txt
|
||||
REM deploy\requirements-moldinsight.lock.txt
|
||||
REM ============================================================
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
where conda >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] conda 未安装或不在 PATH,请先激活 conda 环境 ^(推荐 moldinsight^) 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not "%CONDA_DEFAULT_ENV%"=="moldinsight" if not "%CONDA_DEFAULT_ENV%"=="gemold" (
|
||||
echo [WARN] 当前 conda 环境为 '%CONDA_DEFAULT_ENV%',推荐在 'moldinsight' 内执行
|
||||
)
|
||||
|
||||
echo ==^> 生成 base 锁文件
|
||||
pip freeze --exclude pythonocc-core > deploy\requirements-base.lock.txt
|
||||
|
||||
echo ==^> 生成 moldinsight 锁文件
|
||||
pip freeze --exclude pythonocc-core > deploy\requirements-moldinsight.lock.txt
|
||||
|
||||
echo.
|
||||
echo 已生成:
|
||||
echo deploy\requirements-base.lock.txt
|
||||
echo deploy\requirements-moldinsight.lock.txt
|
||||
echo.
|
||||
echo 下一步:提交两个 lock.txt,并按团队策略同步更新 requirements-*.txt 下限。
|
||||
endlocal
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13)
|
||||
# ============================================================
|
||||
#
|
||||
# 设计要点:
|
||||
# - 锁文件必须在构建产出的 conda/minimal 环境里生成(只有 geMoldInsight 依赖 + 基础库),
|
||||
# 本机开发环境的 `pip freeze` 会污染(全开发栈混装),不能直接落锁。
|
||||
# - 仅在带 moldinsight 的 conda 环境内运行 `pip freeze > deploy/requirements-<x>.lock.txt`
|
||||
# 才有意义。
|
||||
# - 锁文件落盘后即可被 CI / 离线构建 / 复现部署直接 `pip install -r` 锁定版本,
|
||||
# 而不再依赖 >= 下限解析。
|
||||
#
|
||||
# 用法(必须在 `gemold` conda 环境内执行):
|
||||
# bash deploy/generate_lockfiles.sh
|
||||
#
|
||||
# 产物:
|
||||
# deploy/requirements-base.lock.txt
|
||||
# deploy/requirements-moldinsight.lock.txt
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v conda >/dev/null 2>&1; then
|
||||
echo "[ERROR] conda 未安装或不在 PATH,请先激活 conda 环境(推荐环境名 moldinsight)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${CONDA_DEFAULT_ENV:-}" != "moldinsight" && "${CONDA_DEFAULT_ENV:-}" != "gemold" ]]; then
|
||||
echo "[WARN] 当前 conda 环境为 '${CONDA_DEFAULT_ENV:-<未激活>}'," \
|
||||
"推荐在 'moldinsight' conda 环境内运行(否则锁文件将含宿主污染)" >&2
|
||||
fi
|
||||
|
||||
echo "==> 生成 base 锁文件"
|
||||
pip freeze --exclude pythonocc-core > deploy/requirements-base.lock.txt
|
||||
|
||||
echo "==> 生成 moldinsight 锁文件"
|
||||
pip freeze --exclude pythonocc-core > deploy/requirements-moldinsight.lock.txt
|
||||
|
||||
echo
|
||||
echo "已生成:"
|
||||
echo " deploy/requirements-base.lock.txt ($(wc -l < deploy/requirements-base.lock.txt) 行)"
|
||||
echo " deploy/requirements-moldinsight.lock.txt ($(wc -l < deploy/requirements-moldinsight.lock.txt) 行)"
|
||||
echo
|
||||
echo "下一步:"
|
||||
echo " 1. 提交这两个 lock.txt(仅含项目直接依赖 + conda-minimal 环境产出)"
|
||||
echo " 2. 同步更新 deploy/requirements-base.txt / requirements-moldinsight.txt 的版本下限" \
|
||||
"为 lock 中的实际版本(或保留 >=,按团队策略)"
|
||||
@@ -3,9 +3,16 @@ upstream gemold_backend_upstream {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 8000;
|
||||
server_name _;
|
||||
|
||||
# 与 .env MAX_FILE_SIZE=104857600(100MB)对齐;nginx 默认 1m 会直接 413
|
||||
client_max_body_size 100M;
|
||||
|
||||
# 大文件上传给后端足够时间(默认 60s,100MB 可能不够)
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# geMoldInsight — inventory-only 模式
|
||||
#
|
||||
# 一键启动:
|
||||
# docker compose -f docker-compose.inventory.yml up -d
|
||||
#
|
||||
# 服务清单:仅 inventory 后端
|
||||
# 不含前端、不含 moldinsight、不含 Celery worker。
|
||||
#
|
||||
# 基础设施:仅依赖 PostgreSQL + Redis;不依赖 RustFS / MinIO 对象存储,
|
||||
# 不挂 uploads/html 命名卷(inventory 无文件分析链路)。
|
||||
# 注意:服务未声明 profiles(避免裸 up 报 "no service selected"),
|
||||
# 模式切换唯一入口是 -f 文件名。
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 公共环境变量(inventory 子集:DB / Redis / 认证 / 启动参数)
|
||||
# ---------------------------------------------------------------
|
||||
x-inventory-env: &inventory_env
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
|
||||
services:
|
||||
inventory:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.inventory
|
||||
image: gemold-inventory:latest
|
||||
container_name: gemold_inventory
|
||||
command: ["python", "-m", "uvicorn", "entrypoints.inventory:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
ports:
|
||||
- "${INVENTORY_PORT}:8001"
|
||||
environment:
|
||||
<<: *inventory_env
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8001"
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
networks:
|
||||
gemold_network:
|
||||
name: gemold_network
|
||||
driver: bridge
|
||||
@@ -0,0 +1,115 @@
|
||||
# geMoldInsight — moldinsight-only 模式
|
||||
#
|
||||
# 一键启动:
|
||||
# docker compose -f docker-compose.moldinsight.yml up -d
|
||||
#
|
||||
# 服务清单:moldinsight(独立 API)+ moldinsight-celery(异步分析 worker)
|
||||
# 不含前端、不含 inventory。
|
||||
# 镜像:两服务共用 gemold-backend:latest(同一 build 声明,compose 只构建一次;
|
||||
# celery 仅以 command 覆盖启动 worker),无跨镜像构建依赖,干净机器裸 up 一把过。
|
||||
# 注意:服务未声明 profiles(避免裸 up 报 "no service selected"),
|
||||
# 模式切换唯一入口是 -f 文件名。
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 公共环境变量(moldinsight 家族共用)
|
||||
# ---------------------------------------------------------------
|
||||
x-base-env: &base_env
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
|
||||
services:
|
||||
moldinsight:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
# 统一用 gemold-backend tag(历史 gemold-moldinsight:latest 双 tag 已废弃)
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_moldinsight
|
||||
command: ["python", "-m", "uvicorn", "entrypoints.moldinsight:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- "${MOLDINSIGHT_PORT}:8000"
|
||||
environment:
|
||||
<<: *base_env
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# uploads_data:D6 过渡兜底,RustFS 异常时本地路径回退
|
||||
# html_data:D11 后仅作 /html 报告代理的存量兜底读(新产物不落本地)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
moldinsight-celery:
|
||||
# 与 moldinsight 共用同一镜像,仅 command 覆盖启动 worker
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_celery
|
||||
# --pool=solo:任务在 celery 主进程内执行。prefork 的 worker 子进程是
|
||||
# daemonic,multiprocessing 禁止守护进程再生子进程,OCC 常驻进程池
|
||||
# (OccProcessPool spawn 拉起)在 prefork 下必报
|
||||
# "daemonic processes are not allowed to have children"(2026-09-26)。
|
||||
# solo 天然单任务串行,与模块级单例(asyncio.Lock 不可跨循环并发)匹配;
|
||||
# 需要吞吐时横向加容器副本,而不是调并发。
|
||||
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --pool=solo --loglevel=info"]
|
||||
environment:
|
||||
<<: *base_env
|
||||
# 旧 CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 已随 --pool=solo 移除:
|
||||
# solo 单进程串行,二者不适用(.env 里残留定义会被 compose 静默忽略)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
# 注:本文件无 backend service,celery 必须等 moldinsight API 就绪后再启动
|
||||
depends_on:
|
||||
- moldinsight
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
networks:
|
||||
gemold_network:
|
||||
name: gemold_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
name: gemold_uploads_data
|
||||
html_data:
|
||||
name: gemold_html_data
|
||||
+85
-175
@@ -1,3 +1,58 @@
|
||||
# geMoldInsight — unified 模式(默认入口)
|
||||
#
|
||||
# 一键启动:
|
||||
# docker compose up -d
|
||||
#
|
||||
# 其他模式(换文件名即可,无需 --profile):
|
||||
# docker compose -f docker-compose.moldinsight.yml up -d
|
||||
# docker compose -f docker-compose.inventory.yml up -d
|
||||
#
|
||||
# 注意:服务均未声明 profiles(compose 规则:声明了 profiles 的服务在
|
||||
# 不带 --profile 时不会被选中,裸 up 会报 "no service selected"),
|
||||
# 旧 --profile 写法不再是模式开关,模式切换唯一入口是 -f 文件名。
|
||||
#
|
||||
# 服务清单:frontend + backend(unified 入口) + moldinsight-celery
|
||||
# 镜像:gemold-backend / gemold-frontend(celery 复用 gemold-backend,仅 command 不同)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 公共环境变量(moldinsight 家族 backend/celery/moldinsight 共用)
|
||||
# 通过 anchor 在本文件内复用,避免 environment 块重复
|
||||
# ---------------------------------------------------------------
|
||||
x-base-env: &base_env
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
# SECRET_KEY 必须在 .env 中显式配置,否则 compose 直接失败
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
@@ -6,13 +61,11 @@ services:
|
||||
image: gemold-frontend:latest
|
||||
container_name: gemold_frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-80}:80"
|
||||
# 浏览器入口端口(无默认值:必须由 .env 中 FRONTEND_PORT 显式配置)
|
||||
- "${FRONTEND_PORT}:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- full
|
||||
- frontend
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
@@ -23,47 +76,17 @@ services:
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_backend
|
||||
command: ["python", "-m", "uvicorn", "entrypoints.unified:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 宿主机端口由 .env 的 BACKEND_PORT 决定:空/不设则不暴露宿主机端口
|
||||
# (仅经前端 /api 反代同域访问,避免与 frontend 抢占宿主 10003)。
|
||||
# 调试时设 BACKEND_PORT=10003 即可独立访问。
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
- "${BACKEND_PORT:-}:8000"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
<<: *base_env
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# 共享卷过渡兜底(D6/D11):主链路已改走 RustFS。uploads 供 RustFS 异常时
|
||||
# 本地路径回退;html_output 仅作 /html 报告代理的存量兜底读(新产物不落本地)
|
||||
@@ -71,53 +94,33 @@ services:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- full
|
||||
- unified
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
moldinsight-celery:
|
||||
# 与 backend 共用同一镜像(同一 build 声明 + 同一 tag,compose 只构建一次),
|
||||
# 仅以 command 覆盖启动 worker——消除旧 Dockerfile.celery(已删)FROM
|
||||
# gemold-backend 在并行构建下的"镜像尚不存在"陷阱,干净机器裸 up 一把过
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.celery
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_celery
|
||||
# cd /app/src 是必须的:celery_app / celery_tasks 是 src/ 的子模块,
|
||||
# 而其 import 用的是裸模块名(celery_app.py 内 include=["celery_tasks"]),
|
||||
# 所以 worker 必须从 src/ 目录启动;不依赖 celery 版本是否支持 --workdir
|
||||
# --pool=solo:任务在 celery 主进程内执行。prefork 的 worker 子进程是
|
||||
# daemonic,multiprocessing 禁止守护进程再生子进程,OCC 常驻进程池
|
||||
# (OccProcessPool spawn 拉起)在 prefork 下必报
|
||||
# "daemonic processes are not allowed to have children"(2026-09-26)。
|
||||
# solo 天然单任务串行,与 processing_service / redis_task_manager /
|
||||
# db_manager 等模块级单例(其 asyncio.Lock 不可跨循环并发)也匹配;
|
||||
# 需要吞吐时横向加容器副本,而不是调并发。
|
||||
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --pool=solo --loglevel=info"]
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
# Celery/OCC 吞吐调优(OCC_THROUGHPUT 方案 A,默认值在 Dockerfile.celery)
|
||||
CELERY_CONCURRENCY: ${CELERY_CONCURRENCY:-2}
|
||||
CELERY_MAX_TASKS_PER_CHILD: ${CELERY_MAX_TASKS_PER_CHILD:-50}
|
||||
<<: *base_env
|
||||
# 旧 CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 已随 --pool=solo 移除:
|
||||
# solo 单进程串行,二者不适用(.env 里残留定义会被 compose 静默忽略)
|
||||
# uploads_data 共享卷(D6 过渡兜底):RustFS 异常时 worker 回退本地路径下载。
|
||||
# D11 后 worker 不再写 HTML 产物(直传 RustFS 报告键),无需 html_data 卷
|
||||
volumes:
|
||||
@@ -125,110 +128,17 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- full
|
||||
- unified
|
||||
- moldinsight
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
moldinsight:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
image: gemold-moldinsight:latest
|
||||
container_name: gemold_moldinsight
|
||||
ports:
|
||||
- "${MOLDINSIGHT_PORT:-8000}:8000"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# html_output 仅作 /html 报告代理的存量兜底读(D11,新产物不落本地)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- moldinsight
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
inventory:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.inventory
|
||||
image: gemold-inventory:latest
|
||||
container_name: gemold_inventory
|
||||
ports:
|
||||
- "${INVENTORY_PORT:-8001}:8001"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8001"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- inventory
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
networks:
|
||||
gemold_network:
|
||||
# 固定 name,便于跨 compose 文件调试时容器互通(如 inventory-only 与 unified 临时联调)
|
||||
name: gemold_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
name: gemold_uploads_data
|
||||
html_data:
|
||||
name: gemold_html_data
|
||||
@@ -54,6 +54,7 @@
|
||||
| 加工 | `/api/design-cam`、`/api/check-collision`、`/api/optimize-toolpath`、`/api/design-electrodes`、`/api/simulate-machining` | machining_router.py |
|
||||
| 导出 | `/api/export-mold`、`/api/export-download/{filepath}`、`/api/export-recommendations` | export_router.py |
|
||||
| 铝价(模拟数据) | `/api/aluminum-price/current`、`/api/aluminum-price/history` | aluminum_price_routes.py |
|
||||
| 老师傅经验反馈(D17) | `/api/tasks/{task_id}/experience-feedback`(写入:需登录 + 任务归属 + `feedback_experience_hint` 权限;body 含 `scheme_id` / `feedback_status ∈ {adopted, adjust, rejected}` / 可选 `feedback_reason` / `adjust_suggestion` / 上下文快照;写完调用 `TaskQueryService.invalidate_task_view`);`/api/tasks/{task_id}/experience-hints`(读取:需登录 + 任务归属;返回同 stp_file_id + material_family + is_foam 锚定的历史 hints 聚合,按 scheme_axis 分组,含 adopted/rejected/adjust 计数 + 加权 confidence + sample_count + 回显 fingerprint) | experience_feedback_router.py |
|
||||
| 健康检查 | `/api/health`(有路由装载失败时 `status: degraded` 并列出失败清单;`pythonocc` 为真实探测) | health_router.py |
|
||||
| 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py |
|
||||
|
||||
|
||||
+88
-4
@@ -75,8 +75,8 @@ geMoldInsight 的目标架构不是微服务,也不是继续维持历史单体
|
||||
- 应用工厂与共用中间件
|
||||
|
||||
说明:
|
||||
- `shared` 当前仍是“共享平台层 + 历史耦合区”的混合体
|
||||
- 后续会继续向更清晰的 platform 语义收敛,但当前仓库结构仍以 `shared` 为事实名称
|
||||
- `shared` 的定位已明确为**平台层(跨模块基础能力)**;模块专属接线(RustFS 启动 / HTML 报告挂载 / 路由聚合)已收敛回模块层(见 §6.2),当前仓库结构仍以 `shared` 为事实名称
|
||||
- 剩余语义收敛(identity 平台表 vs 模块表的命名与注释口径)随实际重构继续推进
|
||||
|
||||
---
|
||||
|
||||
@@ -191,14 +191,98 @@ geMoldInsight/
|
||||
|
||||
跨模块只允许裸 FK(规则见 §5.1);全量模型注册点收敛为 `migrations/env.py` 与 `tests/conftest.py`;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定(含单模块独立 mapper 配置与旧模块无 facade 断言)。
|
||||
|
||||
### 6.2 app factory 组合职责偏重
|
||||
### 6.2 app factory 组合职责 —— 已收敛(2026-09-18,D3 剩余)
|
||||
|
||||
当前 [src/shared/app_factory.py](../src/shared/app_factory.py) 仍承担较多平台与模块组合职责,是后续平台层收敛的重点。
|
||||
平台层与模块专属接线的边界已明确(`shared` = 跨模块基础能力,模块专属接线归属模块层):
|
||||
|
||||
- **平台工厂 [src/shared/app_factory.py](../src/shared/app_factory.py) 只做纯平台引导**:CORS / 请求日志 / 目录准备 / 静态托管 / 数据库与 Redis 启动 / auth 路由 / /health / SPA fallback。`startup_hooks` 参数承载模块专属启动接线——原 `connect_rustfs` 参数(平台工厂持有 moldinsight 依赖)已移除。
|
||||
- **moldinsight 专属接线收敛回 moldinsight 层**:
|
||||
- RustFS 启动 → [init_storage.py](../src/moldinsight/storage/init_storage.py) 的 `rustfs_startup_hook`(moldinsight/unified 入口经 `startup_hooks` 注入)
|
||||
- /api 路由聚合 + HTML 报告根路径挂载 → [moldinsight/api/__init__.py](../src/moldinsight/api/__init__.py) 的 `register_moldinsight_routers`(入口只做单点调用,不再重复 include html_report)
|
||||
- **入口 [src/entrypoints/](../src/entrypoints/) 退化为纯组装**:sys.path 修正 + 调 create_app + 传 startup_hooks / register_routers。
|
||||
|
||||
### 6.3 文档与结构尚未完全同步
|
||||
|
||||
代码结构已明显模块化,但历史文档中仍保留不少阶段性叙述、旧部署语义与重复说明,这也是本轮文档整理要解决的问题之一。
|
||||
|
||||
### 6.4 D17 Human-in-Loop 老师傅经验反馈闭环 —— 已完成(2026-09-23~24,3 个 commit)
|
||||
|
||||
算法演进由老师傅经验驱动:通过方案级整体反馈(采纳 / 建议调整 / 拒绝)按"产品指纹 + 工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费老师傅沉淀的经验。这是少数"算法层由用户在线学习样本持续校准"的端到端闭环。
|
||||
|
||||
**端到端数据流**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 老师傅在 ResultView 点 👍 老师傅反馈按钮 │
|
||||
│ → HumanFeedbackDialog 三选一(采纳 / 建议调整 / 拒绝) │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ POST /api/tasks/{id}/experience-feedback
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ experience_feedback_router (src/moldinsight/api/) │
|
||||
│ - ensure_task_access 归属校验 │
|
||||
│ - current_user.has_permission("feedback_experience_hint") │
|
||||
│ - service.record_feedback (flush; commit + invalidate) │
|
||||
└──────────────────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ experience_feedback_service.record_feedback │
|
||||
│ - compute_fingerprint (bbox_aspect / volume_bucket / │
|
||||
│ face_bucket / undercut_class / material_family / is_foam) │
|
||||
│ - 写 experience_feedback 表(D9 边界 / D17 衰减 90d TTL) │
|
||||
│ - 同 stp_file_id 整体续期 expires_at │
|
||||
└──────────────────────────────┬───────────────────────────────┘
|
||||
│ 同 stp_file_id 上传新 STP 自动消费
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ processing_service._step_generate_cavity │
|
||||
│ - experience_feedback_service.resolve_for_process_params │
|
||||
│ → hints (List[{scheme_axis, weight, sample_count, ...}]) │
|
||||
│ - hints 装进 run_occ payload 顶层 experience_hints │
|
||||
└──────────────────────────────┬───────────────────────────────┘
|
||||
│ OCC 子进程(spawn 隔离)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ occ_worker._op_generate_cavity │
|
||||
│ - payload.get("experience_hints") or {} → planner.generate_plan(hints=...) │
|
||||
└──────────────────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ MultiSchemeMoldPlanner.generate_plan(..., hints=None) │
|
||||
│ - candidate_generator.generate_candidates(..., hints) │
|
||||
│ * priority_score += weight × 20 │
|
||||
│ * sample_count ≥ 2 + weight ≥ 0.5 → method="human_experience_primary" │
|
||||
│ - scheme_scorer.score_schemes(schemes, *, hints) │
|
||||
│ * score_breakdown["human_hint_bonus"] = weight × 12 │
|
||||
│ (sample_count < 2 时 ×0.5 折半) │
|
||||
│ - global_summary.applied_hints 注入返回 │
|
||||
└──────────────────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ResultView 渲染: │
|
||||
│ - summary-header 加 t-tag theme="success" 📚 历史经验 N 条 │
|
||||
│ - 反馈提交后 onFeedbackSubmitted → loadExperienceHints 即刷 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**硬规则遵守**:
|
||||
|
||||
- 跨模块 FK 仍守 §5.1(`experience_feedback.user_id` / `processing_task_id` / `stp_file_id` 全用字符串表名,无 ORM relationship)
|
||||
- OCC 跨进程守 [occ_worker.py:7-8](../src/moldinsight/core/occ_worker.py#L7-L8) "杜绝 pickle OCC 对象"——payload 普通 dict 透传
|
||||
- D9 边界不破:service.flush + 路由 commit(无 service 内 commit)
|
||||
- 现有 `init_db.py` 幂等修复:按 code 补登权限/角色
|
||||
|
||||
**重量级约束**:
|
||||
|
||||
- `weight = max(0, (adopted-rejected)/total)`:仅正向有效,老师傅拒绝不"扣分"老算法
|
||||
- `sample_count < 2` 时 bonus ×0.5:信号不足折半,但 priority_score 仍加成(候选方向仍偏向)
|
||||
- 解析失败回退空 list:graceful,主流程不因下游错误退化
|
||||
- `canGiveFeedback` 角色门控:`is_superuser || roles 含 process_engineer`
|
||||
|
||||
**测试基线**:192 passed, 13 skipped(批 3 净增 +4 OCC-gated:candidate_generator 3 / scheme_scorer 4 / multi_scheme_planner 2 / processing_service 2);前端 vue-tsc + vite 通过。
|
||||
|
||||
详见 [TECH_DEBT.md](TECH_DEBT.md) D17 + [STATUS.md](STATUS.md) 2026-09-23~24 日志。
|
||||
|
||||
---
|
||||
|
||||
## 7. 专题文档与主骨架的关系
|
||||
|
||||
+78
-2
@@ -16,8 +16,54 @@
|
||||
- 前端同域反代可以面对单一 backend
|
||||
- 比按路径把前端网关分流到两套后端更易维护
|
||||
|
||||
当前 Compose 入口:
|
||||
- [docker-compose.yml](../docker-compose.yml)
|
||||
### 1.1 一键 Compose 部署(按文件名切换模式)
|
||||
|
||||
项目按"模式 ↔ Compose 文件"一一对应的方式支持一键部署。换文件名即可换模式:
|
||||
|
||||
| 模式 | Compose 文件 | 一键命令 |
|
||||
|---|---|---|
|
||||
| **unified**(默认) | [docker-compose.yml](../docker-compose.yml) | `docker compose up -d` |
|
||||
| moldinsight-only | [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) | `docker compose -f docker-compose.moldinsight.yml up -d` |
|
||||
| inventory-only | [docker-compose.inventory.yml](../docker-compose.inventory.yml) | `docker compose -f docker-compose.inventory.yml up -d` |
|
||||
|
||||
> **模式切换唯一入口是 `-f` 文件名**。各 service 均未声明 `profiles`(compose 规则:声明了 profiles 的服务在不带 `--profile` 时不会被选中,裸 `up` 会报 `no service selected`);历史 `--profile full/moldinsight/inventory` 写法随本次拆分失效,请统一改用上表命令。
|
||||
|
||||
### 1.2 宿主机端口约定(默认 = 选择 A)
|
||||
|
||||
部署约定:**unified 模式下前端独占宿主端口,backend 不暴露宿主端口**——浏览器始终只面对一个源,由前端 Nginx 同域反代到 backend,彻底消除 CORS。
|
||||
|
||||
```env
|
||||
# .env(unified 模式最小集)
|
||||
FRONTEND_PORT=10003 # 浏览器入口;前端 Nginx 容器监听 8000,反代 /api 到 backend:8000
|
||||
# BACKEND_PORT 留空或不设 → backend 仅在 docker 网络 gemold_network 内被前端反代访问
|
||||
```
|
||||
|
||||
端口链路:
|
||||
|
||||
```
|
||||
浏览器 → http://宿主机:10003 → frontend容器:8000 → /api/* → backend容器:8000
|
||||
(宿主机 10003) (docker 网络内)
|
||||
```
|
||||
|
||||
何时选 B(前后端都暴露宿主端口):临时直连后端调试、压测、k8s 健康检查等特殊场景。设 `BACKEND_PORT=10005`(避开 10003)后重启 compose 即可——**不建议在常规生产部署中使用**,会引入 CORS 与攻击面问题。
|
||||
|
||||
宿主机端口映射由 `.env` 强制配置,compose 无默认值兜底(缺配置时启动期 fail-fast)。详见 [.env.example §端口配置](../.env.example)、[docs/deployment/PORT_CONFIG.md](deployment/PORT_CONFIG.md)。
|
||||
|
||||
### 1.3 镜像构建
|
||||
|
||||
首次部署或更新代码后先构建,再 `up`:
|
||||
|
||||
```bash
|
||||
bash deploy/build.sh # 显式构建 base / backend / frontend 3 个镜像(celery 复用 backend)
|
||||
```
|
||||
|
||||
或让 compose 构建:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
> **注意**:`docker compose up -d` 在本地已有同名镜像(如 `gemold-backend:latest`)时**不会自动重建**,会直接复用旧镜像启动。更新代码或 Dockerfile 后,必须 `docker compose build` 或 `docker compose up -d --build` 才会生效。
|
||||
|
||||
详细 Linux 部署步骤:
|
||||
- [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md)
|
||||
@@ -35,6 +81,16 @@
|
||||
- 测试/集成环境
|
||||
- 小团队统一部署
|
||||
|
||||
**Compose 文件**:[docker-compose.yml](../docker-compose.yml)(**默认入口**)
|
||||
|
||||
**一键命令**:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
包含服务:`frontend` + `backend`(unified 入口)+ `moldinsight-celery`。
|
||||
|
||||
### 2.2 moldinsight-only
|
||||
|
||||
只部署模具分析后端。
|
||||
@@ -43,6 +99,16 @@
|
||||
- 独立开放分析能力
|
||||
- 异步任务与文件处理独立扩容
|
||||
|
||||
**Compose 文件**:[docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml)
|
||||
|
||||
**一键命令**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.moldinsight.yml up -d
|
||||
```
|
||||
|
||||
包含服务:`moldinsight`(独立 API)+ `moldinsight-celery`(异步 worker)。
|
||||
|
||||
### 2.3 inventory-only
|
||||
|
||||
只部署进销存后端。
|
||||
@@ -51,6 +117,16 @@
|
||||
- 独立部署 ERP / 库存能力
|
||||
- 与 moldinsight 分开发布节奏
|
||||
|
||||
**Compose 文件**:[docker-compose.inventory.yml](../docker-compose.inventory.yml)
|
||||
|
||||
**一键命令**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.inventory.yml up -d
|
||||
```
|
||||
|
||||
包含服务:仅 `inventory`。不挂任何命名卷(inventory 无文件分析链路),不依赖 RustFS / MinIO。
|
||||
|
||||
部署模式的结构含义见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
---
|
||||
|
||||
+30
-7
@@ -9,7 +9,7 @@
|
||||
|
||||
- 配置统一走**环境变量**,代码侧由 [src/shared/config/settings.py](../src/shared/config/settings.py) 的 `Settings` 单例经 `dotenv` + `os.getenv` 读取。
|
||||
- **本地运行**:仓库根 `.env`(`load_dotenv()` 自动加载;不在仓库内,参照 [.env.example](../.env.example) 复制编辑)。
|
||||
- **Compose 运行**:compose 文件用 `${VAR}` 从同目录 `.env` 注入容器环境变量(见 [docker-compose.yml](../docker-compose.yml))。
|
||||
- **Compose 运行**:compose 文件用 `${VAR}` 从同目录 `.env` 注入容器环境变量;按模式对应不同文件名(见 [DEPLOYMENT.md §1.1](DEPLOYMENT.md))。
|
||||
- **键值约定**:
|
||||
- `DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD`:**惰性校验、无代码默认**——缺失时 import 不报错(便于测试/静态分析),真正连库时才失败。生产必须显式配置。
|
||||
- `AUTO_MIGRATE`:应用启动时是否自动执行 alembic 迁移,默认 `true`(单机开发语义);**多副本 / 容器编排部署应设 `false`**,改由部署流程单点执行 `alembic upgrade head` 或 `python -m shared.database.init_db`(迁移脚本已随镜像分发于 `/app/migrations/`)。
|
||||
@@ -26,10 +26,24 @@
|
||||
## 2. 安装与环境
|
||||
|
||||
- 后端依赖:`pip install -r requirements.txt`。
|
||||
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
|
||||
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold` 或 `moldinsight`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
|
||||
- 前端:`cd frontend && npm install`。
|
||||
- 数据库迁移:`migrations/`(`alembic.ini` 在仓库根;2026-09-16 由 `alembic/` 改名——原目录名与 alembic 包重名,应用内 import 会被遮蔽导致启动期迁移静默失败);数据修复类一次性脚本在 `scripts/migrations/` 与 `scripts/db/`,**不是运行时代码**,勿在服务内引用。
|
||||
|
||||
### 2.1 pip 锁文件生成(D13 流程)
|
||||
|
||||
`deploy/requirements-{base,moldinsight}.lock.txt` 是项目依赖的**版本锁**,由 conda 环境首次构建成功后一次性落盘:
|
||||
|
||||
- **生成时机**:在 `moldinsight` / `gemold` conda 环境(仅含项目依赖 + conda 基础库,**不能**在混装全开发栈的本机 pip 环境跑)执行 `pip freeze`
|
||||
- **生成命令**:
|
||||
- Linux / macOS:`bash deploy/generate_lockfiles.sh`
|
||||
- Windows:`deploy\generate_lockfiles.bat`
|
||||
- **产物**:
|
||||
- `deploy/requirements-base.lock.txt`
|
||||
- `deploy/requirements-moldinsight.lock.txt`
|
||||
- **消费方**:CI、离线构建、生产复现部署;`pip install -r deploy/requirements-base.lock.txt` 可直接锁定安装而不依赖 `>=` 解析
|
||||
- **提交策略**:两个 lock.txt 提交到仓库;版本下限(`requirements-{base,moldinsight}.txt`)按团队策略同步或保留 `>=` 灵活解析
|
||||
|
||||
## 3. 本地启动
|
||||
|
||||
后端三入口(均含 sys.path 修正,可从仓库根直接跑):
|
||||
@@ -43,7 +57,7 @@ uvicorn src.entrypoints.moldinsight:app --reload --host 0.0.0.0 --port 8000
|
||||
uvicorn src.entrypoints.inventory:app --reload --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与 [deploy/Dockerfile.celery](../deploy/Dockerfile.celery) CMD 同参):
|
||||
Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与 compose 中 `moldinsight-celery` 的 `command:` 覆盖同参——worker 与后端共用 `gemold-backend` 镜像,无独立 Dockerfile):
|
||||
|
||||
```bash
|
||||
cd src && celery -A celery_app worker --concurrency=2 --loglevel=info
|
||||
@@ -64,13 +78,22 @@ npm run gen:api # 从根目录 openapi.json 重新生成 src/types/api.ts(
|
||||
|
||||
## 4. Docker Compose
|
||||
|
||||
按"模式 ↔ 文件名"一一对应:
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d # frontend + unified backend + moldinsight-celery(推荐)
|
||||
docker compose --profile moldinsight up -d # moldinsight 单模块栈
|
||||
docker compose --profile inventory up -d # inventory 单模块栈
|
||||
# unified(默认;frontend + backend + moldinsight-celery)
|
||||
docker compose up -d
|
||||
|
||||
# moldinsight-only(moldinsight + moldinsight-celery)
|
||||
docker compose -f docker-compose.moldinsight.yml up -d
|
||||
|
||||
# inventory-only(仅 inventory)
|
||||
docker compose -f docker-compose.inventory.yml up -d
|
||||
```
|
||||
|
||||
- 镜像构建:`deploy/build.bat` / `deploy/build.sh`(base → 各服务镜像,见 `deploy/Dockerfile.*`)。
|
||||
> 旧 `--profile` 写法已失效(服务不再声明 profiles);模式切换唯一入口是 `-f` 文件名。
|
||||
|
||||
- 镜像构建:`bash deploy/build.sh`(base → backend → frontend 3 个 tag,celery 复用 backend 镜像);首次部署或更新代码后必须先 build(或 `docker compose up -d --build`)——裸 `up` 对本地已有同名镜像**不会自动重建**。
|
||||
- PostgreSQL / Redis / RustFS 通常**复用服务器已有服务**,不由项目 compose 自带;容器只注入连接配置。
|
||||
|
||||
## 5. 运行时硬性要求
|
||||
|
||||
@@ -46,6 +46,7 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
重点方向:
|
||||
|
||||
- ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成)
|
||||
- ~~D17 Human-in-Loop 老师傅经验反馈~~(2026-09-23~24 完成,3 个 commit:数据 + 权限 + 写入 API / 算法接缝 + OCC payload / 前端按钮 + Dialog + 经验角标;写入即消费闭环通;详见 [TECH_DEBT.md](TECH_DEBT.md) D17)
|
||||
- 模具分析链路的结构继续收口
|
||||
- OCC 依赖场景下的契约测试/集成测试继续补齐
|
||||
|
||||
@@ -62,6 +63,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 主线四:部署与运维一致性
|
||||
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
|
||||
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
|
||||
> 早期条目(2026-09-17 之前)已精简为锚点,完整流水见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 与 [archive/2026-09_status_history.md](archive/2026-09_status_history.md)。
|
||||
|
||||
> 2026-09-27(**分模方案预览契约修复:关键工艺参数面板整片 N/A + 备选方案预览无法切换**——多方案重构后 `cavity_data` 与可视化端的字段契约断裂,两处收口。① **legacy 契约桥接**:新增 `CalculationService.attach_scheme_info_contract`([calculation_service.py](../src/moldinsight/services/calculation_service.py)),多方案规划器([multi_scheme_planner.py](../src/moldinsight/core/multi_scheme_planner.py) `_build_scheme`)生成 `cavity_data` + `key_info` 后立即把方案级 `key_info` 内嵌回 `cavity_data`(`mold_cavities.cavity_key_info` + `cavity_count` + `manufacturing_info.mold_material / mold_hardness / surface_finish / estimated_cycle_time / parting_line_length`)——3D 预览"关键工艺参数"面板([html_generator.py](../src/shared/utils/html_generator.py) `updateInfoPanel` / `updateSummaryPanels`)只拿得到 `cavity_data`,多方案重构后这些字段仅存在于 `scheme.key_info`,面板 10 项 8 项恒 N/A、前端结果页"型腔数"恒回退 1 腔(根因佐证:`_strip_heavy_geometry` 专门保留 `mold_cavities.cavity_key_info`,说明预览契约仍是 legacy 结构,多方案生成器未遵守);② **方案级预览恢复**:[processing_service.py](../src/moldinsight/services/processing_service.py) `_attach_scheme_previews` 由"仅最优方案生成完整 HTML + 其余方案只生成 summary JSON(前端零消费死产物 `summary_file`,已删除)"改为**每方案生成完整预览**(HTML + `_summary.json` + `_data.json` 三件直传 RustFS 报告键)并写 `scheme["html_file"] = /html/{name}`——前端 ResultView 预览取 `selectedScheme?.html_file`,此前后端从不写方案级 `html_file`,切方案 iframe `:key` 不变,备选方案永远显示推荐方案预览;任务级 HTML 复用推荐方案预览不再重复生成(`save_html_file` 要求本地文件存在,`_attach_scheme_previews` 返回推荐方案预览本地路径,无任何方案 `cavity_data` 时回退任务级单份生成);③ 前端 [ResultView.vue](../frontend/src/modules/moldinsight/ResultView.vue) 3D 预览标题栏加"当前:方案名" t-tag(多方案时渲染),切换有明确反馈。**已知取舍**:每方案预览复制一份网格数据 JSON,RustFS 报告键存储随方案数线性增长,换备选方案真实独立分模预览(存储吃紧时演进共享产品网格 + 方案差分,见 [TECH_DEBT.md](TECH_DEBT.md) D11 追记)。**历史任务不回填**:已入库任务缺字段与每方案预览文件,需重新分析才能看到完整参数与切换预览。**接口面零变化**(无路由/Pydantic schema 变更,`scheme.html_file` 为 `/api/status/{task_id}` 自由 dict 内新增字段,openapi.json 不触发重导出)。**测试基线**:**193 passed, 13 skipped**(净增 1:`test_calculation_service_bridges_scheme_info_contract`,OCC-free 直测契约桥接);前端 `vue-tsc -b` 通过。**说明**:本地 pip 环境无 pythonocc,`multi_scheme_planner` 运行时路径未实跑(py_compile 通过;core→services import 模式与 `aluminum_foam_mold` 引 `MaterialService` 同款,无循环依赖),OCC 全量验证待 conda 环境补跑。)
|
||||
>
|
||||
> 2026-09-26(**端口默认值统一 10003 / 10004**:约定"容器内部端口无所谓,重要的是映射到宿主机的端口;前端页面 = 10003"。① [.env.example](../.env.example) 端口段重写:移除冗余的 `HOST` / `PORT`(uvicorn 命令硬编码,未读取)+ 移除误导性的"应用内部监听端口"注释;新增端口段约定(`FRONTEND_PORT=10003` 浏览器入口、`BACKEND_PORT=10003` 同端口供调试直连、`MOLDINSIGHT_PORT=10003` / `INVENTORY_PORT=10004` 独立模式);② [docker-compose.yml](../docker-compose.yml) frontend 默认端口回退 `80→10003`、backend 默认 `8000→10003`、删除 backend service 内冗余的 `HOST/PORT` env(uvicorn `--host/--port` 已是单一事实源,env 无代码读);③ [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) / [docker-compose.inventory.yml](../docker-compose.inventory.yml) `MOLDINSIGHT_PORT/INVENTORY_PORT` 默认 `8000/8001→10003/10004`;④ [docs/deployment/DEPLOY_PORT.md](../docs/deployment/DEPLOY_PORT.md) §3 / [docs/deployment/PORT_CONFIG.md](../docs/deployment/PORT_CONFIG.md) §1 §2 端口映射示例同步。**验证**:yaml 渲染后端口映射 `[unified] frontend 10003→80 / backend 10003→8000`、`[moldinsight] 10003→8000`、`[inventory] 10004→8001`,与约定一致。**遗留**:服务器 `.env` 与新版 `.env.example` 对齐(已有字段名一致,仅注释差异,不需要重设值)。
|
||||
>
|
||||
> 2026-09-26(**Compose 拆分部署机端到端复验:3 个收尾修复 + 1 处文档澄清**——① `--workdir` 误用修复:[docker-compose.yml](../docker-compose.yml) / [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) 中 `moldinsight-celery` 的 `command:` 原照搬旧 Dockerfile.celery 的 `celery worker --workdir=/app/src ...`,celery 5.x 已移除 `--workdir` 选项(部署机实测报 `No such option '--workdir'`),改为 `cd /app/src && exec celery -A celery_app worker ...`——celery_app.py 内 `include=["celery_tasks"]` 为裸模块名,必须在 `src/` 下启动 worker,与是否支持 `--workdir` 解耦,跨 celery 版本稳定;② **裸 `up` 不重建已有镜像**澄清:部署机复用旧 gemold-backend 镜像起容器(旧 miniconda base + 旧代码),`docker compose up -d` 仅在本地无同名镜像时构建,DEPLOYMENT §1.2 / README / OPERATIONS §4 / LINUX_SETUP §11 同步补一句"更新代码后须 `up -d --build` 或先 `docker compose build`";③ build.sh 步骤由"四步(base→backend→celery→frontend)"修正为"三步(base→backend→frontend,celery 复用 backend 镜像)"——`Dockerfile.celery` 早已删除但 build.sh 与 README 的描述未跟改,三处文档统一收口。**遗留**:服务器 `docker compose up -d --build` 重建验证新 base + 新 celery 启动命令端到端可用。)
|
||||
>
|
||||
> 2026-09-24(**Compose 按部署模式拆分为三个一键文件 + 文档全量同步**:① 单文件 profile 编排拆为"模式 ↔ 文件名"一一对应的三文件——[docker-compose.yml](../docker-compose.yml)(unified 默认入口:frontend + backend + moldinsight-celery,`docker compose up -d` 即起)+ [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml)(moldinsight-only:独立 API + celery)+ [docker-compose.inventory.yml](../docker-compose.inventory.yml)(inventory-only:仅 inventory,不声明任何命名卷避免空卷);② **服务不再声明 `profiles`**——compose 规则是声明了 profiles 的服务在裸 `up` 下不会被选中(拆分首版保留 profiles 导致裸 `up` / 裸 `-f` 均报 `no service selected`,部署机实测暴露后移除),模式切换唯一入口是 `-f` 文件名,历史 `--profile full/moldinsight/inventory` 写法随拆分失效(其目标服务本就已移出默认文件,兼容无意义);③ **顺手修复两个既有部署隐患**——moldinsight-only 场景 celery 的 `depends_on` 悬空(原指向被 profile 过滤掉的 `backend`,现各文件内分别指向 `backend` / `moldinsight`),以及 `gemold-moldinsight:latest` 与 `gemold-backend:latest` 双 tag 漂移(moldinsight service 的 image 统一为 `gemold-backend:latest`,与 [Dockerfile.celery](../deploy/Dockerfile.celery) 的 FROM 对齐,干净环境单跑 moldinsight-only 不再构建失败);④ `gemold_network` / `uploads_data` / `html_data` 加 `name:` 固定命名,跨文件 / 跨模式可复用;每文件内部以 YAML anchor(`x-base-env`)收敛 35+ 行重复 environment,`SECRET_KEY` / `ADMIN_PASSWORD` 的 `${VAR:?}` fail-fast 校验保留;⑤ 文档同步 11 文件:[DEPLOYMENT.md](DEPLOYMENT.md) §1.1 新增一键部署总表 + §2 三模式各附文件名与一键命令,[deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md) §6/§11 重写,[README.md](../README.md) 快速开始与 Compose 入口、[OPERATIONS.md](OPERATIONS.md) §4、[deploy/build.sh](../deploy/build.sh) / [.bat](../deploy/build.bat) 末尾提示、PORT_CONFIG / DEPLOY_PORT / STORAGE_SETUP / frontend/README 链接全部对齐(`AGENTS.md` §4.1 部署方式→DEPLOYMENT 同步规则满足);⑥ **部署机首次实测再暴露并修复两个干净机器构建必挂点**——(a) [.dockerignore](../.dockerignore) 自"重写独立dockerfile"起排除整个 `deploy/`,而 Dockerfile.frontend 要 COPY `deploy/nginx/frontend.conf`、Dockerfile.moldinsight 要 COPY `deploy/requirements-*.txt`(历史一直有旧镜像兜底未暴露;BuildKit 不支持重包含被排除目录的子文件,直接移除该行,deploy/ 仅几 KB 无上下文负担);(b) `Dockerfile.celery` `FROM gemold-backend:latest` 在 compose 并行构建下引用尚不存在的本地镜像必挂——**删除 Dockerfile.celery**,`moldinsight-celery` 改为与 API 服务**同一 build 声明 + 同一 `gemold-backend:latest` tag**(compose 去重只构建一次),celery 仅以 `command:` 覆盖启动 worker(`--concurrency` / `--max-tasks-per-child` 参数经 compose 命令与 `.env` 透传,语义不变),build.sh/.bat 移除 gemold-celery 构建步骤,OPERATIONS / OCC_THROUGHPUT / TECH_DEBT / .env.example 的 Dockerfile.celery 指向同步改写;⑦ **镜像 base 由 miniconda 切换 Miniforge**——[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) FROM `continuumio/miniconda3:24.7.1-0` → `condaforge/miniforge3:24.7.1-2`(conda-forge 默认且唯一渠道,无 defaults 渠道与 Anaconda ToS 顾虑;与 CI 已用的 Miniforge 安装、开发机 Miniforge 同源;tag 经 Docker Hub 社区用例确认存在;conda create 步骤与 python=3.12 / pythonocc-core=7.9.0 锁定不变),[TECH_DEBT.md](TECH_DEBT.md) D13 锁定记录同步。**验证**:三文件 YAML 解析 + 结构静态校验通过(services / depends_on / 卷声明 / anchor 合并 / 网络命名 / 无 profiles 残留 / celery 与 API 服务 build 声明一致性);5 个 service 的 environment 键与拆分前逐一比对(YAML 展开合并键后 39/39、34/34、39/39、34/34、20/20)零丢失。**遗留**:部署机 `git pull` 后裸 `docker compose up -d` 端到端复验;base 镜像(miniconda3 / node / nginx)拉取依赖 docker.io 连通性,不通时需配镜像加速。
|
||||
>
|
||||
> 2026-09-24(**D17 Human-in-Loop 老师傅经验反馈批 3 上线(前端按钮 + Dialog + 经验角标)——闭环可视**:① [ResultView.vue:35-47](frontend/src/modules/moldinsight/ResultView.vue#L35-L47) 方案卡片 summary-header 加 `t-tag theme="success" variant="light"` 经验角标("📚 历史经验 N 条"),从 `hintsByAxis[currentAxisHint]` 读取,按 scheme_axis 索引,无 hints 时不渲染;② [ResultView.vue:131-138](frontend/src/modules/moldinsight/ResultView.vue#L131-L138) `export-buttons-bar` 加 `👍 老师傅反馈` 按钮(`v-if="canGiveFeedback"` 角色门控:admin 或 process_engineer);③ 新建 [components/HumanFeedbackDialog.vue](frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue):t-dialog + t-form + t-radio-group 三选一(采纳 / 建议调整 / 拒绝)+ t-textarea 原因 + 调整建议(仅 adjust 模式显隐);提交走 `moldinsightApi.submitExperienceFeedback`,成功后 emit `submitted` 让父组件重拉 hints 刷新角标;④ [shared/api-client.ts:407-444](frontend/src/shared/api-client.ts#L407-L444) `moldinsightApi` 新增 `getExperienceHints` / `submitExperienceFeedback` 两个方法(生成类型由 openapi-typescript 自动产出);⑤ [ResultView.vue](frontend/src/modules/moldinsight/ResultView.vue) `onMounted` 调 `loadExperienceHints` 拉一次 + 反馈提交后 `onFeedbackSubmitted` 再拉一次(写入即消费前端可见);`canGiveFeedback` 走 `is_superuser || roles 含 process_engineer` 表达式(项目硬规则"前端不要破坏 ResultView.vue 视觉一致性":按钮与既有 6 个 t-button 同一 `export-buttons-bar`,theme/size 一致;角标 theme="success" variant="light" 与既有 t-tag theme="primary" / "warning" 同款)。**接口变更三件套随批完成**:openapi.json 重导出(2 个新 path,含 ExperienceFeedbackCreate / ExperienceHintItem / ExperienceHintsResponse 三个新 schema)→ `npm run gen:api` 再生 types/api.ts → `npm run build` 通过。**后端基线**:**192 passed, 13 skipped**(批 3 不改后端);**前端构建**:vue-tsc + vite 通过,ResultView 包大小 37.18 kB / 11.91 kB gzip。**D17 闭环端到端可用**:admin / process_engineer 在 ResultView 点"👍 老师傅反馈" → Dialog 选"采纳"+ 写原因 → 提交 → 角标即时刷新(重拉 hints);下次同指纹 STP 分析,`PartingCandidateGenerator` 候选方向加成 + `PartingSchemeScorer` total_score 加成 + method 标签升级 `human_experience_primary`。**下一步**:批 4(衰减机制完善 + DFM 规则库独立模块化 + 经验冲突仲裁 UI)按需排期。)
|
||||
|
||||
> 2026-09-23(**D17 Human-in-Loop 老师傅经验反馈批 2 上线(算法接缝 + OCC payload 通道)——闭环通**:① 算法层 4 个核心文件加 `hints` 形参透传链:[parting_candidate_generator.py:13-66](src/moldinsight/core/parting_candidate_generator.py#L13-L66) `_build_axis_metrics` 末尾按 hints 加成(`weight × 20` 上限,`sample_count ≥ 2 + weight ≥ 0.5` → method 标签升级 `human_experience_primary`);[parting_scheme_scorer.py:8-46](src/moldinsight/core/parting_scheme_scorer.py#L8-L46) `_score_scheme` 新增 `human_hint_bonus` 字段(weight × 12 上限,sample_count < 2 时 ×0.5 折半),纳入 total_score;[multi_scheme_planner.py:26-86](src/moldinsight/core/multi_scheme_planner.py#L26-L86) `generate_plan` 透传 hints 到下两层,`global_summary.applied_hints` 注入返回;② [processing_service.py:531-595](src/moldinsight/services/processing_service.py#L531-L595) `_step_generate_cavity` 调 `experience_feedback_service.resolve_for_process_params` 拿同指纹 hints,装进 run_occ payload 顶层 `experience_hints` 字段(普通 dict 透传,pickle 安全,满足 [occ_worker.py:7-8](src/moldinsight/core/occ_worker.py#L7-L8) 硬规则);③ [occ_worker.py:117-140](src/moldinsight/core/occ_worker.py#L117-L140) `_op_generate_cavity` 读 `payload.get("experience_hints") or {}` 透传给 `planner.generate_plan(..., hints=...)`;④ D17 闭环验证:老师傅写一条同指纹 `adopted` → 同 X 通道下次分析 `priority_score` +18,`score_breakdown.human_hint_bonus` +12(sample_count=3),method 标签升级 `human_experience_primary`。**接口面零变化**(路径 / schema 不动;仅 OCC 子进程内部响应含 `global_summary.applied_hints`,由前端 ResultView 渲染角标——批 3 实现)。**测试基线**:**192 passed, 13 skipped**(批 2 净增 7 通过 + 4 OCC-gated skip:candidate_generator 3 例 / scheme_scorer 4 例在无 OCC 环境跑通,multi_scheme_planner + processing_service 4 例 OCC-gated 待 conda `gemold` 镜像验证)。**接口变更三件套执行节点**:openapi.json 重导出与前端 `gen:api` 待批 3 完成后一并执行(前端调用两 path + ResultView 渲染一并改)。**下一步**:批 3 前端(ResultView 按钮组 + `HumanFeedbackDialog.vue` + `moldinsightApi` 两个方法 + 经验角标)。)
|
||||
|
||||
> 2026-09-23(**D17 Human-in-Loop 老师傅经验反馈批 1 上线(数据 + 权限 + 写入 API)**:① 新增 `experience_feedback` 表(32 表迁移,alembic head `b7d1f4a92c3e`)——老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 + 工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费;② 新增 3 个权限码(`view_experience_feedback` / `feedback_experience_hint` / `manage_experience_feedback`)+ 新角色 `process_engineer`(含 view + feedback 权限,admin 角色 permissions 同步补齐);③ 新增 2 个端点(`POST /api/tasks/{task_id}/experience-feedback` 提交反馈 + `GET /api/tasks/{task_id}/experience-hints` 拉取同指纹历史 hints 摘要);④ `init_db.py` 幂等 bug 修复——既有 DB 启动期不再跳过新增权限 / 角色补登(`init_permissions` / `init_roles` 改为按 code 比对,新增保留已有 id);⑤ ORM / 迁移 / service / router / api 注册均落位:D9 边界(service.flush + 路由 commit);D17 衰减(写新反馈时同 `stp_file_id` 整体续期 90 天 TTL);`User.has_permission` 全仓首次调用点([src/shared/models/identity.py:38](src/shared/models/identity.py#L38) 此前仅定义零调用)。**接口面新增 2 path**(openapi.json 重导出随批 3 一并执行——批 2 OCC payload 接缝改了 `/api/status/{task_id}` 实际响应结构需等到 OCC 集成落地再重导出)。**测试基线**:**185 passed, 9 skipped**(批 1 净增 59 测试,含 `compute_fingerprint` 分桶参数化覆盖 bbox / volume / face / undercut / material / is_foam 各边界值 + API 契约 401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)。**下一步**:批 2 算法接缝(PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 透传 hints + OCC worker payload `experience_hints` 通道)+ 批 3 前端按钮 + 反馈 Dialog + 经验角标渲染。)
|
||||
|
||||
> 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-22(**D13 锁文件流程固化:镜像引入已清偿 + 落锁流程就绪**:① 新增 [deploy/generate_lockfiles.sh](../deploy/generate_lockfiles.sh) / [.bat](../deploy/generate_lockfiles.bat):在 moldinsight conda 环境(仅项目依赖,**不能**在混装开发栈跑)执行 `pip freeze --exclude pythonocc-core`,产出 `deploy/requirements-{base,moldinsight}.lock.txt`;② [Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 注释改为指向生成脚本;③ [docs/OPERATIONS.md](../docs/OPERATIONS.md) §2.1 增加完整流程说明(生成时机 / 命令 / 产物 / 消费方 / 提交策略);④ [tests/test_lockfile_generation.py](../tests/test_lockfile_generation.py) 加锁文件存在性 + 体积契约,默认 skip(仓库单测不阻塞),CI 镜像构建 job 显式 `pytest --run-lockfile-check` 启用 fail-fast。**遗留**:锁文件本身尚未落盘——本机 Miniforge 装的是跨项目开发栈混装环境,污染严重不能直接用 `pip freeze`;须等 CI / 生产机器首次构建 moldinsight 镜像后按流程落锁。**测试基线**:**126 passed, 9 skipped**(默认 4 个原有 skip + D13 新增 5 个 skip;启用 `--run-lockfile-check` 时严格断言 2 项锁文件契约)。)
|
||||
>
|
||||
> 2026-09-02 起(含 09-17 之前基线条目)的完整内容见 [archive/2026-09_status_history.md](archive/2026-09_status_history.md)。
|
||||
>
|
||||
> 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。)
|
||||
|
||||
|
||||
+91
-77
@@ -8,68 +8,43 @@
|
||||
|
||||
## 1. 当前技术债概览
|
||||
|
||||
当前最主要的技术债集中在两个区域:
|
||||
当前最主要的技术债集中在三个区域:
|
||||
|
||||
- **moldinsight API 与处理链路的结构收口**
|
||||
- **inventory 复杂业务域的 service 继续下沉**
|
||||
- **文档 / 部署 / 历史语义与当前代码现状未完全一致**
|
||||
|
||||
已经完成的高优先级治理不再作为持续待办反复展开,当前重点聚焦在“还没完成、且值得继续推进”的部分。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已完成的重要治理(摘要)
|
||||
## 2. 已完成的重要治理(主题摘要)
|
||||
|
||||
以下高价值治理已完成:
|
||||
按主题归类的高价值治理已完成项。每项的具体修复清单 / 迁移号 / 回归测试 / 测试基线见归档:
|
||||
|
||||
- [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md):批次 0–4 + 后续专项 + 2026-09-21 inventory 服务下沉 + 2026-09-22 schema/datetime 弃用清零 的完整流水账
|
||||
- [archive/MOLDINSIGHT_TECH_DEBT_PLAN.md](archive/MOLDINSIGHT_TECH_DEBT_PLAN.md):更早的设计审查原始计划
|
||||
|
||||
### 2.1 安全与权限
|
||||
- debug/history 路由补鉴权
|
||||
- 任务访问控制收紧
|
||||
- 无主数据不再默认放行
|
||||
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5,2026-09-16 清偿,见 D5 条目)
|
||||
- bcrypt 创建口令超 72 字节显式拒绝、验证侧截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失时明确报错,代码侧弱默认移除(D14 部分,2026-09-16)
|
||||
- debug / history 路由补鉴权;任务访问控制收紧;无主数据不再默认放行
|
||||
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5 → §3 D5)
|
||||
- bcrypt 超 72 字节显式拒绝 + 截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失明确报错
|
||||
|
||||
### 2.2 静默失败与可用性
|
||||
- `detect-undercuts` 改为基于真实 shape 分析
|
||||
- OCC 超时后重建 executor,避免全队列永久堵死
|
||||
- OCC 超时后重建 executor(短期)→ D10 方案 B 进程化彻底替换
|
||||
- 后台任务统一分派,补强引用与并发控制
|
||||
|
||||
### 2.3 状态存储与缓存
|
||||
- Redis 任务状态改为 Hash 字段级更新,兼容旧格式
|
||||
- 完成态任务视图增加缓存
|
||||
- 导出缓存与持久化链路收口,支持重启后再导出
|
||||
- 内存回退彻底删除,PG 为任务状态单一事实源(原 D7)
|
||||
- 完成态任务视图缓存;导出缓存与持久化链路收口
|
||||
|
||||
### 2.4 架构与代码清理
|
||||
- 删除旧单体入口与死代码
|
||||
- 设置惰性配置校验,提升可测试性
|
||||
- Generator 公共接口提取完成,补充契约测试
|
||||
|
||||
### 2.5 部署正确性(2026-09-16,批次 0/1)
|
||||
- `/api/status/{task_id}` 补鉴权与归属校验(原 D5)
|
||||
- 主处理链路改走 RustFS:分派入参 `stp_file_id` 化,源文件按 object_key 下载;compose 共享卷过渡兜底(原 D6)
|
||||
- `AUTO_MIGRATE` 开关 + 迁移脚本随镜像分发 + `alembic/`→`migrations/` 改名修复包遮蔽(原 D12)
|
||||
- OCC 镜像改 conda 运行时原生执行、基础镜像 tag 锁定(D13 主体);compose 关键项去弱默认(D14 部分)
|
||||
|
||||
### 2.6 任务一致性模型(2026-09-16,批次 2)
|
||||
- Redis 内存回退彻底删除,PG 为任务状态单一事实源(原 D7);批量元数据入库(`processing_tasks.batch_id`,迁移 `a3f8c2d91e47`)
|
||||
- 型腔生成失败任务标 failed,不再静默 completed(原 D8)
|
||||
- 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9)
|
||||
- D11(HTML 双写双读)本批未动:正确性已由共享卷兜底,RustFS 单一来源留待后续批次
|
||||
|
||||
### 2.7 API 与代码结构(2026-09-17,批次 3)
|
||||
- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变,请求体全量 Pydantic 化(原 D1)
|
||||
- 路由装载失败显式化:`ROUTE_MODULES` 清单 + route_registry,失败经 `/api/health` 呈现 degraded(含真实 pythonocc 探测),DEBUG 下 fail fast
|
||||
- 纯 Python 重计算端点(设计/加工/CAM 打包)统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环;OCC 操作仍走单线程 executor(D10 不变,批次 4)
|
||||
- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务;无调用方的 `log_user_activity` 死代码删除
|
||||
- 配置治理收尾:`MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14)
|
||||
- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422,功能端到端断裂);Dockerfile.celery 的 FROM tag 与 compose/build.sh 实际构建的 `gemold-backend:latest` 对齐(此前干净环境 celery 镜像必构建失败)
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api`
|
||||
|
||||
### 2.8 架构演进(2026-09-17,批次 4)
|
||||
- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表);**三条跨模块 ORM relationship(`User.stp_files`、`STPFile.user`、`STPFile.product`)经全仓核实均无使用方,直接删除**——跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1),单模块部署不再依赖另一侧模型注册;约 45 处 import 全量改写,无兼容 facade;全量注册点收敛为 migrations/env.py 与 tests/conftest.py;零调用方的死方法 `db_manager.create_tables` 一并删除(拆分后会静默建残缺 schema)
|
||||
- OCC 泄漏治理 + 吞吐方案设计先行(原 D10):`_reset_occ_executor` 补 `cancel_futures=True`——不止卫生问题:旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争);吞吐路线定稿于 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治)
|
||||
- 归属边界回归测试:[tests/test_model_ownership.py](../tests/test_model_ownership.py)(31 表全量注册、单模块独立 mapper 配置、旧模块无 facade)
|
||||
|
||||
详细历史过程保留在原始技术债文档中,后续将转入归档。
|
||||
- 删除旧单体入口与死代码(`db_manager.create_tables` / `log_user_activity` / `CADExporter.export_mold_results` / `getAluminumPrice` 等)
|
||||
- 惰性配置校验,提升可测试性
|
||||
- Generator 公共接口提取 + 契约测试
|
||||
- 共享 ORM 按模块拆分,跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1)
|
||||
|
||||
---
|
||||
|
||||
@@ -85,47 +60,35 @@
|
||||
|
||||
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。
|
||||
|
||||
### D2. 铝价模拟数据未显式标注来源
|
||||
### D2. 铝价模拟数据未显式标注来源 —— 已清偿(2026-09-18,批次 4 后续专项)
|
||||
|
||||
现状:
|
||||
- 铝价服务返回的是模拟/参考数据,但接口层未明确表达
|
||||
修复内容(保留编号以维持引用稳定):
|
||||
- [src/moldinsight/services/aluminum_price_service.py](../src/moldinsight/services/aluminum_price_service.py):`get_aluminum_current_price` 响应补 `source: "simulated"`,`get_aluminum_price_history` 逐项补同字段
|
||||
- [frontend/src/modules/home/HomeView.vue](../frontend/src/modules/home/HomeView.vue):按 `source` 字段渲染"模拟数据 · 参考走势,非实时行情"标注(不再硬编码"上海期货交易所"等虚假来源)
|
||||
- 死代码 `getAluminumPrice` 删除(前端此前保留了一份本地硬编码函数,已无调用方)
|
||||
|
||||
影响:
|
||||
- 容易误导前端与业务使用者,把模拟数据理解为实时行情
|
||||
~~原现状 / 影响~~:铝价接口返回走势数据但无来源声明,前端原硬编码"上海期货交易所"字样,与实际模拟数据不一致,属虚假来源声明。
|
||||
|
||||
建议:
|
||||
- 响应增加 `source: "simulated"`
|
||||
- 前端界面同步标注“模拟/参考数据”
|
||||
### D3. shared/platform 边界仍需继续收敛 —— 主体已清偿(2026-09-17 批次 4 + 2026-09-18 后续)
|
||||
|
||||
优先级:**P2**
|
||||
|
||||
### D3. shared/platform 边界仍需继续收敛 —— ORM 归属已清偿(2026-09-17,批次 4)
|
||||
|
||||
已完成部分:
|
||||
- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 §2.8 与 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
|
||||
已完成部分(修复文件清单见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 批次 4 / 后续专项):
|
||||
- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
|
||||
- 旧 `shared/models/database.py` 物理删除,无兼容 facade;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定
|
||||
- **app_factory 组合职责收敛**(2026-09-18):平台工厂只做纯平台引导,`connect_rustfs` 参数移除;moldinsight 专属接线(RustFS 启动钩子 [init_storage.py](../src/moldinsight/storage/init_storage.py) `rustfs_startup_hook`、路由单点聚合 [moldinsight/api/__init__.py](../src/moldinsight/api/__init__.py) `register_moldinsight_routers`)收敛回模块层,入口退化为纯组装(ARCHITECTURE §6.2)
|
||||
|
||||
仍保留的收敛方向(低优先级,随实际重构推进):
|
||||
- [app_factory.py](../src/shared/app_factory.py) 组合职责偏重(ARCHITECTURE §6.2)
|
||||
- identity / platform 的边界语义(ROADMAP §2.1)
|
||||
- identity / platform 的边界语义(ROADMAP §2.1):平台表与模块表的命名/注释口径随实际重构推进
|
||||
|
||||
优先级:**P3**(剩余部分)
|
||||
优先级:**P3**(仅剩 identity/platform 语义注释口径)
|
||||
|
||||
### D4. 文档现状 / 规划 / 历史混放
|
||||
### D4. 文档现状 / 规划 / 历史混放 —— 已清偿(2026-09-22)
|
||||
|
||||
现状:
|
||||
- 文档存在部署说明重叠、计划/总结/权威文档混放
|
||||
- README 承担过多职责
|
||||
修复内容(保留编号以维持引用稳定):
|
||||
- 已建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架,每类信息单一归属;历史材料归档至 `docs/archive/`
|
||||
- 本文档 §2 由"按批次回顾"精简为"按主题摘要",修复文件清单 / 迁移号 / 回归测试 / 测试基线等详细流水账整体迁入 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md)(避免与 §3 重复膨胀)
|
||||
- §3 中对历史批次的引用(如 D3 → §2.8)改为 archive 指针;D2 等已清偿项补齐时间戳
|
||||
|
||||
影响:
|
||||
- 新成员难以判断“哪篇才是当前有效说法”
|
||||
- 状态、部署、规划容易发生漂移
|
||||
|
||||
建议:
|
||||
- 建立 `STATUS / ARCHITECTURE / ROADMAP / DEPLOYMENT` 主骨架
|
||||
- 历史材料迁入 `docs/archive/`
|
||||
|
||||
优先级:**P1**
|
||||
~~原现状 / 影响~~:TECH_DEBT §2 与 §3 内容重复膨胀,文档目录结构清晰度受新成员评估影响。
|
||||
|
||||
### D5. `/api/status/{task_id}` 未鉴权(安全缺口)—— 已清偿(2026-09-16,批次 0)
|
||||
|
||||
@@ -182,7 +145,7 @@
|
||||
- 顺带删除:内存形状缓存链(`_cache_export_shapes` / `get_export_shapes` / `_persist_step_exports`)、`CADExporter.export_mold_results`(零调用方)、shape_loader(→ [stp_materializer.py](../src/moldinsight/services/stp_materializer.py))
|
||||
- 回归测试:[tests/test_occ_process_pool.py](../tests/test_occ_process_pool.py)(OCC-gated,6 例含真实盒体 STP 端到端)
|
||||
|
||||
**方案 A 部署参数落地**(2026-09-18):`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 [Dockerfile.celery](../deploy/Dockerfile.celery) + compose + `.env.example`(`--max-tasks-per-child` 仍保留为进程回收兜底)。
|
||||
**方案 A 部署参数落地**(2026-09-18):`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 compose + `.env.example`(`--max-tasks-per-child` 仍保留为进程回收兜底;2026-09-24 起 worker 与后端共用 `gemold-backend` 镜像,Dockerfile.celery 已移除,参数经 compose `command:` 覆盖与环境变量传递)。
|
||||
|
||||
保留为已知约束(非待修缺陷):
|
||||
- 单进程内 OCC 串行是正确性要求(OCC 非线程安全),吞吐扩展走多进程(方案 A/B)
|
||||
@@ -197,6 +160,7 @@
|
||||
- **读侧**:`/html` StaticFiles 挂载删除,新增代理路由 [html_report_router.py](../src/moldinsight/api/html_report_router.py)(挂根路径保持 URL 形状——持久化 cavity JSON 与前端 iframe 均引用 `/html/{filename}`):RustFS 报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404,防路径穿越(单段文件名校验)
|
||||
- **部署**:celery 服务摘除 `html_data` 卷(不再写本地);Dockerfile.moldinsight 删除 `COPY html_output/`(构建机陈旧报告不再烤进镜像)
|
||||
- **已知约束**(沿用 StaticFiles 时代既定姿态,非新引入):报告路由不做认证——iframe 无法携带 Authorization 头
|
||||
- **2026-09-27 追记**:可视化产物从"每任务一套"改为**每分模方案一套**——`_attach_scheme_previews` 为每个 `candidate_schemes` 生成独立 HTML / `_summary.json` / `_data.json`([processing_service.py](../src/moldinsight/services/processing_service.py)),`scheme["html_file"]` 供前端切方案即切预览;任务级 HTML 复用推荐方案那份(`HTMLFile` 记录仍仅推荐方案一条)。**已知取舍**:网格数据 JSON 每方案复制一份,报告键存储随方案数线性增长(当前 ≤3 方案,接受);存储吃紧时再演进共享产品网格 + 仅差分型腔/模芯,暂不排期
|
||||
- 回归测试:[tests/test_html_report_router.py](../tests/test_html_report_router.py)(8 例:四链路命中、新旧格式记录区分、媒体类型、404、穿越拒绝)
|
||||
|
||||
~~原现状 / 影响~~:可视化 HTML/摘要同时写本地与 RustFS,多副本下 /html 命中结果取决于负载均衡,跨副本文件不共享。
|
||||
@@ -211,13 +175,28 @@
|
||||
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`
|
||||
|
||||
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(主体已清偿,锁文件遗留)
|
||||
### 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 镜像引入方式脆弱 + 依赖无版本锁(镜像引入已清偿;锁文件流程已固化,待首次构建落盘)
|
||||
|
||||
现状:
|
||||
- ~~从 conda env 拷贝 site-packages 进 python:3.12-slim~~(2026-09-16 已修正:[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 改为 conda 运行时原生执行,不再跨镜像拷贝;基础镜像 tag 锁定 `continuumio/miniconda3:24.7.1-0`、`python:3.12-slim-bookworm`;tag 可用性随下次镜像构建验证)
|
||||
- [requirements.txt](../requirements.txt) 全部为 `>=` 下限,无锁文件(**遗留**:首次镜像构建成功后 `pip freeze` 生成锁文件,命令已注释在 Dockerfile 内)
|
||||
- ~~从 conda env 拷贝 site-packages 进 python:3.12-slim~~(2026-09-16 已修正:[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 改为 conda 运行时原生执行,不再跨镜像拷贝;基础镜像 tag 锁定 `condaforge/miniforge3:24.7.1-2`——2026-09-24 由 `continuumio/miniconda3:24.7.1-0` 切换,conda-forge 单渠道无 Anaconda ToS 顾虑、与 CI / 开发机 Miniforge 同源;另锁定 `python:3.12-slim-bookworm`;tag 可用性随下次镜像构建验证)
|
||||
- 锁文件流程已固化(2026-09-22):
|
||||
- 新增 [deploy/generate_lockfiles.sh](../deploy/generate_lockfiles.sh) / [generate_lockfiles.bat](../deploy/generate_lockfiles.bat):在 moldinsight conda 环境(仅项目依赖,**不能**在混装开发栈跑)执行 `pip freeze --exclude pythonocc-core`,产出 `deploy/requirements-{base,moldinsight}.lock.txt`
|
||||
- [Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 注释改为指向生成脚本
|
||||
- [docs/OPERATIONS.md](../docs/OPERATIONS.md) §2.1 增加完整流程说明(生成时机 / 命令 / 产物 / 消费方 / 提交策略)
|
||||
- [tests/test_lockfile_generation.py](../tests/test_lockfile_generation.py) 加锁文件存在性 + 体积契约;默认 skip(仓库单测不阻塞),CI 镜像构建 job 显式 `pytest --run-lockfile-check` 启用
|
||||
- **遗留**:锁文件本身尚未落盘——本机 Miniforge 装的是跨项目开发栈混装环境,污染严重不能直接用 `pip freeze`;须等 CI / 生产机器首次构建 moldinsight 镜像后,按流程跑 `bash deploy/generate_lockfiles.sh` 落锁并提交。已存在护栏:CI 镜像构建 job 跑 `--run-lockfile-check` 后若未落盘会 fail-fast,强制流程走通
|
||||
|
||||
优先级:**P2**(剩余锁文件部分)
|
||||
优先级:**P2**(流程已固化,剩"首次构建后落盘"一次性产物)
|
||||
|
||||
### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3)
|
||||
|
||||
@@ -235,6 +214,40 @@
|
||||
|
||||
~~原现状 / 影响~~:`vue-tsc -b`(`npm run build` 的类型检查步)因既有 TS6133 失败,前端无法出生产包(与批次 3 改动无关的既有问题)。
|
||||
|
||||
### D17. 算法成熟度距"老师傅经验"差距 + Human-in-Loop 闭环 —— 批 1 已清偿(2026-09-23)
|
||||
|
||||
**背景**:现有算法(分模 / 倒扣 / 评分 / DFM 校验)是 OCC BREP 上的工程启发式,距模具师傅"看完就知道该咋改"的实战经验仍有结构性差距——倒扣邻接聚类缺失、滑块 / 斜顶设计是纯几何启发、DFM 规则库仅 4 条、评分权重拍脑袋(详见 2026-09-22 用户对话评估)。
|
||||
|
||||
**方案**:引入 Human-in-Loop 闭环——老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,以"产品指纹 + 工艺参数"为索引跨任务匹配,**下次同指纹产品分析自动消费这些经验**(OCC worker payload 透传 → MultiSchemeMoldPlanner → PartingSchemeScorer 加成)。老师傅的经验以结构化数据沉淀,避免成为"知识库坟墓"。
|
||||
|
||||
**批 1 已完成(数据 + 权限 + 写入 API)**:
|
||||
- 新增 `experience_feedback` 表(alembic head `b7d1f4a92c3e`,32 表迁移),含 fingerprint JSON 列(PG 下 GIN 索引支持 jsonb_path_query)
|
||||
- 3 个权限码(`view_experience_feedback` / `feedback_experience_hint` / `manage_experience_feedback`)+ 新角色 `process_engineer`;admin 角色 permissions 同步补齐
|
||||
- `init_db.py` 幂等 bug 修复——既有 DB 启动期不再跳过新增权限 / 角色补登(`init_permissions` / `init_roles` 改为按 code 比对,新增保留已有 id,避免 FK 引用失效)
|
||||
- 新增端点 `POST /api/tasks/{task_id}/experience-feedback`(提交方案级反馈;`TaskQueryService.ensure_task_access` 归属校验 + `User.has_permission` 全仓首次调用)+ `GET /api/tasks/{task_id}/experience-hints`(按 material_family + is_foam 锚定的历史聚合)
|
||||
- D9 边界遵守:service.flush + 路由 commit;D17 衰减机制:写新反馈时同 `stp_file_id` 整体续期 90 天 TTL(无 celery beat 依赖)
|
||||
- 测试基线:185 passed, 9 skipped(批 1 净增 59 测试)
|
||||
|
||||
**批 2 已完成(算法接缝 + OCC payload 通道)—— 闭环通**:
|
||||
- [parting_candidate_generator.py](src/moldinsight/core/parting_candidate_generator.py) `generate_candidates(..., hints=None)`:`priority_score += weight × 20`,`sample_count ≥ 2 + weight ≥ 0.5` 时 method 标签升级 `human_experience_primary`
|
||||
- [parting_scheme_scorer.py](src/moldinsight/core/parting_scheme_scorer.py) `score_schemes(..., *, hints=None)`:新增 `score_breakdown["human_hint_bonus"]`(`weight × 12`,`sample_count < 2` 时 ×0.5 折半),纳入 total_score;keyword-only 防与位置参数混淆
|
||||
- [multi_scheme_planner.py](src/moldinsight/core/multi_scheme_planner.py) `generate_plan(..., hints=None)`:透传 hints 到下两层,`global_summary.applied_hints` 注入返回供前端展示
|
||||
- [processing_service.py](src/moldinsight/services/processing_service.py) `_step_generate_cavity`:调 `experience_feedback_service.resolve_for_process_params` 拿同指纹 hints,装进 run_occ payload 顶层 `experience_hints`;解析失败回退空 list 不阻塞主流程
|
||||
- [occ_worker.py](src/moldinsight/core/occ_worker.py) `_op_generate_cavity`:`payload.get("experience_hints") or {}` 透传给 `planner.generate_plan`,普通 dict 跨进程 pickle 安全
|
||||
- 测试基线:192 passed, 13 skipped(批 2 净增 7 通过 + 4 OCC-gated skip)
|
||||
|
||||
**批 3 已完成(前端按钮 + Dialog + 经验角标)—— 闭环可视**:
|
||||
- [ResultView.vue:35-47](frontend/src/modules/moldinsight/ResultView.vue#L35-L47) 方案卡片 summary-header 加 `t-tag` 经验角标(`currentAxisHint` computed 按 scheme_axis 索引 hintsByAxis,无 hints 不渲染)
|
||||
- [ResultView.vue:131-138](frontend/src/modules/moldinsight/ResultView.vue#L131-L138) `export-buttons-bar` 加 `👍 老师傅反馈` 按钮(`v-if="canGiveFeedback"` 角色门控:admin 或 process_engineer)
|
||||
- [HumanFeedbackDialog.vue](frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue) 新组件:t-dialog + t-form + t-radio-group 三选一 + t-textarea;走 `moldinsightApi.submitExperienceFeedback`,成功后 emit `submitted` 让父组件重拉 hints
|
||||
- [shared/api-client.ts:407-444](frontend/src/shared/api-client.ts#L407-L444) `moldinsightApi` 新增 `getExperienceHints` / `submitExperienceFeedback`
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(2 新 path)→ `npm run gen:api` → `npm run build` 通过
|
||||
|
||||
**剩余工作(按需排期)**:
|
||||
- 批 4:衰减机制完善(与 DB 一致性定期核查)+ DFM 规则库独立模块化 + 经验冲突仲裁 UI
|
||||
|
||||
~~原现状 / 影响~~:算法生成的方案与真实工程决策有差距,老师傅每次都要推翻系统建议重来,沉淀经验无结构化路径。
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前推荐治理顺序
|
||||
@@ -250,6 +263,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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 2026-09 后端设计治理批次实施流水账(归档)
|
||||
|
||||
> 文档定位:**2026-09 设计审查产出的批次 0–4 与后续专项的完整实施流水账**。
|
||||
> 主骨架权威文档见:
|
||||
> - [../STATUS.md](../STATUS.md)(每批次完成的时间点与产物)
|
||||
> - [../TECH_DEBT.md](../TECH_DEBT.md)(活跃债务 / 已清偿项的当前位置)
|
||||
> - [../ROADMAP.md](../ROADMAP.md)(批次计划与执行顺序)
|
||||
> - [../ARCHITECTURE.md](../ARCHITECTURE.md)(平台 / 模块边界当前定稿)
|
||||
|
||||
## 批次 0(2026-09-16,安全与诚实)
|
||||
|
||||
- `/api/status/{task_id}` 补 JWT 鉴权 + 任务归属校验(无 token 401 / 他人或无主任务 403 / 不存在 404)
|
||||
- 归属校验收敛为 `TaskQueryService.ensure_task_access` 供 task_router 与 advanced_router 共用(原 D5)
|
||||
- 上传预检 `pythonocc_available` 从硬编码 true 改为惰性真实探测
|
||||
- bcrypt 口令治理:创建侧超 72 字节显式拒绝、验证侧截断比较
|
||||
- `SECRET_KEY` 未配置 / `RUSTFS_*` 缺失时惰性校验抛明确错误
|
||||
- 完成态任务未持久化 `analysis_metrics` 时 `/api/status` 组装视图 500 修复
|
||||
- 测试基线:96 passed
|
||||
- 回归测试:`tests/test_status_endpoint_auth.py` 8 项
|
||||
- 接口行为变化已同步 [../API_CONTRACT.md](../API_CONTRACT.md) §3.2
|
||||
|
||||
## 批次 1(2026-09-16,部署正确性)
|
||||
|
||||
- 主处理链路改走 RustFS:分派入参 `file_path` → `stp_file_id`(原 D6)
|
||||
- 处理方按 PG 元数据从 RustFS 下载源文件到任务专属临时目录(保留原始文件名,下游产物命名不变),任务结束即清理
|
||||
- RustFS 不可用时回退 `STPFile.file_path` 节点本地路径;compose 为 backend / celery 增加共享卷 `uploads_data` / `html_data` 作过渡兜底
|
||||
- `AUTO_MIGRATE` 开关(settings / .env.example / compose 透传),默认 `true` 保持单机开发行为(原 D12)
|
||||
- 迁移目录 `alembic/` → `migrations/`(修复 `import alembic` 命中本地目录遮蔽真实包的命名冲突)+ Docker 镜像补 `COPY migrations/` + `COPY alembic.ini`
|
||||
- Dockerfile.moldinsight 改为 conda 运行时原生执行,基础镜像 tag 锁定(原 D13 主体)
|
||||
- compose 关键项去弱默认:`SECRET_KEY` / `ADMIN_PASSWORD` 改 `${VAR:?}` 强制显式配置;`create_admin_user` 对空口令显式报错
|
||||
- 测试基线:98 passed, 1 skipped
|
||||
- 回归测试:`tests/test_deployment_config.py`
|
||||
|
||||
## 批次 2(2026-09-16,任务一致性模型)
|
||||
|
||||
- Redis 进程内存回退彻底删除(写 no-op / 读 None,Redis 仅热缓存),PG 为任务状态单一事实源(原 D7)
|
||||
- 批量元数据入库:`processing_tasks` 新增 `batch_id` 列(迁移 `a3f8c2d91e47`),`GET /api/batch/{batch_id}` 改为 PG 聚合查询 + `STPFile.user_id` 归属校验
|
||||
- 删除 Redis batch key 与进程内 dict 双通道
|
||||
- `TaskQueryService` PG 视图与 batch 聚合响应补 `progress` / `current_step`
|
||||
- 型腔分模失败不再吞异常 → 任务 failed(原 D8)
|
||||
- 持久化事务边界收口:数据本体写方法只 flush,编排层分阶段原子收口(阶段 A 几何+网格、阶段 B 型腔+HTML+特征+指标+验证),失败先 rollback 再置 failed(原 D9)
|
||||
- 进度/状态更新保留即时 commit(长任务进度可见性)
|
||||
- 测试基线:105 passed, 1 skipped
|
||||
- 回归测试:`tests/test_batch_status_pg.py` 4 项 + `tests/test_redis_no_fallback.py` 3 项
|
||||
|
||||
## 批次 3(2026-09-17,API 与代码结构)
|
||||
|
||||
- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变(原 D1)
|
||||
- 全部请求体改 Pydantic 模型(`request.json()` 手动解析退役),校验失败统一 422
|
||||
- `_get_cached_import` 上提为 `core_modules.py` 共用
|
||||
- 路由装载失败显式化:`ROUTE_MODULES` 清单 + `route_registry`,失败经 `/api/health` 呈现 degraded(`pythonocc` 真实探测),DEBUG 下 fail fast
|
||||
- 纯 Python 重计算端点统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环
|
||||
- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务(原 D14 部分)
|
||||
- `MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14 收尾)
|
||||
- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422);Dockerfile.celery 的 FROM tag 与实际构建 tag 对齐
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api`
|
||||
- 测试基线:122 passed, 2 skipped
|
||||
- 回归测试:4 个测试文件共 17 项(`test_advanced_split_contract` / `test_route_load_status` / `test_config_governance` / `test_auth_password_reset`)
|
||||
|
||||
## 批次 4(2026-09-17,架构演进)
|
||||
|
||||
- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表)
|
||||
- 三条跨模块 ORM relationship(`User.stp_files` / `STPFile.user` / `STPFile.product`)经全仓核实均无使用方,直接删除;跨模块桥接收敛为裸 FK 硬规则([../ARCHITECTURE.md](../ARCHITECTURE.md) §5.1)
|
||||
- 约 45 处 import 全量改写,无兼容 facade
|
||||
- 全量注册点收敛为 migrations/env.py 与 tests/conftest.py
|
||||
- 零调用方的死方法 `db_manager.create_tables` 一并删除
|
||||
- `_reset_occ_executor` 补 `cancel_futures=True`(旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**,属数据竞争而非单纯泄漏,原 D10 短期治理)
|
||||
- 吞吐路线定稿于 `topics/performance/OCC_THROUGHPUT.md`(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治)
|
||||
- 顺手清偿 D15:`vite.config.ts` 删除未用的 `mode` 参数,`vue-tsc -b` 恢复通过
|
||||
- 回归测试:`tests/test_model_ownership.py`(31 表全量注册 / 单模块独立 mapper 配置 / 旧模块无 facade)
|
||||
- 测试基线:125 passed, 2 skipped
|
||||
- 接口面零变化(无 openapi 重导出)
|
||||
|
||||
## 批次 4 后续专项(2026-09-18)
|
||||
|
||||
- D11 清偿:可视化报告 RustFS 单源化
|
||||
- 写侧 HTMLGenerator 每任务写临时目录,`.html`/`_summary.json`/`_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`
|
||||
- 读侧 `/html` StaticFiles 本地挂载删除,新增代理路由 `html_report_router.py`(报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404)
|
||||
- 防路径穿越(单段文件名校验),URL 形状 `/html/{filename}` 不变
|
||||
- 部署:celery 服务摘除 `html_data` 卷;Dockerfile.moldinsight 删除 `COPY html_output/`
|
||||
- 已知约束:报告路由不做认证(iframe 无法携带 Authorization 头)
|
||||
- 删除 `get_stp_file_with_data` 的死数据块
|
||||
- OCC 方案 A 部署参数落地:`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 Dockerfile.celery + compose + .env.example
|
||||
- D2 清偿:铝价响应带 `source: "simulated"`;前端按来源渲染"模拟数据 · 参考走势"标注;死代码 `getAluminumPrice` 删除
|
||||
- D10 方案 B 实施:`run_occ(fn, *args)` → `run_occ(op_name, payload)`;常驻 OCC 进程池 + kill-on-timeout 根治残留线程泄漏
|
||||
- CI 门禁:`.gitea/workflows/ci.yml` 三 job(pytest / 前端构建 / openapi 漂移检测)
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(76→77 paths,新增 `/html/{filename}`)+ 前端 `gen:api` 再生
|
||||
- 测试基线:143 passed, 0 skipped
|
||||
|
||||
## 后续小步治理(2026-09-21 ~ 2026-09-22)
|
||||
|
||||
- 2026-09-21 inventory 业务层继续沉淀批次(4 批):
|
||||
- `customer / supplier / warehouse` → `master_data_service`
|
||||
- `material_routes`(价格历史 / 趋势 / 供应商关联)→ `material_service`
|
||||
- `product_routes`(CRUD / BOM / from-task 跨模块桥接)→ `product_service`
|
||||
- `dashboard_routes`(首页统计 / 低库存预警)→ `dashboard_service`
|
||||
- 2026-09-22 schema / datetime 现代化弃用清零(D12):
|
||||
- 全仓 14 处 `class Config:` + auth_routes 三处全部迁移到 `model_config = ConfigDict(from_attributes=True)`
|
||||
- `auth_service.py` 中 `datetime.utcnow()` 改用 `datetime.now(timezone.utc)`
|
||||
- 测试基线最终落点:126 passed, 4 skipped,零 deprecation warning
|
||||
@@ -0,0 +1,10 @@
|
||||
# 2026-09 之前 STATUS 早段历史(归档)
|
||||
|
||||
> 文档定位:**[../STATUS.md](../STATUS.md) 顶部精简后,2026-09-17 之前条目的完整副本**。
|
||||
> 2026-09-17 及之后的批次(批次 0–4 + 后续专项 + 2026-09-21 inventory 服务下沉 + 2026-09-22 schema/datetime 弃用清零)以摘要形式保留在 [../STATUS.md](../STATUS.md) 顶部,详细流水见 [2026-09_governance_batches.md](2026-09_governance_batches.md)。
|
||||
|
||||
---
|
||||
|
||||
> 2026-09-02(**模块化收口 + 文档主骨架建立(基线条目)**:代码侧完成 moldinsight 技术债治理——安全收口(debug/history 权限补齐、任务访问控制收紧)、静默失败修复(`detect-undercuts` 基于真实 shape 重建)、OCC 超时后 executor 重建防毒化全队列、后台任务统一分派、Redis 任务状态改 Hash 原子更新、完成态任务视图缓存、导出缓存与持久化收口、旧入口与死代码删除、Generator 公共接口提取 + 契约测试;详见 [TECH_DEBT.md](../TECH_DEBT.md) §2。结构侧完成 `src/entrypoints/` 三入口拆分(moldinsight / inventory / unified)、`shared` 平台能力集中、前端独立 `frontend/` 工程。文档侧建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架,README 收敛为唯一导航入口,历史材料迁入 [archive/](README.md)。**测试基线**:本地 pip 环境 **47 passed, 1 skipped**(pythonocc 缺失自动 skip);moldinsight conda + OCC 环境 **88 passed**。inventory 侧少量既有 deprecation warnings 不影响通过。)
|
||||
|
||||
> 此前:2026-09-01(**文档体系专项整理启动**:明确「README 只做导航、每类信息单一归属、历史材料进 archive」的文档治理原则;建立 deployment/ 主题目录与 archive/ 归档目录;部署文档收口为 DEPLOYMENT(入口)+ deployment/LINUX_SETUP(操作)+ deployment/DEPLOY_PORT / PORT_CONFIG(端口补充)三层。)
|
||||
@@ -25,6 +25,8 @@
|
||||
- [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)
|
||||
- [ZERO_FINISHED_INVENTORY_CERTIFICATE.md](ZERO_FINISHED_INVENTORY_CERTIFICATE.md)
|
||||
- [CONFLUENCE_ARCHIVE_STRUCTURE.md](CONFLUENCE_ARCHIVE_STRUCTURE.md)
|
||||
- [2026-09_governance_batches.md](2026-09_governance_batches.md):2026-09 设计审查批次 0–4 + 后续专项的完整实施流水账([../TECH_DEBT.md](../TECH_DEBT.md) 与 [../STATUS.md](../STATUS.md) 仅保留摘要)
|
||||
- [2026-09_status_history.md](2026-09_status_history.md):[../STATUS.md](../STATUS.md) 顶部精简后,2026-09-17 之前条目的完整副本
|
||||
- [topics/ai/](topics/ai/):已迁移的 AI 相关专题历史材料
|
||||
- [topics/performance/](topics/performance/):已迁移的性能专题历史材料
|
||||
- [topics/aluminum-foam/](topics/aluminum-foam/):已迁移的铝泡沫专题历史材料
|
||||
|
||||
@@ -64,26 +64,30 @@
|
||||
## 3. Docker Compose 端口来源
|
||||
|
||||
当前主部署文件:
|
||||
- [docker-compose.yml](../../docker-compose.yml)
|
||||
- [docker-compose.yml](../../docker-compose.yml)(unified,默认入口)
|
||||
- [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
|
||||
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)
|
||||
|
||||
关键端口映射:
|
||||
关键端口映射(默认 10003 / 10004,详见 [.env.example](../../.env.example)):
|
||||
|
||||
- `FRONTEND_PORT` → frontend Nginx 外部端口
|
||||
- `BACKEND_PORT` → unified backend 外部端口
|
||||
- `FRONTEND_PORT` → frontend Nginx 外部端口(浏览器入口,推荐直接访问)
|
||||
- unified backend → **不暴露宿主机端口**,经前端 /api 反代同域访问(docker 网络内 `backend:8000` 互通)
|
||||
- `MOLDINSIGHT_PORT` → moldinsight-only 模式宿主机端口
|
||||
- `INVENTORY_PORT` → inventory-only 模式宿主机端口
|
||||
- `MOLDINSIGHT_PORT` → moldinsight-only 独立部署端口
|
||||
- `INVENTORY_PORT` → inventory-only 独立部署端口
|
||||
|
||||
示例:
|
||||
|
||||
```env
|
||||
MOLDINSIGHT_PORT=8000
|
||||
INVENTORY_PORT=8001
|
||||
MOLDINSIGHT_PORT=10003
|
||||
INVENTORY_PORT=10004
|
||||
```
|
||||
|
||||
对应 compose 行为:
|
||||
|
||||
- moldinsight:`${MOLDINSIGHT_PORT:-8000}:8000`
|
||||
- inventory:`${INVENTORY_PORT:-8001}:8001`
|
||||
- moldinsight:`${MOLDINSIGHT_PORT:-10003}:8000`
|
||||
- inventory:`${INVENTORY_PORT:-10004}:8001`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ RUSTFS_SECRET_KEY=minioadmin
|
||||
- `/health` → unified backend
|
||||
- `/html` → unified backend(内部再提供 moldinsight 分析产物)
|
||||
|
||||
如果使用根目录 [docker-compose.yml](../../docker-compose.yml) 的 `frontend` 服务,则该入口已经内置在前端 Nginx 镜像中。
|
||||
如果使用 [docker-compose.yml](../../docker-compose.yml)(unified 模式)的 `frontend` 服务,则该入口已经内置在前端 Nginx 镜像中。
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +199,9 @@ celery -A src.celery_app.celery_app worker --loglevel=info
|
||||
如需按模块独立部署,则使用 `moldinsight-only` 或 `inventory-only` 入口;它们仍共享同一个仓库、同一个数据库与同一套基础设施。
|
||||
|
||||
当前入口与部署编排见:
|
||||
- [../../docker-compose.yml](../../docker-compose.yml)
|
||||
- [../../docker-compose.yml](../../docker-compose.yml)(默认 unified)
|
||||
- [../../docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
|
||||
- [../../docker-compose.inventory.yml](../../docker-compose.inventory.yml)
|
||||
- [../../src/entrypoints/unified.py](../../src/entrypoints/unified.py)
|
||||
|
||||
---
|
||||
@@ -398,25 +400,36 @@ curl http://127.0.0.1:8000/health
|
||||
|
||||
## 11. Docker Compose 说明
|
||||
|
||||
当前 [docker-compose.yml](../../docker-compose.yml) 会启动:
|
||||
仓库根目录维护 3 个独立 compose 文件,按文件名映射部署模式:
|
||||
|
||||
- `frontend`
|
||||
- `backend`
|
||||
- `moldinsight-celery`
|
||||
- 可选:`moldinsight` / `inventory`(独立模块模式)
|
||||
| 模式 | Compose 文件 | 一键命令 |
|
||||
|---|---|---|
|
||||
| unified(默认) | [docker-compose.yml](../../docker-compose.yml) | `docker compose up -d` |
|
||||
| moldinsight-only | [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml) | `docker compose -f docker-compose.moldinsight.yml up -d` |
|
||||
| inventory-only | [docker-compose.inventory.yml](../../docker-compose.inventory.yml) | `docker compose -f docker-compose.inventory.yml up -d` |
|
||||
|
||||
它**不会**再拉起:
|
||||
> 注意:各 service 均未声明 `profiles`,旧 `--profile full/moldinsight/inventory` 写法不再是模式开关(声明了 profiles 的服务在裸 `up` 下不会被选中,会报 `no service selected`);模式切换统一用上表 `-f` 命令。
|
||||
|
||||
不同模式分别包含的服务:
|
||||
|
||||
- **unified**:`frontend` + `backend`(unified 入口)+ `moldinsight-celery`
|
||||
- **moldinsight-only**:`moldinsight`(独立 API)+ `moldinsight-celery`
|
||||
- **inventory-only**:仅 `inventory`
|
||||
|
||||
任一 compose 文件**都不会**再拉起:
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- MinIO
|
||||
- MinIO / RustFS
|
||||
|
||||
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息;前端则由 `frontend` 容器独立提供,并通过同域反代转发到后端。
|
||||
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息;前端则由 `frontend` 容器独立提供(仅 unified 模式包含),通过同域反代转发到后端。
|
||||
|
||||
示例:
|
||||
镜像构建:
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
bash deploy/build.sh # base → backend → frontend(celery 复用 backend 镜像)
|
||||
# 或让 compose 构建:docker compose up -d --build
|
||||
# 注意:docker compose up -d 对本地已有同名镜像不会自动重建,更新代码后需 --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -17,18 +17,20 @@
|
||||
|
||||
对于 Docker Compose 部署,当前主要端口配置来源于:
|
||||
|
||||
- [docker-compose.yml](../../docker-compose.yml)
|
||||
- [docker-compose.yml](../../docker-compose.yml)(unified,默认入口)
|
||||
- [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
|
||||
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)
|
||||
- `.env` / `deploy/.env.example`
|
||||
|
||||
注意:该 compose 文件仅负责项目应用容器,不负责 PostgreSQL / Redis / 对象存储容器。
|
||||
注意:这些 compose 文件仅负责项目应用容器,不负责 PostgreSQL / Redis / 对象存储容器。
|
||||
|
||||
核心环境变量:
|
||||
|
||||
```env
|
||||
FRONTEND_PORT=80
|
||||
BACKEND_PORT=8000
|
||||
MOLDINSIGHT_PORT=8000
|
||||
INVENTORY_PORT=8001
|
||||
FRONTEND_PORT=10003 # unified 模式浏览器入口(前端 Nginx 对外)
|
||||
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署时使用
|
||||
INVENTORY_PORT=10004 # inventory-only 独立部署时使用
|
||||
# 注意:unified backend 不暴露宿主机端口,仅经前端 /api 反代(docker 网络内部 backend:8000 互通)
|
||||
```
|
||||
|
||||
其余基础设施通常为:
|
||||
@@ -50,10 +52,10 @@ RUSTFS_ENDPOINT=http://localhost:9000
|
||||
|
||||
| 变量 / 端口 | 用途 |
|
||||
|---|---|
|
||||
| `FRONTEND_PORT` | 前端 Nginx 宿主机暴露端口 |
|
||||
| `BACKEND_PORT` | unified backend 宿主机暴露端口 |
|
||||
| `MOLDINSIGHT_PORT` | moldinsight-only 独立部署端口 |
|
||||
| `INVENTORY_PORT` | inventory-only 独立部署端口 |
|
||||
| `FRONTEND_PORT`(默认 10003) | 前端 Nginx 宿主机暴露端口(浏览器入口) |
|
||||
| unified backend | **不暴露宿主机端口**——经前端 /api 反代,docker 网络内 `backend:8000` 互通 |
|
||||
| `MOLDINSIGHT_PORT`(默认 10003) | moldinsight-only 独立部署端口 |
|
||||
| `INVENTORY_PORT`(默认 10004) | inventory-only 独立部署端口 |
|
||||
| `DB_PORT` | PostgreSQL 端口 |
|
||||
| `REDIS_PORT` | Redis 端口 |
|
||||
| `9000` | MinIO/RustFS S3 兼容 API |
|
||||
@@ -116,7 +118,8 @@ Compose 通过端口映射暴露服务:
|
||||
- inventory → `${INVENTORY_PORT}:8001`
|
||||
|
||||
当前实际定义见:
|
||||
- [docker-compose.yml](../../docker-compose.yml)
|
||||
- [docker-compose.yml](../../docker-compose.yml)(unified / moldinsight-only)
|
||||
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)(inventory-only 的 `INVENTORY_PORT`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
| 阶段 | 动作 | 状态 |
|
||||
|---|---|---|
|
||||
| 短期 | 方案 A:`--concurrency` 伸缩 + `--max-tasks-per-child` 兜底回收;`cancel_futures=True` 修复重建并发风险 | ✅ 部署参数 2026-09-18 落地(`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 Dockerfile.celery + compose + .env.example) |
|
||||
| 短期 | 方案 A:`--concurrency` 伸缩 + `--max-tasks-per-child` 兜底回收;`cancel_futures=True` 修复重建并发风险 | ✅ 部署参数 2026-09-18 落地(`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 compose + .env.example;2026-09-24 起 worker 与后端共用 `gemold-backend` 镜像、Dockerfile.celery 移除,参数以 compose `command:` 覆盖传递) |
|
||||
| 中期 | 方案 B:`run_occ(op_name, payload)` 接口演进 + 常驻进程池,kill-on-timeout 根治泄漏 | ✅ 2026-09-18 实施完成(见 §5;回归测试 [tests/test_occ_process_pool.py](../../../tests/test_occ_process_pool.py)) |
|
||||
| 长期 | 方案 C:sidecar,仅在出现独立伸缩需求时启动 | 暂不启动 |
|
||||
|
||||
|
||||
@@ -116,9 +116,11 @@ RUSTFS_SECRET_KEY=change-me
|
||||
|
||||
### 3. 启动项目服务
|
||||
|
||||
当前推荐通过:
|
||||
- [docker-compose.yml](../../../docker-compose.yml)
|
||||
- 或 [src/entrypoints/](../../../src/entrypoints/)
|
||||
当前推荐通过(按模式对应不同 compose 文件):
|
||||
- `docker compose up -d`([docker-compose.yml](../../../docker-compose.yml),默认 unified)
|
||||
- `docker compose -f docker-compose.moldinsight.yml up -d`
|
||||
- `docker compose -f docker-compose.inventory.yml up -d`
|
||||
- 或直接 [src/entrypoints/](../../../src/entrypoints/) 入口
|
||||
|
||||
而不是继续使用历史单体 `python src/main.py` 作为默认方式。
|
||||
|
||||
|
||||
+3
-7
@@ -51,17 +51,13 @@ npm run build
|
||||
- [deploy/Dockerfile.frontend](../deploy/Dockerfile.frontend)
|
||||
- [deploy/nginx/frontend.conf](../deploy/nginx/frontend.conf)
|
||||
|
||||
以及根目录 Compose 中的 `frontend` 服务:
|
||||
以及根目录 [docker-compose.yml](../docker-compose.yml)(unified 模式)中的 `frontend` 服务。完整系统一键启动:
|
||||
|
||||
```bash
|
||||
docker compose --profile frontend up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
完整系统:
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
```
|
||||
> 旧 `--profile frontend` 仅起前端的写法已随 compose 拆分移除;前端同域反代依赖 unified backend,推荐整栈启动。前后端分离开发时,前端本地 `npm run dev`、后端直跑 `uvicorn`(见 [LINUX_SETUP.md](../docs/deployment/LINUX_SETUP.md) §6)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,17 @@
|
||||
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
|
||||
<div class="summary-header">
|
||||
<h3>推荐方案</h3>
|
||||
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
|
||||
<div class="tag-row">
|
||||
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
|
||||
<t-tag
|
||||
v-if="currentAxisHint"
|
||||
theme="success"
|
||||
variant="light"
|
||||
:title="`历史 ${currentAxisHint.sample_count} 次相似产品反馈:采纳 ${currentAxisHint.adopted_count} / 拒绝 ${currentAxisHint.rejected_count}`"
|
||||
>
|
||||
📚 历史经验 {{ currentAxisHint.sample_count }} 条
|
||||
</t-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div class="info-item">
|
||||
@@ -126,12 +136,24 @@
|
||||
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
|
||||
💰 成本估算
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canGiveFeedback"
|
||||
type="default"
|
||||
size="small"
|
||||
@click="openFeedbackDialog"
|
||||
title="老师傅经验反馈:标记采纳 / 建议调整 / 拒绝,下一次同指纹产品分析将自动应用"
|
||||
>
|
||||
👍 老师傅反馈
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
|
||||
<div class="summary-header">
|
||||
<h3>3D 预览</h3>
|
||||
<t-tag theme="primary">重点区域</t-tag>
|
||||
<t-tag v-if="candidateSchemes.length > 1" theme="warning" variant="light">
|
||||
当前:{{ selectedScheme?.title || selectedScheme?.scheme_id }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<t-alert v-if="state.previewStatus === 'error'" theme="warning" title="预览加载失败" :message="'HTML 已生成但加载异常,请检查该链接是否可访问:' + selectedHtmlFile" style="margin-bottom: var(--space-3);" />
|
||||
<iframe
|
||||
@@ -535,6 +557,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</t-loading>
|
||||
|
||||
<HumanFeedbackDialog
|
||||
v-model:visible="state.feedbackDialogVisible"
|
||||
:task-id="(route.params.taskId as string)"
|
||||
:scheme-id="selectedScheme?.scheme_id || ''"
|
||||
:scheme-axis="selectedScheme?.axis || 'Z'"
|
||||
:scheme-title="selectedScheme?.title || selectedScheme?.scheme_id || ''"
|
||||
:fingerprint="state.hintsFingerprint"
|
||||
:score-at-submit="selectedScheme?.score"
|
||||
:confidence-at-submit="selectedScheme?.confidence_score"
|
||||
@submitted="onFeedbackSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -543,8 +577,10 @@ import { reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { moldinsightApi } from '@/shared/api-client'
|
||||
import { handleApiError, addNotification } from '@/shared/notification'
|
||||
import { formatDateTime, formatNumber } from '@/shared/utils'
|
||||
import HumanFeedbackDialog from './components/HumanFeedbackDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -573,9 +609,12 @@ interface Scheme {
|
||||
summary?: string
|
||||
reason?: string
|
||||
score?: number
|
||||
score_breakdown?: { manufacturability?: number; parting_quality?: number }
|
||||
confidence_score?: number
|
||||
score_breakdown?: { manufacturability?: number; parting_quality?: number; human_hint_bonus?: number }
|
||||
mold_structure_type?: string
|
||||
offset_label?: string
|
||||
axis?: string
|
||||
method?: string
|
||||
cavity_data?: CavityData
|
||||
key_info?: KeyInfo
|
||||
html_file?: string
|
||||
@@ -724,6 +763,18 @@ const state = reactive({
|
||||
costLoading: false,
|
||||
costError: '',
|
||||
costResult: null as CostEstimate | null,
|
||||
// D17 Human-in-Loop:老师傅经验反馈
|
||||
feedbackDialogVisible: false,
|
||||
hintsFingerprint: {} as Record<string, string>,
|
||||
hints: [] as Array<{
|
||||
scheme_axis: string
|
||||
adopted_count: number
|
||||
rejected_count: number
|
||||
adjust_count: number
|
||||
confidence: number
|
||||
weight: number
|
||||
sample_count: number
|
||||
}>,
|
||||
})
|
||||
|
||||
const camSteelOptions = [
|
||||
@@ -777,12 +828,63 @@ const createProductFromAnalysis = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// D17 Human-in-Loop:经验反馈
|
||||
const taskId = computed<string>(() => (route.params.taskId as string) || '')
|
||||
|
||||
const hintsByAxis = computed<Record<string, (typeof state.hints)[number]>>(() => {
|
||||
const map: Record<string, (typeof state.hints)[number]> = {}
|
||||
for (const h of state.hints) {
|
||||
map[h.scheme_axis] = h
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const currentAxisHint = computed<(typeof state.hints)[number] | undefined>(() => {
|
||||
const axis = selectedScheme.value?.axis
|
||||
if (!axis) return undefined
|
||||
return hintsByAxis.value[axis]
|
||||
})
|
||||
|
||||
const canGiveFeedback = computed(() => {
|
||||
const u: any = appStore.user
|
||||
if (!u) return false
|
||||
if (u.is_superuser) return true
|
||||
const roles = u.roles as Array<{ code: string }> | undefined
|
||||
return !!roles?.some(r => r.code === 'process_engineer')
|
||||
})
|
||||
|
||||
const loadExperienceHints = async () => {
|
||||
if (!taskId.value) return
|
||||
try {
|
||||
const data = await moldinsightApi.getExperienceHints(taskId.value)
|
||||
state.hints = (data.hints || []) as typeof state.hints
|
||||
state.hintsFingerprint = data.fingerprint || {}
|
||||
} catch (e) {
|
||||
// 不阻塞主流程:拉取失败时静默退化(按钮仍可点击,新反馈走 POST 写入)
|
||||
console.warn('拉取经验 hints 失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
if (!selectedScheme.value?.scheme_id) {
|
||||
addNotification('当前方案未确定,无法反馈', 'error')
|
||||
return
|
||||
}
|
||||
state.feedbackDialogVisible = true
|
||||
}
|
||||
|
||||
const onFeedbackSubmitted = async () => {
|
||||
addNotification('反馈已生效,正在刷新经验角标', 'success')
|
||||
await loadExperienceHints()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!appStore.user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
loadTask()
|
||||
loadExperienceHints()
|
||||
})
|
||||
|
||||
const getPriorityText = (priority: string) => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<t-dialog
|
||||
:visible="visible"
|
||||
@update:visible="(v: boolean) => emit('update:visible', v)"
|
||||
header="老师傅经验反馈"
|
||||
:close-on-overlay-click="true"
|
||||
width="540px"
|
||||
>
|
||||
<div v-if="schemeTitle" class="context-block">
|
||||
<div class="context-row">
|
||||
<span class="context-label">方案</span>
|
||||
<span class="context-value">{{ schemeTitle }}</span>
|
||||
</div>
|
||||
<div v-if="hasFingerprint" class="context-row">
|
||||
<span class="context-label">产品指纹</span>
|
||||
<span class="context-value">{{ formatFingerprint() }}</span>
|
||||
</div>
|
||||
<div class="context-row">
|
||||
<span class="context-label">本次应用</span>
|
||||
<span class="context-value hint-meta">写后即被下次同指纹分析消费(D17 闭环)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t-form label-width="80px">
|
||||
<t-form-item label="反馈">
|
||||
<t-radio-group v-model="form.feedback_status">
|
||||
<t-radio-button value="adopted">✅ 采纳</t-radio-button>
|
||||
<t-radio-button value="adjust">✏️ 建议调整</t-radio-button>
|
||||
<t-radio-button value="rejected">✕ 拒绝</t-radio-button>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item v-if="form.feedback_status === 'adjust'" label="调整建议">
|
||||
<t-textarea
|
||||
v-model="form.adjust_suggestion"
|
||||
placeholder="具体怎么调?(如:分型面偏上 0.5mm / 增加滑块位置 / 改水路直径)"
|
||||
:maxlength="2000"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item label="原因">
|
||||
<t-textarea
|
||||
v-model="form.feedback_reason"
|
||||
placeholder="为什么这样判断?(可选,便于团队理解)"
|
||||
:maxlength="2000"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
|
||||
<template #footer>
|
||||
<t-button theme="default" @click="cancel" :disabled="submitting">取消</t-button>
|
||||
<t-button theme="primary" :loading="submitting" @click="submit">提交反馈</t-button>
|
||||
</template>
|
||||
</t-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, computed } from 'vue'
|
||||
import { moldinsightApi } from '@/shared/api-client'
|
||||
import { addNotification } from '@/shared/notification'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
taskId: string
|
||||
schemeId: string
|
||||
schemeAxis: string
|
||||
schemeTitle: string
|
||||
fingerprint: Record<string, string>
|
||||
scoreAtSubmit?: number
|
||||
confidenceAtSubmit?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
interface FeedbackForm {
|
||||
feedback_status: 'adopted' | 'adjust' | 'rejected'
|
||||
feedback_reason: string
|
||||
adjust_suggestion: string
|
||||
}
|
||||
|
||||
const form = reactive<FeedbackForm>({
|
||||
feedback_status: 'adopted',
|
||||
feedback_reason: '',
|
||||
adjust_suggestion: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const hasFingerprint = computed(() => {
|
||||
return Boolean(props.fingerprint && Object.keys(props.fingerprint).length > 0)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
// 打开时重置表单
|
||||
form.feedback_status = 'adopted'
|
||||
form.feedback_reason = ''
|
||||
form.adjust_suggestion = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function cancel() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function formatFingerprint(): string {
|
||||
const f = props.fingerprint || {}
|
||||
const items = [
|
||||
f.bbox_aspect,
|
||||
f.volume_bucket,
|
||||
f.face_bucket,
|
||||
f.material_family,
|
||||
f.is_foam,
|
||||
].filter(Boolean)
|
||||
return items.join(' / ') || '—'
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.taskId || !props.schemeId) {
|
||||
addNotification('任务或方案标识缺失', 'error')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await moldinsightApi.submitExperienceFeedback(props.taskId, {
|
||||
scheme_id: props.schemeId,
|
||||
feedback_status: form.feedback_status,
|
||||
feedback_reason: form.feedback_reason || undefined,
|
||||
adjust_suggestion:
|
||||
form.feedback_status === 'adjust' ? form.adjust_suggestion || undefined : undefined,
|
||||
score_at_submit: typeof props.scoreAtSubmit === 'number' ? props.scoreAtSubmit : undefined,
|
||||
confidence_at_submit:
|
||||
typeof props.confidenceAtSubmit === 'number'
|
||||
? props.confidenceAtSubmit
|
||||
: undefined,
|
||||
})
|
||||
addNotification('反馈已提交,下次同指纹产品分析将自动应用', 'success')
|
||||
emit('submitted')
|
||||
emit('update:visible', false)
|
||||
} catch (e: any) {
|
||||
addNotification(e?.message || '反馈提交失败', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-block {
|
||||
background: var(--bg-secondary, #f5f7fa);
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color, #e7e7e7);
|
||||
}
|
||||
.context-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.context-label {
|
||||
color: var(--text-secondary, #888);
|
||||
min-width: 70px;
|
||||
}
|
||||
.context-value {
|
||||
color: var(--text-primary, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hint-meta {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -403,4 +403,47 @@ export const moldinsightApi = {
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
// D17 Human-in-Loop:老师傅经验反馈(写入即消费闭环)
|
||||
getExperienceHints(taskId: string) {
|
||||
return apiRequest<{
|
||||
task_id: string
|
||||
stp_file_id: number
|
||||
material_name: string
|
||||
is_foam: boolean
|
||||
fingerprint: Record<string, string>
|
||||
hints: Array<{
|
||||
scheme_axis: string
|
||||
adopted_count: number
|
||||
rejected_count: number
|
||||
adjust_count: number
|
||||
confidence: number
|
||||
weight: number
|
||||
sample_count: number
|
||||
}>
|
||||
}>(`/api/tasks/${taskId}/experience-hints`)
|
||||
},
|
||||
|
||||
submitExperienceFeedback(
|
||||
taskId: string,
|
||||
data: {
|
||||
scheme_id: string
|
||||
feedback_status: 'adopted' | 'adjust' | 'rejected'
|
||||
feedback_reason?: string
|
||||
adjust_suggestion?: string
|
||||
confidence_at_submit?: number
|
||||
score_at_submit?: number
|
||||
}
|
||||
) {
|
||||
return apiRequest<{
|
||||
id: number
|
||||
scheme_id: string
|
||||
scheme_axis: string
|
||||
feedback_status: string
|
||||
created_at: string
|
||||
}>(`/api/tasks/${taskId}/experience-feedback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+250
-926
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
"""add experience_feedback
|
||||
|
||||
Human-in-Loop 闭环(D17,2026-09):老师傅对系统推荐方案给出'采纳 / 调整 / 拒绝'
|
||||
反馈,按'产品指纹 + 工艺参数'为索引跨任务匹配;下次同指纹产品分析自动消费。
|
||||
- 新增 experience_feedback 表(方案级反馈)
|
||||
- 跨模块裸 FK(user_id / processing_task_id / stp_file_id)
|
||||
- 复合索引 (stp_file_id, scheme_axis, feedback_status) 用于按方向聚合采纳计数
|
||||
- GIN 索引(PG only)用于 fingerprint JSON 字段检索
|
||||
|
||||
Revision ID: b7d1f4a92c3e
|
||||
Revises: a3f8c2d91e47
|
||||
Create Date: 2026-09-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b7d1f4a92c3e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a3f8c2d91e47'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'experience_feedback',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('processing_task_id', sa.Integer(), nullable=False),
|
||||
sa.Column('stp_file_id', sa.Integer(), nullable=False),
|
||||
sa.Column('scheme_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('scheme_axis', sa.String(length=1), nullable=False),
|
||||
sa.Column('scheme_method', sa.String(length=50), nullable=True),
|
||||
sa.Column('feedback_status', sa.String(length=20), nullable=False),
|
||||
sa.Column('feedback_reason', sa.Text(), nullable=True),
|
||||
sa.Column('adjust_suggestion', sa.Text(), nullable=True),
|
||||
sa.Column('process_params_snapshot', sa.JSON(), nullable=True),
|
||||
sa.Column('fingerprint', sa.JSON(), nullable=False),
|
||||
sa.Column('confidence_at_submit', sa.Float(), nullable=True),
|
||||
sa.Column('score_at_submit', sa.Float(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role_code', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(
|
||||
['processing_task_id'], ['processing_tasks.id'], ondelete='CASCADE'
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['stp_file_id'], ['stp_files.id'], ondelete='CASCADE'
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], ondelete='RESTRICT'
|
||||
),
|
||||
)
|
||||
op.create_index(op.f('ix_experience_feedback_id'), 'experience_feedback', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_processing_task_id'), 'experience_feedback', ['processing_task_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_stp_file_id'), 'experience_feedback', ['stp_file_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_scheme_id'), 'experience_feedback', ['scheme_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_feedback_status'), 'experience_feedback', ['feedback_status'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_user_id'), 'experience_feedback', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_created_at'), 'experience_feedback', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_expires_at'), 'experience_feedback', ['expires_at'], unique=False)
|
||||
op.create_index(
|
||||
op.f('ix_experience_feedback_stp_axis_status'),
|
||||
'experience_feedback',
|
||||
['stp_file_id', 'scheme_axis', 'feedback_status'],
|
||||
unique=False,
|
||||
)
|
||||
# 注意:此处原本计划给 fingerprint 建 GIN 索引("jsonb_path_query 类查询"),
|
||||
# 但该列是 sa.JSON() → PG 的 json 类型,而 GIN 只支持 jsonb(json 无默认
|
||||
# 操作符类,CREATE INDEX 直接报 UndefinedObject)。且代码侧并无 JSON 包含
|
||||
# 查询——聚合过滤在 Python 侧进行,DB 侧走上方 (stp_file_id, scheme_axis,
|
||||
# feedback_status) 复合索引。故不建此索引;未来若真需要 JSON 检索,应先把
|
||||
# 列迁为 jsonb 再建 GIN。首次对生产库执行时曾因此报错回滚(2026-09-26)。
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if op.get_bind().dialect.name == 'postgresql':
|
||||
op.execute("DROP INDEX IF EXISTS ix_experience_feedback_fingerprint_gin")
|
||||
op.drop_index(op.f('ix_experience_feedback_stp_axis_status'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_expires_at'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_created_at'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_user_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_feedback_status'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_scheme_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_stp_file_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_processing_task_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_id'), table_name='experience_feedback')
|
||||
op.drop_table('experience_feedback')
|
||||
+476
-1183
File diff suppressed because it is too large
Load Diff
@@ -10,28 +10,22 @@ sys.path.insert(0, str(src_root))
|
||||
os.chdir(Path(__file__).parent.parent.parent)
|
||||
|
||||
from shared.app_factory import create_app
|
||||
from moldinsight.storage.init_storage import rustfs_startup_hook
|
||||
|
||||
|
||||
def _register_routers(app):
|
||||
"""注册 moldinsight 业务路由"""
|
||||
"""注册 moldinsight 业务路由(/api 聚合 + HTML 报告代理,收敛于 moldinsight.api)"""
|
||||
try:
|
||||
from moldinsight.api import router as moldinsight_router
|
||||
app.include_router(moldinsight_router, prefix="/api")
|
||||
from moldinsight.api import register_moldinsight_routers
|
||||
register_moldinsight_routers(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] MoldInsight 路由: {e}")
|
||||
|
||||
# D11:HTML 报告代理挂根路径,URL 形状与原 StaticFiles 保持一致(/html/{filename})
|
||||
try:
|
||||
from moldinsight.api.html_report_router import include_into as include_html_report
|
||||
include_html_report(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] HTML 报告路由: {e}")
|
||||
|
||||
|
||||
app = create_app(
|
||||
title="Gemold - 模具分析引擎",
|
||||
service_name="moldinsight",
|
||||
connect_rustfs=True,
|
||||
serve_frontend_static=False,
|
||||
startup_hooks=[rustfs_startup_hook],
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -10,23 +10,17 @@ sys.path.insert(0, str(src_root))
|
||||
os.chdir(Path(__file__).parent.parent.parent)
|
||||
|
||||
from shared.app_factory import create_app
|
||||
from moldinsight.storage.init_storage import rustfs_startup_hook
|
||||
|
||||
|
||||
def _register_routers(app):
|
||||
"""注册 unified 业务路由"""
|
||||
"""注册 unified 业务路由(moldinsight 单点聚合 + inventory)"""
|
||||
try:
|
||||
from moldinsight.api import router as moldinsight_router
|
||||
app.include_router(moldinsight_router, prefix="/api")
|
||||
from moldinsight.api import register_moldinsight_routers
|
||||
register_moldinsight_routers(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] MoldInsight 路由: {e}")
|
||||
|
||||
# D11:HTML 报告代理挂根路径,URL 形状与原 StaticFiles 保持一致(/html/{filename})
|
||||
try:
|
||||
from moldinsight.api.html_report_router import include_into as include_html_report
|
||||
include_html_report(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] HTML 报告路由: {e}")
|
||||
|
||||
try:
|
||||
from inventory.api import inventory_router
|
||||
app.include_router(inventory_router)
|
||||
@@ -37,7 +31,7 @@ def _register_routers(app):
|
||||
app = create_app(
|
||||
title="Gemold - Unified Backend",
|
||||
service_name="unified",
|
||||
connect_rustfs=True,
|
||||
serve_frontend_static=False,
|
||||
startup_hooks=[rustfs_startup_hook],
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -9,17 +9,15 @@
|
||||
|
||||
路由前缀: /api/customers
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer
|
||||
from ..schemas import CustomerCreate, CustomerResponse
|
||||
from ..services.master_data_service import master_data_service
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
|
||||
@@ -32,12 +30,7 @@ async def list_customers(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
query = select(Customer).where(Customer.is_active == True)
|
||||
if search:
|
||||
query = query.where(Customer.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [CustomerResponse.from_orm(c) for c in result.scalars().all()]
|
||||
return await master_data_service.list_customers(db_session, skip, limit, search)
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerResponse, status_code=201)
|
||||
@@ -46,15 +39,7 @@ async def create_customer(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
data = customer_data.dict()
|
||||
if not data.get("code"):
|
||||
data["code"] = f"C{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
customer = Customer(**data)
|
||||
db_session.add(customer)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(customer)
|
||||
return CustomerResponse.from_orm(customer)
|
||||
return await master_data_service.create_customer(db_session, customer_data, current_user)
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerResponse)
|
||||
@@ -64,17 +49,7 @@ async def update_customer(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
for key, value in customer_data.dict().items():
|
||||
setattr(customer, key, value)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.refresh(customer)
|
||||
return CustomerResponse.from_orm(customer)
|
||||
return await master_data_service.update_customer(db_session, customer_id, customer_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{customer_id}")
|
||||
@@ -83,11 +58,4 @@ async def delete_customer(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
customer.is_active = False
|
||||
await db_session.flush()
|
||||
return {"message": "客户已删除"}
|
||||
return await master_data_service.delete_customer(db_session, customer_id, current_user)
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder
|
||||
from ..services.dashboard_service import dashboard_service
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
|
||||
|
||||
@@ -26,55 +25,4 @@ async def get_dashboard(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
material_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
|
||||
) or 0
|
||||
finished_product_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
|
||||
) or 0
|
||||
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True))
|
||||
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True))
|
||||
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True))
|
||||
|
||||
total_stock = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
total_value = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity * Product.cost_price))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
|
||||
pending_purchase = await db_session.scalar(
|
||||
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
|
||||
)
|
||||
pending_sales = await db_session.scalar(
|
||||
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
|
||||
)
|
||||
|
||||
low_stock_products = await db_session.execute(
|
||||
select(Product, Inventory)
|
||||
.join(Inventory, Product.id == Inventory.product_id)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Inventory.quantity <= Product.min_stock)
|
||||
.limit(10)
|
||||
)
|
||||
low_stock = [
|
||||
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
|
||||
for p, i in low_stock_products.all()
|
||||
]
|
||||
|
||||
return {
|
||||
"finished_product_count": finished_product_count,
|
||||
"material_count": material_count,
|
||||
"supplier_count": supplier_count,
|
||||
"customer_count": customer_count,
|
||||
"warehouse_count": warehouse_count,
|
||||
"total_stock": total_stock,
|
||||
"total_value": round(total_value, 2),
|
||||
"pending_purchase": pending_purchase,
|
||||
"pending_sales": pending_sales,
|
||||
"low_stock_products": low_stock
|
||||
}
|
||||
return await dashboard_service.get_dashboard(db_session)
|
||||
|
||||
@@ -8,15 +8,13 @@
|
||||
|
||||
路由前缀: /api/materials
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from typing import Optional, List
|
||||
from typing import List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
@@ -24,22 +22,11 @@ from ..schemas import (
|
||||
MaterialSupplierResponse,
|
||||
MaterialPriceTrendResponse
|
||||
)
|
||||
from ..services.material_service import material_service
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["物料管理"])
|
||||
|
||||
|
||||
async def _get_product(db_session: AsyncSession, product_id: int) -> Product:
|
||||
result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/{product_id}/price-history", response_model=MaterialPriceHistoryResponse, status_code=201)
|
||||
async def add_material_price_history(
|
||||
product_id: int,
|
||||
@@ -47,42 +34,7 @@ async def add_material_price_history(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
# 检查供应商是否存在
|
||||
if price_data.supplier_id:
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == price_data.supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
if not supplier_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="供应商不存在")
|
||||
|
||||
price_history = MaterialPriceHistory(
|
||||
product_id=product_id,
|
||||
price=price_data.price,
|
||||
supplier_id=price_data.supplier_id,
|
||||
remark=price_data.remark
|
||||
)
|
||||
db_session.add(price_history)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(price_history)
|
||||
|
||||
# 更新产品的成本价格为最新价格
|
||||
product.cost_price = price_data.price
|
||||
await db_session.flush()
|
||||
|
||||
return MaterialPriceHistoryResponse(
|
||||
id=price_history.id,
|
||||
product_id=price_history.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
price=price_history.price,
|
||||
effective_date=price_history.effective_date,
|
||||
supplier_id=price_history.supplier_id,
|
||||
supplier_name=price_history.supplier.name if price_history.supplier else None,
|
||||
remark=price_history.remark,
|
||||
created_at=price_history.created_at
|
||||
)
|
||||
return await material_service.add_price_history(db_session, product_id, price_data, current_user)
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-history", response_model=List[MaterialPriceHistoryResponse])
|
||||
@@ -92,31 +44,7 @@ async def get_material_price_history(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(limit)
|
||||
)
|
||||
price_history_list = result.scalars().all()
|
||||
|
||||
return [
|
||||
MaterialPriceHistoryResponse(
|
||||
id=ph.id,
|
||||
product_id=ph.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
price=ph.price,
|
||||
effective_date=ph.effective_date,
|
||||
supplier_id=ph.supplier_id,
|
||||
supplier_name=ph.supplier.name if ph.supplier else None,
|
||||
remark=ph.remark,
|
||||
created_at=ph.created_at
|
||||
)
|
||||
for ph in price_history_list
|
||||
]
|
||||
return await material_service.get_price_history(db_session, product_id, limit)
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-trend", response_model=MaterialPriceTrendResponse)
|
||||
@@ -126,45 +54,7 @@ async def get_material_price_trend(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
# 计算价格趋势
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(months)
|
||||
)
|
||||
price_history_list = result.scalars().all()
|
||||
|
||||
if not price_history_list:
|
||||
raise HTTPException(status_code=404, detail="无价格历史记录")
|
||||
|
||||
prices = [ph.price for ph in reversed(price_history_list)]
|
||||
dates = [ph.effective_date for ph in reversed(price_history_list)]
|
||||
|
||||
# 计算价格变化
|
||||
current_price = price_history_list[0].price
|
||||
first_price = price_history_list[-1].price
|
||||
price_change = current_price - first_price
|
||||
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
|
||||
|
||||
return MaterialPriceTrendResponse(
|
||||
product_id=product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
current_price=current_price,
|
||||
price_change=round(price_change, 2),
|
||||
price_change_percent=round(price_change_percent, 2),
|
||||
price_history=[
|
||||
{
|
||||
"date": ph.effective_date,
|
||||
"price": ph.price,
|
||||
"supplier_name": ph.supplier.name if ph.supplier else None
|
||||
}
|
||||
for ph in price_history_list
|
||||
]
|
||||
)
|
||||
return await material_service.get_price_trend(db_session, product_id, months)
|
||||
|
||||
|
||||
@router.post("/{product_id}/suppliers", response_model=MaterialSupplierResponse, status_code=201)
|
||||
@@ -174,63 +64,7 @@ async def add_material_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
# 检查供应商是否存在
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == supplier_data.supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=400, detail="供应商不存在")
|
||||
|
||||
# 检查是否已存在关联
|
||||
existing = await db_session.execute(
|
||||
select(MaterialSupplier)
|
||||
.where(
|
||||
MaterialSupplier.product_id == product_id,
|
||||
MaterialSupplier.supplier_id == supplier_data.supplier_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
|
||||
|
||||
# 如果设置为主要供应商,将其他供应商设置为非主要
|
||||
if supplier_data.is_primary:
|
||||
await db_session.execute(
|
||||
MaterialSupplier.__table__.update()
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
|
||||
material_supplier = MaterialSupplier(
|
||||
product_id=product_id,
|
||||
supplier_id=supplier_data.supplier_id,
|
||||
is_primary=supplier_data.is_primary,
|
||||
contact_person=supplier_data.contact_person,
|
||||
contact_phone=supplier_data.contact_phone,
|
||||
lead_time=supplier_data.lead_time,
|
||||
min_order_quantity=supplier_data.min_order_quantity
|
||||
)
|
||||
db_session.add(material_supplier)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(material_supplier)
|
||||
|
||||
return MaterialSupplierResponse(
|
||||
id=material_supplier.id,
|
||||
product_id=material_supplier.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
supplier_id=material_supplier.supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
is_primary=material_supplier.is_primary,
|
||||
contact_person=material_supplier.contact_person,
|
||||
contact_phone=material_supplier.contact_phone,
|
||||
lead_time=material_supplier.lead_time,
|
||||
min_order_quantity=material_supplier.min_order_quantity,
|
||||
created_at=material_supplier.created_at,
|
||||
updated_at=material_supplier.updated_at
|
||||
)
|
||||
return await material_service.add_material_supplier(db_session, product_id, supplier_data, current_user)
|
||||
|
||||
|
||||
@router.get("/{product_id}/suppliers", response_model=List[MaterialSupplierResponse])
|
||||
@@ -239,33 +73,7 @@ async def get_material_suppliers(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier)
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
supplier_list = result.scalars().all()
|
||||
|
||||
return [
|
||||
MaterialSupplierResponse(
|
||||
id=ms.id,
|
||||
product_id=ms.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
supplier_id=ms.supplier_id,
|
||||
supplier_name=ms.supplier.name if ms.supplier else None,
|
||||
is_primary=ms.is_primary,
|
||||
contact_person=ms.contact_person,
|
||||
contact_phone=ms.contact_phone,
|
||||
lead_time=ms.lead_time,
|
||||
min_order_quantity=ms.min_order_quantity,
|
||||
created_at=ms.created_at,
|
||||
updated_at=ms.updated_at
|
||||
)
|
||||
for ms in supplier_list
|
||||
]
|
||||
return await material_service.get_material_suppliers(db_session, product_id)
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}")
|
||||
@@ -274,17 +82,7 @@ async def remove_material_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
|
||||
)
|
||||
material_supplier = result.scalar_one_or_none()
|
||||
if not material_supplier:
|
||||
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
|
||||
|
||||
await db_session.delete(material_supplier)
|
||||
await db_session.flush()
|
||||
|
||||
return {"message": "物料供应商关联已删除"}
|
||||
return await material_service.remove_material_supplier(db_session, supplier_id, current_user)
|
||||
|
||||
|
||||
@router.get("/suppliers/{supplier_id}/materials", response_model=List[MaterialSupplierResponse])
|
||||
@@ -293,36 +91,4 @@ async def get_supplier_materials(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
# 检查供应商是否存在
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier)
|
||||
.where(MaterialSupplier.supplier_id == supplier_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
material_list = result.scalars().all()
|
||||
|
||||
return [
|
||||
MaterialSupplierResponse(
|
||||
id=ms.id,
|
||||
product_id=ms.product_id,
|
||||
product_sku=ms.product.sku if ms.product else None,
|
||||
product_name=ms.product.name if ms.product else None,
|
||||
supplier_id=ms.supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
is_primary=ms.is_primary,
|
||||
contact_person=ms.contact_person,
|
||||
contact_phone=ms.contact_phone,
|
||||
lead_time=ms.lead_time,
|
||||
min_order_quantity=ms.min_order_quantity,
|
||||
created_at=ms.created_at,
|
||||
updated_at=ms.updated_at
|
||||
)
|
||||
for ms in material_list
|
||||
]
|
||||
return await material_service.get_supplier_materials(db_session, supplier_id)
|
||||
|
||||
@@ -9,67 +9,20 @@
|
||||
|
||||
路由前缀: /api/products
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_, func, delete
|
||||
from typing import Optional, List, Dict
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from inventory.models import Product, ProductMaterial
|
||||
from ..schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductBOMUpdate,
|
||||
ProductBOMResponse,
|
||||
ProductMaterialItemResponse
|
||||
)
|
||||
from ..schemas import ProductBOMResponse, ProductBOMUpdate, ProductCreate, ProductResponse
|
||||
from ..services.product_service import product_service
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
|
||||
|
||||
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
|
||||
if not product_ids:
|
||||
return {}
|
||||
result = await db_session.execute(
|
||||
select(
|
||||
ProductMaterial.finished_product_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
Product.cost_price * ProductMaterial.quantity
|
||||
),
|
||||
0
|
||||
)
|
||||
)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(product_ids))
|
||||
.group_by(ProductMaterial.finished_product_id)
|
||||
)
|
||||
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
|
||||
|
||||
|
||||
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
sku=product.sku,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
category=product.category,
|
||||
unit=product.unit,
|
||||
item_type=product.item_type,
|
||||
cost_price=Decimal(str(product.cost_price or 0)),
|
||||
sale_price=Decimal(str(product.sale_price or 0)),
|
||||
min_stock=product.min_stock,
|
||||
max_stock=product.max_stock,
|
||||
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
|
||||
is_active=product.is_active,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ProductResponse])
|
||||
async def list_products(
|
||||
@@ -81,21 +34,7 @@ async def list_products(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
query = select(Product).where(Product.is_active == True)
|
||||
|
||||
if search:
|
||||
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||||
if category:
|
||||
query = query.where(Product.category == category)
|
||||
if item_type:
|
||||
query = query.where(Product.item_type == item_type)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
products = result.scalars().all()
|
||||
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
|
||||
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
|
||||
return await product_service.list_products(db_session, skip, limit, search, category, item_type)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductResponse, status_code=201)
|
||||
@@ -104,21 +43,7 @@ async def create_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if product_data.item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product_dict = product_data.dict()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
product = Product(**product_dict)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
return await product_service.create_product(db_session, product_data, current_user)
|
||||
|
||||
|
||||
@router.post("/from-task/{task_id}", response_model=ProductResponse, status_code=201)
|
||||
@@ -127,62 +52,7 @@ async def create_product_from_task(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""从模具分析任务创建进销存成品,回写 stp_files.product_id(P2-1)"""
|
||||
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="分析任务不存在")
|
||||
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
|
||||
stp_file = stp_result.scalar_one_or_none()
|
||||
if not stp_file:
|
||||
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
|
||||
|
||||
# 已关联成品则直接返回(幂等)
|
||||
if stp_file.product_id:
|
||||
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
|
||||
product = existed.scalar_one_or_none()
|
||||
if product:
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
# 生成唯一 SKU:MI{stp_file_id},冲突则追加序号
|
||||
base_sku = f"MI{stp_file_id}"
|
||||
sku = base_sku
|
||||
n = 1
|
||||
while True:
|
||||
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
|
||||
if not conflict.scalar_one_or_none():
|
||||
break
|
||||
n += 1
|
||||
sku = f"{base_sku}-{n}"
|
||||
|
||||
name = Path(stp_file.original_filename or f"mold_{stp_file_id}").stem or f"模具分析-{stp_file_id}"
|
||||
desc_parts = []
|
||||
if stp_file.volume:
|
||||
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
|
||||
if stp_file.product_weight:
|
||||
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
|
||||
if stp_file.surface_area:
|
||||
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
|
||||
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
|
||||
|
||||
product = Product(
|
||||
sku=sku,
|
||||
name=name,
|
||||
description=description,
|
||||
category="模具成品",
|
||||
unit="件",
|
||||
item_type="finished",
|
||||
cost_price=0,
|
||||
sale_price=0,
|
||||
min_stock=0,
|
||||
max_stock=0,
|
||||
)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
stp_file.product_id = product.id
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
return await product_service.create_product_from_task(db_session, task_id, current_user)
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductResponse)
|
||||
@@ -192,25 +62,7 @@ async def update_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Product).where(Product.id == product_id))
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product_data.item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
|
||||
product_dict = product_data.dict()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
|
||||
for key, value in product_dict.items():
|
||||
setattr(product, key, value)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
|
||||
return _build_product_response(product, material_cost_map.get(product.id, 0))
|
||||
return await product_service.update_product(db_session, product_id, product_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{product_id}")
|
||||
@@ -219,14 +71,7 @@ async def delete_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
result = await db_session.execute(select(Product).where(Product.id == product_id))
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
product.is_active = False
|
||||
await db_session.flush()
|
||||
return {"message": "产品已删除"}
|
||||
return await product_service.delete_product(db_session, product_id, current_user)
|
||||
|
||||
|
||||
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
|
||||
@@ -235,44 +80,7 @@ async def get_product_bom(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id == product_id)
|
||||
.order_by(ProductMaterial.id.asc())
|
||||
)
|
||||
|
||||
items: List[ProductMaterialItemResponse] = []
|
||||
total_material_cost = Decimal("0")
|
||||
for bom, material in bom_result.all():
|
||||
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
|
||||
total_material_cost += line_cost
|
||||
items.append(
|
||||
ProductMaterialItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
quantity=Decimal(str(bom.quantity)),
|
||||
unit_cost=Decimal(str(material.cost_price or 0)),
|
||||
line_cost=line_cost,
|
||||
)
|
||||
)
|
||||
|
||||
return ProductBOMResponse(
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
total_material_cost=total_material_cost,
|
||||
items=items,
|
||||
)
|
||||
return await product_service.get_product_bom(db_session, product_id)
|
||||
|
||||
|
||||
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
|
||||
@@ -282,46 +90,4 @@ async def replace_product_bom(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
material_ids = [item.material_id for item in payload.items]
|
||||
if len(material_ids) != len(set(material_ids)):
|
||||
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
|
||||
|
||||
if material_ids:
|
||||
material_result = await db_session.execute(
|
||||
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
|
||||
)
|
||||
materials = material_result.scalars().all()
|
||||
material_map = {m.id: m for m in materials}
|
||||
if len(material_map) != len(material_ids):
|
||||
raise HTTPException(status_code=400, detail="存在无效物料")
|
||||
invalid_materials = [m.name for m in materials if m.item_type != "material"]
|
||||
if invalid_materials:
|
||||
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
|
||||
else:
|
||||
material_map = {}
|
||||
|
||||
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||||
|
||||
for item in payload.items:
|
||||
if item.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||||
db_session.add(
|
||||
ProductMaterial(
|
||||
finished_product_id=product_id,
|
||||
material_product_id=item.material_id,
|
||||
quantity=item.quantity,
|
||||
loss_rate=item.loss_rate,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.flush()
|
||||
return await get_product_bom(product_id, db_session, current_user)
|
||||
return await product_service.replace_product_bom(db_session, product_id, payload, current_user)
|
||||
|
||||
@@ -9,17 +9,15 @@
|
||||
|
||||
路由前缀: /api/suppliers
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Supplier
|
||||
from ..schemas import SupplierCreate, SupplierResponse
|
||||
from ..services.master_data_service import master_data_service
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
|
||||
|
||||
@@ -32,12 +30,7 @@ async def list_suppliers(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
query = select(Supplier).where(Supplier.is_active == True)
|
||||
if search:
|
||||
query = query.where(Supplier.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [SupplierResponse.from_orm(s) for s in result.scalars().all()]
|
||||
return await master_data_service.list_suppliers(db_session, skip, limit, search)
|
||||
|
||||
|
||||
@router.post("", response_model=SupplierResponse, status_code=201)
|
||||
@@ -46,15 +39,7 @@ async def create_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
data = supplier_data.dict()
|
||||
if not data.get("code"):
|
||||
data["code"] = f"S{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
supplier = Supplier(**data)
|
||||
db_session.add(supplier)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(supplier)
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
return await master_data_service.create_supplier(db_session, supplier_data, current_user)
|
||||
|
||||
|
||||
@router.put("/{supplier_id}", response_model=SupplierResponse)
|
||||
@@ -64,17 +49,7 @@ async def update_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
|
||||
supplier = result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
for key, value in supplier_data.dict().items():
|
||||
setattr(supplier, key, value)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.refresh(supplier)
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
return await master_data_service.update_supplier(db_session, supplier_id, supplier_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
@@ -83,11 +58,4 @@ async def delete_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
|
||||
supplier = result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
supplier.is_active = False
|
||||
await db_session.flush()
|
||||
return {"message": "供应商已删除"}
|
||||
return await master_data_service.delete_supplier(db_session, supplier_id, current_user)
|
||||
|
||||
@@ -9,15 +9,13 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Warehouse
|
||||
from ..schemas import WarehouseCreate, WarehouseResponse
|
||||
from ..services.master_data_service import master_data_service
|
||||
|
||||
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
|
||||
|
||||
@@ -27,10 +25,7 @@ async def list_warehouses(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
|
||||
)
|
||||
return [WarehouseResponse.from_orm(w) for w in result.scalars().all()]
|
||||
return await master_data_service.list_warehouses(db_session)
|
||||
|
||||
|
||||
@router.post("", response_model=WarehouseResponse, status_code=201)
|
||||
@@ -39,12 +34,4 @@ async def create_warehouse(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
data = warehouse_data.dict()
|
||||
if not data.get("code"):
|
||||
data["code"] = f"W{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
warehouse = Warehouse(**data)
|
||||
db_session.add(warehouse)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(warehouse)
|
||||
return WarehouseResponse.from_orm(warehouse)
|
||||
return await master_data_service.create_warehouse(db_session, warehouse_data, current_user)
|
||||
|
||||
@@ -54,7 +54,8 @@ from .material_schemas import (
|
||||
MaterialPriceHistoryResponse,
|
||||
MaterialSupplierCreate,
|
||||
MaterialSupplierResponse,
|
||||
MaterialPriceTrendResponse
|
||||
MaterialPriceTrendResponse,
|
||||
PriceHistoryItem
|
||||
)
|
||||
from .purchase_demand_schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
@@ -89,6 +90,6 @@ __all__ = [
|
||||
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
|
||||
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
|
||||
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
|
||||
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
|
||||
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse", "PriceHistoryItem",
|
||||
"PurchaseDemandCalculateRequest", "PurchaseDemandItemResponse", "PurchaseDemandResponse",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -20,5 +20,4 @@ class CustomerResponse(BaseModel):
|
||||
email: Optional[str]
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List, Literal
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -39,8 +39,7 @@ class FinanceAllocationResponse(BaseModel):
|
||||
order_id: int
|
||||
allocated_amount: Decimal
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FinanceTransactionResponse(BaseModel):
|
||||
@@ -59,8 +58,7 @@ class FinanceTransactionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
allocations: List[FinanceAllocationResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FinanceSummaryResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -14,8 +14,7 @@ class InventoryResponse(BaseModel):
|
||||
locked_quantity: Decimal
|
||||
available_quantity: Decimal
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryCreate(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
定义物料价格历史和物料供应商关联的数据结构
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
@@ -27,9 +27,8 @@ class MaterialPriceHistoryResponse(BaseModel):
|
||||
supplier_name: Optional[str]
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MaterialSupplierCreate(BaseModel):
|
||||
@@ -57,9 +56,8 @@ class MaterialSupplierResponse(BaseModel):
|
||||
min_order_quantity: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PriceHistoryItem(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -33,8 +33,7 @@ class ProductResponse(BaseModel):
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProductMaterialItemUpdate(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
@@ -34,8 +34,7 @@ class PurchaseOrderResponse(BaseModel):
|
||||
received_date: Optional[datetime]
|
||||
paid_date: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PurchaseOrderItemResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
@@ -43,8 +43,7 @@ class SalesOrderResponse(BaseModel):
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SalesOrderItemResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -27,5 +27,4 @@ class StockMovementResponse(BaseModel):
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -20,5 +20,4 @@ class SupplierResponse(BaseModel):
|
||||
email: Optional[str]
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -19,5 +19,4 @@ class WarehouseResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_default: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""仪表盘聚合业务服务层
|
||||
|
||||
将 dashboard_routes 中的聚合查询与统计编排下沉到此,
|
||||
路由层只做依赖注入与响应返回。
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from inventory.models import Customer, Inventory, Product, PurchaseOrder, SalesOrder, Supplier, Warehouse
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""仪表盘统计服务"""
|
||||
|
||||
@staticmethod
|
||||
async def get_dashboard(db_session: AsyncSession) -> dict:
|
||||
material_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
|
||||
) or 0
|
||||
finished_product_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
|
||||
) or 0
|
||||
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) or 0
|
||||
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) or 0
|
||||
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) or 0
|
||||
|
||||
total_stock = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
total_value = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity * Product.cost_price))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
|
||||
pending_purchase = await db_session.scalar(
|
||||
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
|
||||
) or 0
|
||||
pending_sales = await db_session.scalar(
|
||||
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
|
||||
) or 0
|
||||
|
||||
low_stock_products = await db_session.execute(
|
||||
select(Product, Inventory)
|
||||
.join(Inventory, Product.id == Inventory.product_id)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Inventory.quantity <= Product.min_stock)
|
||||
.limit(10)
|
||||
)
|
||||
low_stock = [
|
||||
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
|
||||
for p, i in low_stock_products.all()
|
||||
]
|
||||
|
||||
return {
|
||||
"finished_product_count": finished_product_count,
|
||||
"material_count": material_count,
|
||||
"supplier_count": supplier_count,
|
||||
"customer_count": customer_count,
|
||||
"warehouse_count": warehouse_count,
|
||||
"total_stock": total_stock,
|
||||
"total_value": round(total_value, 2),
|
||||
"pending_purchase": pending_purchase,
|
||||
"pending_sales": pending_sales,
|
||||
"low_stock_products": low_stock,
|
||||
}
|
||||
|
||||
|
||||
dashboard_service = DashboardService()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""进销存主数据业务服务层
|
||||
|
||||
将 customer / supplier / warehouse 这类主数据 CRUD 编排从路由层下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Supplier, Warehouse
|
||||
from ..schemas import (
|
||||
CustomerCreate,
|
||||
CustomerResponse,
|
||||
SupplierCreate,
|
||||
SupplierResponse,
|
||||
WarehouseCreate,
|
||||
WarehouseResponse,
|
||||
)
|
||||
|
||||
|
||||
def _generate_code(prefix: str) -> str:
|
||||
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
|
||||
async def _get_entity_or_404(
|
||||
db_session: AsyncSession,
|
||||
model: Type[Customer] | Type[Supplier] | Type[Warehouse],
|
||||
entity_id: int,
|
||||
detail: str,
|
||||
):
|
||||
result = await db_session.execute(select(model).where(model.id == entity_id))
|
||||
entity = result.scalar_one_or_none()
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
return entity
|
||||
|
||||
|
||||
def _build_customer_response(customer: Customer) -> CustomerResponse:
|
||||
return CustomerResponse.model_validate(customer)
|
||||
|
||||
|
||||
def _build_supplier_response(supplier: Supplier) -> SupplierResponse:
|
||||
return SupplierResponse.model_validate(supplier)
|
||||
|
||||
|
||||
def _build_warehouse_response(warehouse: Warehouse) -> WarehouseResponse:
|
||||
return WarehouseResponse.model_validate(warehouse)
|
||||
|
||||
|
||||
class MasterDataService:
|
||||
"""客户 / 供应商 / 仓库主数据服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_customers(
|
||||
db_session: AsyncSession,
|
||||
skip: int,
|
||||
limit: int,
|
||||
search: Optional[str],
|
||||
) -> List[CustomerResponse]:
|
||||
query: Select = select(Customer).where(Customer.is_active == True)
|
||||
if search:
|
||||
query = query.where(Customer.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [_build_customer_response(customer) for customer in result.scalars().all()]
|
||||
|
||||
@staticmethod
|
||||
async def create_customer(
|
||||
db_session: AsyncSession,
|
||||
customer_data: CustomerCreate,
|
||||
current_user: User,
|
||||
) -> CustomerResponse:
|
||||
data = customer_data.model_dump()
|
||||
if not data.get("code"):
|
||||
data["code"] = _generate_code("C")
|
||||
|
||||
customer = Customer(**data)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(customer)
|
||||
return _build_customer_response(customer)
|
||||
|
||||
@staticmethod
|
||||
async def update_customer(
|
||||
db_session: AsyncSession,
|
||||
customer_id: int,
|
||||
customer_data: CustomerCreate,
|
||||
current_user: User,
|
||||
) -> CustomerResponse:
|
||||
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
|
||||
for key, value in customer_data.model_dump().items():
|
||||
setattr(customer, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(customer)
|
||||
return _build_customer_response(customer)
|
||||
|
||||
@staticmethod
|
||||
async def delete_customer(
|
||||
db_session: AsyncSession,
|
||||
customer_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
|
||||
customer.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "客户已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def list_suppliers(
|
||||
db_session: AsyncSession,
|
||||
skip: int,
|
||||
limit: int,
|
||||
search: Optional[str],
|
||||
) -> List[SupplierResponse]:
|
||||
query: Select = select(Supplier).where(Supplier.is_active == True)
|
||||
if search:
|
||||
query = query.where(Supplier.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [_build_supplier_response(supplier) for supplier in result.scalars().all()]
|
||||
|
||||
@staticmethod
|
||||
async def create_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_data: SupplierCreate,
|
||||
current_user: User,
|
||||
) -> SupplierResponse:
|
||||
data = supplier_data.model_dump()
|
||||
if not data.get("code"):
|
||||
data["code"] = _generate_code("S")
|
||||
|
||||
supplier = Supplier(**data)
|
||||
db_session.add(supplier)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(supplier)
|
||||
return _build_supplier_response(supplier)
|
||||
|
||||
@staticmethod
|
||||
async def update_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
supplier_data: SupplierCreate,
|
||||
current_user: User,
|
||||
) -> SupplierResponse:
|
||||
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
|
||||
for key, value in supplier_data.model_dump().items():
|
||||
setattr(supplier, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(supplier)
|
||||
return _build_supplier_response(supplier)
|
||||
|
||||
@staticmethod
|
||||
async def delete_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
|
||||
supplier.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "供应商已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def list_warehouses(
|
||||
db_session: AsyncSession,
|
||||
) -> List[WarehouseResponse]:
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
|
||||
)
|
||||
return [_build_warehouse_response(warehouse) for warehouse in result.scalars().all()]
|
||||
|
||||
@staticmethod
|
||||
async def create_warehouse(
|
||||
db_session: AsyncSession,
|
||||
warehouse_data: WarehouseCreate,
|
||||
current_user: User,
|
||||
) -> WarehouseResponse:
|
||||
data = warehouse_data.model_dump()
|
||||
if not data.get("code"):
|
||||
data["code"] = _generate_code("W")
|
||||
|
||||
warehouse = Warehouse(**data)
|
||||
db_session.add(warehouse)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(warehouse)
|
||||
return _build_warehouse_response(warehouse)
|
||||
|
||||
|
||||
master_data_service = MasterDataService()
|
||||
@@ -0,0 +1,284 @@
|
||||
"""物料管理业务服务层
|
||||
|
||||
将 material_routes 中的价格历史、价格趋势、物料供应商关联等业务编排下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import desc, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User
|
||||
from inventory.models import MaterialPriceHistory, MaterialSupplier, Product, Supplier
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
MaterialPriceTrendResponse,
|
||||
MaterialSupplierCreate,
|
||||
MaterialSupplierResponse,
|
||||
PriceHistoryItem,
|
||||
)
|
||||
|
||||
|
||||
async def _get_material_product(db_session: AsyncSession, product_id: int) -> Product:
|
||||
result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
|
||||
return product
|
||||
|
||||
|
||||
async def _get_active_supplier(db_session: AsyncSession, supplier_id: int, detail: str = "供应商不存在") -> Supplier:
|
||||
result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=400 if detail == "供应商不存在" else 404, detail=detail)
|
||||
return supplier
|
||||
|
||||
|
||||
def _build_price_history_response(
|
||||
product: Product,
|
||||
price_history: MaterialPriceHistory,
|
||||
supplier_name: str | None,
|
||||
) -> MaterialPriceHistoryResponse:
|
||||
return MaterialPriceHistoryResponse(
|
||||
id=price_history.id,
|
||||
product_id=price_history.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
price=price_history.price,
|
||||
effective_date=price_history.effective_date,
|
||||
supplier_id=price_history.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
remark=price_history.remark,
|
||||
created_at=price_history.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_material_supplier_response(
|
||||
product: Product,
|
||||
material_supplier: MaterialSupplier,
|
||||
supplier_name: str | None,
|
||||
) -> MaterialSupplierResponse:
|
||||
return MaterialSupplierResponse(
|
||||
id=material_supplier.id,
|
||||
product_id=material_supplier.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
supplier_id=material_supplier.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
is_primary=material_supplier.is_primary,
|
||||
contact_person=material_supplier.contact_person,
|
||||
contact_phone=material_supplier.contact_phone,
|
||||
lead_time=material_supplier.lead_time,
|
||||
min_order_quantity=material_supplier.min_order_quantity,
|
||||
created_at=material_supplier.created_at,
|
||||
updated_at=material_supplier.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class MaterialService:
|
||||
"""物料价格与供应商关联服务"""
|
||||
|
||||
@staticmethod
|
||||
async def add_price_history(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
price_data: MaterialPriceHistoryCreate,
|
||||
current_user: User,
|
||||
) -> MaterialPriceHistoryResponse:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
|
||||
supplier_name = None
|
||||
if price_data.supplier_id:
|
||||
supplier = await _get_active_supplier(db_session, price_data.supplier_id)
|
||||
supplier_name = supplier.name
|
||||
|
||||
price_history = MaterialPriceHistory(
|
||||
product_id=product_id,
|
||||
price=price_data.price,
|
||||
supplier_id=price_data.supplier_id,
|
||||
remark=price_data.remark,
|
||||
)
|
||||
db_session.add(price_history)
|
||||
product.cost_price = price_data.price
|
||||
await db_session.commit()
|
||||
await db_session.refresh(price_history)
|
||||
|
||||
return _build_price_history_response(product, price_history, supplier_name)
|
||||
|
||||
@staticmethod
|
||||
async def get_price_history(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
limit: int,
|
||||
) -> List[MaterialPriceHistoryResponse]:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory, Supplier)
|
||||
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(limit)
|
||||
)
|
||||
return [
|
||||
_build_price_history_response(product, ph, supplier.name if supplier else None)
|
||||
for ph, supplier in result.all()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_price_trend(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
months: int,
|
||||
) -> MaterialPriceTrendResponse:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory, Supplier)
|
||||
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(months)
|
||||
)
|
||||
rows = result.all()
|
||||
price_history_list = [ph for ph, _supplier in rows]
|
||||
if not price_history_list:
|
||||
raise HTTPException(status_code=404, detail="无价格历史记录")
|
||||
|
||||
current_price = price_history_list[0].price
|
||||
first_price = price_history_list[-1].price
|
||||
price_change = current_price - first_price
|
||||
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
|
||||
|
||||
return MaterialPriceTrendResponse(
|
||||
product_id=product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
current_price=current_price,
|
||||
price_change=round(price_change, 2),
|
||||
price_change_percent=round(price_change_percent, 2),
|
||||
price_history=[
|
||||
PriceHistoryItem(
|
||||
date=ph.effective_date,
|
||||
price=ph.price,
|
||||
supplier_name=supplier.name if supplier else None,
|
||||
)
|
||||
for ph, supplier in rows
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def add_material_supplier(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
supplier_data: MaterialSupplierCreate,
|
||||
current_user: User,
|
||||
) -> MaterialSupplierResponse:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
supplier = await _get_active_supplier(db_session, supplier_data.supplier_id)
|
||||
|
||||
existing = await db_session.execute(
|
||||
select(MaterialSupplier).where(
|
||||
MaterialSupplier.product_id == product_id,
|
||||
MaterialSupplier.supplier_id == supplier_data.supplier_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
|
||||
|
||||
if supplier_data.is_primary:
|
||||
await db_session.execute(
|
||||
update(MaterialSupplier)
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
|
||||
material_supplier = MaterialSupplier(
|
||||
product_id=product_id,
|
||||
supplier_id=supplier_data.supplier_id,
|
||||
is_primary=supplier_data.is_primary,
|
||||
contact_person=supplier_data.contact_person,
|
||||
contact_phone=supplier_data.contact_phone,
|
||||
lead_time=supplier_data.lead_time,
|
||||
min_order_quantity=supplier_data.min_order_quantity,
|
||||
)
|
||||
db_session.add(material_supplier)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(material_supplier)
|
||||
|
||||
return _build_material_supplier_response(product, material_supplier, supplier.name)
|
||||
|
||||
@staticmethod
|
||||
async def get_material_suppliers(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
) -> List[MaterialSupplierResponse]:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier, Supplier)
|
||||
.outerjoin(Supplier, MaterialSupplier.supplier_id == Supplier.id)
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
return [
|
||||
_build_material_supplier_response(product, ms, supplier.name if supplier else None)
|
||||
for ms, supplier in result.all()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def remove_material_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
|
||||
)
|
||||
material_supplier = result.scalar_one_or_none()
|
||||
if not material_supplier:
|
||||
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
|
||||
|
||||
await db_session.delete(material_supplier)
|
||||
await db_session.commit()
|
||||
return {"message": "物料供应商关联已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def get_supplier_materials(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
) -> List[MaterialSupplierResponse]:
|
||||
supplier = await _get_active_supplier(db_session, supplier_id, detail="供应商不存在")
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier, Product)
|
||||
.outerjoin(Product, MaterialSupplier.product_id == Product.id)
|
||||
.where(MaterialSupplier.supplier_id == supplier_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
return [
|
||||
MaterialSupplierResponse(
|
||||
id=ms.id,
|
||||
product_id=ms.product_id,
|
||||
product_sku=product.sku if product else None,
|
||||
product_name=product.name if product else None,
|
||||
supplier_id=ms.supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
is_primary=ms.is_primary,
|
||||
contact_person=ms.contact_person,
|
||||
contact_phone=ms.contact_phone,
|
||||
lead_time=ms.lead_time,
|
||||
min_order_quantity=ms.min_order_quantity,
|
||||
created_at=ms.created_at,
|
||||
updated_at=ms.updated_at,
|
||||
)
|
||||
for ms, product in result.all()
|
||||
]
|
||||
|
||||
|
||||
material_service = MaterialService()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""产品管理业务服务层
|
||||
|
||||
将 product_routes 中不跨模块的产品 CRUD / BOM 编排下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from inventory.models import Product, ProductMaterial
|
||||
from ..schemas import (
|
||||
ProductBOMResponse,
|
||||
ProductBOMUpdate,
|
||||
ProductCreate,
|
||||
ProductMaterialItemResponse,
|
||||
ProductResponse,
|
||||
)
|
||||
|
||||
|
||||
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, Decimal]:
|
||||
if not product_ids:
|
||||
return {}
|
||||
result = await db_session.execute(
|
||||
select(
|
||||
ProductMaterial.finished_product_id,
|
||||
func.coalesce(
|
||||
func.sum(Product.cost_price * ProductMaterial.quantity),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(product_ids))
|
||||
.group_by(ProductMaterial.finished_product_id)
|
||||
)
|
||||
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
|
||||
|
||||
|
||||
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
sku=product.sku,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
category=product.category,
|
||||
unit=product.unit,
|
||||
item_type=product.item_type,
|
||||
cost_price=Decimal(str(product.cost_price or 0)),
|
||||
sale_price=Decimal(str(product.sale_price or 0)),
|
||||
min_stock=product.min_stock,
|
||||
max_stock=product.max_stock,
|
||||
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
|
||||
is_active=product.is_active,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_product_or_404(db_session: AsyncSession, product_id: int, active_only: bool = False) -> Product:
|
||||
query = select(Product).where(Product.id == product_id)
|
||||
if active_only:
|
||||
query = query.where(Product.is_active == True)
|
||||
result = await db_session.execute(query)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
return product
|
||||
|
||||
|
||||
def _validate_item_type(item_type: str) -> None:
|
||||
if item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
|
||||
|
||||
async def _build_bom_response(db_session: AsyncSession, product: Product) -> ProductBOMResponse:
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id == product.id)
|
||||
.order_by(ProductMaterial.id.asc())
|
||||
)
|
||||
|
||||
items: List[ProductMaterialItemResponse] = []
|
||||
total_material_cost = Decimal("0")
|
||||
for bom, material in bom_result.all():
|
||||
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
|
||||
total_material_cost += line_cost
|
||||
items.append(
|
||||
ProductMaterialItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
quantity=Decimal(str(bom.quantity)),
|
||||
unit_cost=Decimal(str(material.cost_price or 0)),
|
||||
line_cost=line_cost,
|
||||
)
|
||||
)
|
||||
|
||||
return ProductBOMResponse(
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
total_material_cost=total_material_cost,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
class ProductService:
|
||||
"""产品 CRUD 与 BOM 服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_products(
|
||||
db_session: AsyncSession,
|
||||
skip: int,
|
||||
limit: int,
|
||||
search: Optional[str],
|
||||
category: Optional[str],
|
||||
item_type: Optional[str],
|
||||
) -> List[ProductResponse]:
|
||||
query = select(Product).where(Product.is_active == True)
|
||||
|
||||
if search:
|
||||
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||||
if category:
|
||||
query = query.where(Product.category == category)
|
||||
if item_type:
|
||||
query = query.where(Product.item_type == item_type)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
products = result.scalars().all()
|
||||
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
|
||||
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
|
||||
|
||||
@staticmethod
|
||||
async def create_product(
|
||||
db_session: AsyncSession,
|
||||
product_data: ProductCreate,
|
||||
current_user: User,
|
||||
) -> ProductResponse:
|
||||
_validate_item_type(product_data.item_type)
|
||||
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product_dict = product_data.model_dump()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
product = Product(**product_dict)
|
||||
db_session.add(product)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
@staticmethod
|
||||
async def create_product_from_task(
|
||||
db_session: AsyncSession,
|
||||
task_id: str,
|
||||
current_user: User,
|
||||
) -> ProductResponse:
|
||||
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="分析任务不存在")
|
||||
|
||||
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
|
||||
stp_file = stp_result.scalar_one_or_none()
|
||||
if not stp_file:
|
||||
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
|
||||
|
||||
if stp_file.product_id:
|
||||
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
|
||||
product = existed.scalar_one_or_none()
|
||||
if product:
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
base_sku = f"MI{stp_file.id}"
|
||||
sku = base_sku
|
||||
n = 1
|
||||
while True:
|
||||
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
|
||||
if not conflict.scalar_one_or_none():
|
||||
break
|
||||
n += 1
|
||||
sku = f"{base_sku}-{n}"
|
||||
|
||||
name = Path(stp_file.original_filename or f"mold_{stp_file.id}").stem or f"模具分析-{stp_file.id}"
|
||||
desc_parts = []
|
||||
if stp_file.volume:
|
||||
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
|
||||
if stp_file.product_weight:
|
||||
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
|
||||
if stp_file.surface_area:
|
||||
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
|
||||
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
|
||||
|
||||
product = Product(
|
||||
sku=sku,
|
||||
name=name,
|
||||
description=description,
|
||||
category="模具成品",
|
||||
unit="件",
|
||||
item_type="finished",
|
||||
cost_price=0,
|
||||
sale_price=0,
|
||||
min_stock=0,
|
||||
max_stock=0,
|
||||
)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
stp_file.product_id = product.id
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_product(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
product_data: ProductCreate,
|
||||
current_user: User,
|
||||
) -> ProductResponse:
|
||||
product = await _get_product_or_404(db_session, product_id)
|
||||
_validate_item_type(product_data.item_type)
|
||||
|
||||
conflict = await db_session.execute(
|
||||
select(Product).where(Product.sku == product_data.sku, Product.id != product_id)
|
||||
)
|
||||
if conflict.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product_dict = product_data.model_dump()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
|
||||
for key, value in product_dict.items():
|
||||
setattr(product, key, value)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
|
||||
return _build_product_response(product, material_cost_map.get(product.id, 0))
|
||||
|
||||
@staticmethod
|
||||
async def delete_product(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
product = await _get_product_or_404(db_session, product_id)
|
||||
product.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "产品已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def get_product_bom(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
) -> ProductBOMResponse:
|
||||
product = await _get_product_or_404(db_session, product_id, active_only=True)
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
return await _build_bom_response(db_session, product)
|
||||
|
||||
@staticmethod
|
||||
async def replace_product_bom(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
payload: ProductBOMUpdate,
|
||||
current_user: User,
|
||||
) -> ProductBOMResponse:
|
||||
product = await _get_product_or_404(db_session, product_id, active_only=True)
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
material_ids = [item.material_id for item in payload.items]
|
||||
if len(material_ids) != len(set(material_ids)):
|
||||
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
|
||||
|
||||
if material_ids:
|
||||
material_result = await db_session.execute(
|
||||
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
|
||||
)
|
||||
materials = material_result.scalars().all()
|
||||
material_map = {m.id: m for m in materials}
|
||||
if len(material_map) != len(material_ids):
|
||||
raise HTTPException(status_code=400, detail="存在无效物料")
|
||||
invalid_materials = [m.name for m in materials if m.item_type != "material"]
|
||||
if invalid_materials:
|
||||
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
|
||||
|
||||
for item in payload.items:
|
||||
if item.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||||
|
||||
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||||
for item in payload.items:
|
||||
db_session.add(
|
||||
ProductMaterial(
|
||||
finished_product_id=product_id,
|
||||
material_product_id=item.material_id,
|
||||
quantity=item.quantity,
|
||||
loss_rate=item.loss_rate,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
return await _build_bom_response(db_session, product)
|
||||
|
||||
|
||||
product_service = ProductService()
|
||||
@@ -4,6 +4,7 @@ import importlib
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
from moldinsight.api.html_report_router import include_into as include_html_report
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -26,6 +27,7 @@ ROUTE_MODULES = [
|
||||
("加工", "moldinsight.api.machining_router", False),
|
||||
("导出", "moldinsight.api.export_router", False),
|
||||
("铝价", "moldinsight.api.aluminum_price_routes", False),
|
||||
("老师傅经验反馈", "moldinsight.api.experience_feedback_router", False), # D17 Human-in-Loop
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
("调试", "moldinsight.api.debug_router", True),
|
||||
]
|
||||
@@ -54,3 +56,15 @@ def _safe_include(label: str, module_path: str, debug_only: bool = False):
|
||||
|
||||
for _label, _module_path, _debug_only in ROUTE_MODULES:
|
||||
_safe_include(_label, _module_path, _debug_only)
|
||||
|
||||
|
||||
def register_moldinsight_routers(app):
|
||||
"""注册 moldinsight 全部业务路由到 app(D3 收敛:入口侧单点调用)。
|
||||
|
||||
- /api 聚合路由:各子路由模块装载失败经 ROUTE_MODULES/route_load_status 呈现
|
||||
(/api/health degraded,DEBUG fail-fast),见 _safe_include
|
||||
- HTML 报告代理挂根路径 /html/{filename}(URL 形状与原 StaticFiles 一致),
|
||||
失败同样登记 route_load_status(html_report_router.include_into)
|
||||
"""
|
||||
app.include_router(router, prefix="/api")
|
||||
include_html_report(app)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""老师傅经验反馈 API:D17 Human-in-Loop 闭环。
|
||||
|
||||
端点:
|
||||
- POST /api/tasks/{task_id}/experience-feedback 提交方案级反馈
|
||||
- GET /api/tasks/{task_id}/experience-hints 拉取同指纹历史 hints 摘要
|
||||
|
||||
权限:
|
||||
- 写入:Depends(get_current_active_user) + ensure_task_access + 行内 has_permission
|
||||
- 读取:Depends(get_current_active_user) + ensure_task_access(所有登录用户可看)
|
||||
|
||||
Pydantic 模型写在路由文件内(项目硬规则,shared/models/schemas.py 不扩张)。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
from moldinsight.services.experience_feedback_service import (
|
||||
ExperienceFeedbackService,
|
||||
compute_fingerprint,
|
||||
)
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.models import ExperienceFeedback, ProcessingTask, GeometryData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic 请求 / 响应模型(写在路由文件内,硬规则)──
|
||||
|
||||
class ExperienceFeedbackCreate(BaseModel):
|
||||
"""老师傅方案级反馈请求体。"""
|
||||
|
||||
scheme_id: str = Field(..., min_length=1, max_length=64)
|
||||
feedback_status: Literal["adopted", "adjust", "rejected"]
|
||||
feedback_reason: Optional[str] = Field(None, max_length=2000)
|
||||
adjust_suggestion: Optional[str] = Field(None, max_length=2000)
|
||||
confidence_at_submit: Optional[float] = Field(None, ge=0.0, le=1.0)
|
||||
score_at_submit: Optional[float] = Field(None, ge=0.0, le=100.0)
|
||||
|
||||
|
||||
class ExperienceFeedbackResponse(BaseModel):
|
||||
"""反馈写入响应。"""
|
||||
|
||||
id: int
|
||||
scheme_id: str
|
||||
scheme_axis: str
|
||||
feedback_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ExperienceHintItem(BaseModel):
|
||||
"""同指纹历史 hints 摘要(按 scheme_axis 聚合)。"""
|
||||
|
||||
scheme_axis: str
|
||||
adopted_count: int
|
||||
rejected_count: int
|
||||
adjust_count: int
|
||||
confidence: float
|
||||
weight: float
|
||||
sample_count: int
|
||||
|
||||
|
||||
class ExperienceHintsResponse(BaseModel):
|
||||
"""GET /experience-hints 响应。"""
|
||||
|
||||
task_id: str
|
||||
stp_file_id: int
|
||||
material_name: str
|
||||
is_foam: bool
|
||||
fingerprint: Dict[str, str]
|
||||
hints: List[ExperienceHintItem]
|
||||
|
||||
|
||||
# ── 端点 ──
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/experience-feedback",
|
||||
response_model=ExperienceFeedbackResponse,
|
||||
)
|
||||
async def submit_feedback(
|
||||
task_id: str,
|
||||
body: ExperienceFeedbackCreate,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""提交方案级反馈。
|
||||
|
||||
权限:登录用户 + 任务归属 + feedback_experience_hint。
|
||||
写入后由路由 commit(D9 边界)+ invalidate_task_view(task_view 60s TTL 失效)。
|
||||
"""
|
||||
# 1. 任务归属校验(与 task_router / design_router 同一约定)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
# 2. 权限校验:行内 has_permission(identity.py:38 全仓首次调用)
|
||||
if not current_user.has_permission("feedback_experience_hint"):
|
||||
raise HTTPException(403, "需要工艺工程师或管理员权限")
|
||||
|
||||
# 3. 写反馈(仅 flush,D9 边界由本路由 commit)
|
||||
feedback = await ExperienceFeedbackService().record_feedback(
|
||||
session=db_session,
|
||||
task_id=task_id,
|
||||
scheme_id=body.scheme_id,
|
||||
feedback_status=body.feedback_status,
|
||||
feedback_reason=body.feedback_reason,
|
||||
adjust_suggestion=body.adjust_suggestion,
|
||||
user=current_user,
|
||||
confidence_at_submit=body.confidence_at_submit,
|
||||
score_at_submit=body.score_at_submit,
|
||||
process_params_snapshot=None, # 路由不接管 process_params,由算法层填
|
||||
)
|
||||
|
||||
try:
|
||||
await db_session.commit()
|
||||
except Exception as exc:
|
||||
await db_session.rollback()
|
||||
logger.error(f"反馈提交失败: {exc}")
|
||||
raise HTTPException(500, "反馈提交失败")
|
||||
|
||||
# 4. 失效任务视图缓存(写反馈后 next view 立即反映 hints)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return ExperienceFeedbackResponse(
|
||||
id=feedback.id,
|
||||
scheme_id=feedback.scheme_id,
|
||||
scheme_axis=feedback.scheme_axis,
|
||||
feedback_status=feedback.feedback_status,
|
||||
created_at=feedback.created_at or datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}/experience-hints",
|
||||
response_model=ExperienceHintsResponse,
|
||||
)
|
||||
async def get_experience_hints(
|
||||
task_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""拉取该任务的同指纹历史 hints 摘要。
|
||||
|
||||
权限:登录用户 + 任务归属。组织知识对所有人可见(不要求工艺工程师权限)。
|
||||
"""
|
||||
# 1. 任务归属校验
|
||||
row = await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
_, stp_file = row
|
||||
|
||||
# 2. 取 material / is_foam
|
||||
pt_row = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
processing_task = pt_row.scalar_one_or_none()
|
||||
params = (processing_task.parameters if processing_task else None) or {}
|
||||
material_name = str(params.get("material") or "ABS")
|
||||
is_foam = bool(params.get("is_foam_material", False))
|
||||
|
||||
# 3. 计算 fingerprint(用于回显 + 与 record_feedback 用同一函数)
|
||||
geo_row = await db_session.execute(
|
||||
select(GeometryData).where(GeometryData.stp_file_id == stp_file.id)
|
||||
)
|
||||
geo = geo_row.scalar_one_or_none()
|
||||
geometry_summary: Dict[str, Any] = {}
|
||||
if geo is not None:
|
||||
geometry_summary = {
|
||||
"volume": geo.volume,
|
||||
"bounding_box": {
|
||||
"min": geo.bounding_box_min,
|
||||
"max": geo.bounding_box_max,
|
||||
},
|
||||
"topology_faces": geo.topology_faces,
|
||||
}
|
||||
fingerprint = compute_fingerprint(geometry_summary, material_name, is_foam)
|
||||
|
||||
# 4. 拉 hints 聚合
|
||||
hints = await ExperienceFeedbackService().list_hints_for_task(
|
||||
session=db_session,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
)
|
||||
|
||||
return ExperienceHintsResponse(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
fingerprint=fingerprint,
|
||||
hints=[ExperienceHintItem(**h) for h in hints],
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
|
||||
from moldinsight.core.mold_generator_registry import mold_generator_registry
|
||||
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
from moldinsight.services.calculation_service import CalculationService
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -30,6 +31,7 @@ class MultiSchemeMoldPlanner:
|
||||
is_foam_material: bool = False,
|
||||
max_schemes: int = 3,
|
||||
process_params: Optional[Dict[str, Any]] = None,
|
||||
hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
generator = mold_generator_registry.get_by_type("aluminum_foam" if is_foam_material else "injection")
|
||||
generator.set_material(material["name"])
|
||||
@@ -37,10 +39,12 @@ class MultiSchemeMoldPlanner:
|
||||
|
||||
analysis = generator.analyze_product_geometry(shape)
|
||||
analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape)
|
||||
# D17 Human-in-Loop 闭环:把老师傅经验 hints 注入候选方向生成
|
||||
candidates = self.candidate_generator.generate_candidates(
|
||||
analysis=analysis,
|
||||
is_foam_material=is_foam_material,
|
||||
max_candidates=max_schemes,
|
||||
hints=hints,
|
||||
)
|
||||
|
||||
schemes = []
|
||||
@@ -62,7 +66,8 @@ class MultiSchemeMoldPlanner:
|
||||
if not schemes:
|
||||
raise ValueError("未能生成任何可用分模方案")
|
||||
|
||||
scored_schemes = self.scheme_scorer.score_schemes(schemes)[:max_schemes]
|
||||
# D17:hints 透传到评分器,权重轴方向评分加成
|
||||
scored_schemes = self.scheme_scorer.score_schemes(schemes, hints=hints)[:max_schemes]
|
||||
export_shapes = {}
|
||||
for idx, scheme in enumerate(scored_schemes, start=1):
|
||||
scheme["raw_scheme_id"] = scheme.get("scheme_id")
|
||||
@@ -80,6 +85,7 @@ class MultiSchemeMoldPlanner:
|
||||
"global_summary": {
|
||||
"scheme_count": len(scored_schemes),
|
||||
"recommended_reason": best_scheme.get("summary", ""),
|
||||
"applied_hints": hints or {}, # D17:给前端展示"本次应用了哪几条经验"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -144,6 +150,9 @@ class MultiSchemeMoldPlanner:
|
||||
|
||||
cavity_data = generator.generate_detailed_cavity_json(cavity_result)
|
||||
key_info = generator.generate_cavity_key_info(cavity_result)
|
||||
# 桥接 legacy 契约:3D 预览面板/前端只读 cavity_data 内嵌字段,
|
||||
# key_info 不内嵌回去则"关键工艺参数"整片 N/A(见 attach_scheme_info_contract)
|
||||
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
|
||||
cavity_data.setdefault("metadata", {})
|
||||
cavity_data["metadata"]["scheme_id"] = candidate["scheme_id"]
|
||||
cavity_data["metadata"]["scheme_method"] = candidate["method"]
|
||||
|
||||
@@ -121,6 +121,9 @@ def _op_generate_cavity(payload):
|
||||
plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接
|
||||
经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports
|
||||
产物结构一致,主进程原样存入 export_artifacts。
|
||||
|
||||
D17 Human-in-Loop:payload 顶层 experience_hints 透传给 planner.generate_plan
|
||||
让同指纹历史老师傅反馈影响本次分模评分。payload 普通 dict 透传,pickle 安全。
|
||||
"""
|
||||
parser = _cached("parser", _get_parser)
|
||||
planner = _cached("planner", _get_planner)
|
||||
@@ -130,6 +133,7 @@ def _op_generate_cavity(payload):
|
||||
material=payload["material"],
|
||||
is_foam_material=payload.get("is_foam_material", False),
|
||||
process_params=payload.get("process_params"),
|
||||
hints=payload.get("experience_hints") or {},
|
||||
)
|
||||
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
||||
export_manifest = _persist_export_shapes(payload, export_shapes)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
class PartingCandidateGenerator:
|
||||
@@ -15,10 +15,31 @@ class PartingCandidateGenerator:
|
||||
analysis: Dict[str, Any],
|
||||
is_foam_material: bool = False,
|
||||
max_candidates: int = 3,
|
||||
hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
|
||||
|
||||
axis_metrics = self._build_axis_metrics(bbox_dims, analysis, is_foam_material)
|
||||
|
||||
# D17 Human-in-Loop 闭环:老师傅采纳多的 axis 优先级加成。
|
||||
# hints 结构:{axis: {"weight": 0.0-1.0, "sample_count": int, ...}};
|
||||
# 由 ExperienceFeedbackService.list_hints_for_task 聚合后产出。
|
||||
if hints:
|
||||
for axis_metric in axis_metrics:
|
||||
axis = axis_metric["axis"]
|
||||
hint = hints.get(axis)
|
||||
if not hint:
|
||||
continue
|
||||
weight = float(hint.get("weight", 0.0))
|
||||
sample_count = int(hint.get("sample_count", 0))
|
||||
# 上限 +20 分(weight=1.0 时);weight 仅正值,不"扣分"老算法。
|
||||
axis_metric["priority_score"] = axis_metric["priority_score"] + weight * 20.0
|
||||
# sample_count 足够 + weight 强信号 → method 标签升级为"经验驱动"
|
||||
if sample_count >= 2 and weight >= 0.5:
|
||||
axis_metric["method"] = "human_experience_primary"
|
||||
axis_metric["human_hint_weight"] = weight
|
||||
axis_metric["human_hint_sample_count"] = sample_count
|
||||
|
||||
axis_order = [item["axis"] for item in sorted(
|
||||
axis_metrics,
|
||||
key=lambda item: item["priority_score"],
|
||||
|
||||
@@ -5,10 +5,15 @@ import re
|
||||
class PartingSchemeScorer:
|
||||
"""对候选分模方案打分并排序。"""
|
||||
|
||||
def score_schemes(self, schemes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def score_schemes(
|
||||
self,
|
||||
schemes: List[Dict[str, Any]],
|
||||
*,
|
||||
hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
scored = []
|
||||
for scheme in schemes:
|
||||
score_breakdown = self._score_scheme(scheme)
|
||||
score_breakdown = self._score_scheme(scheme, hints=hints)
|
||||
undercut_priority_bonus = self._build_undercut_priority_bonus(scheme, score_breakdown)
|
||||
total_score = round(
|
||||
score_breakdown["manufacturability"] * 0.25
|
||||
@@ -16,6 +21,7 @@ class PartingSchemeScorer:
|
||||
+ score_breakdown["parting_quality"] * 0.15
|
||||
+ score_breakdown["machining_cost"] * 0.15
|
||||
+ score_breakdown["risk"] * 0.10
|
||||
+ score_breakdown.get("human_hint_bonus", 0.0)
|
||||
+ undercut_priority_bonus,
|
||||
2,
|
||||
)
|
||||
@@ -44,7 +50,12 @@ class PartingSchemeScorer:
|
||||
scheme["title"] = "推荐方案" if rank == 1 else f"备选方案 {rank}"
|
||||
return scored
|
||||
|
||||
def _score_scheme(self, scheme: Dict[str, Any]) -> Dict[str, float]:
|
||||
def _score_scheme(
|
||||
self,
|
||||
scheme: Dict[str, Any],
|
||||
*,
|
||||
hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Dict[str, float]:
|
||||
cavity_data = scheme.get("cavity_data", {})
|
||||
key_info = scheme.get("key_info", {})
|
||||
candidate_priority = float(scheme.get("priority_score", 60.0))
|
||||
@@ -125,14 +136,48 @@ class PartingSchemeScorer:
|
||||
risk_base += 4.0
|
||||
risk = max(35.0, risk_base)
|
||||
|
||||
human_hint_bonus = PartingSchemeScorer._compute_human_hint_bonus(scheme, hints)
|
||||
|
||||
return {
|
||||
"manufacturability": round(manufacturability, 2),
|
||||
"undercut_complexity": round(undercut_complexity, 2),
|
||||
"parting_quality": round(parting_quality, 2),
|
||||
"machining_cost": round(machining_cost, 2),
|
||||
"risk": round(risk, 2),
|
||||
"human_hint_bonus": human_hint_bonus,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _compute_human_hint_bonus(
|
||||
scheme: Dict[str, Any],
|
||||
hints: Optional[Dict[str, Dict[str, Any]]],
|
||||
) -> float:
|
||||
"""D17 Human-in-Loop:老师傅经验加权(写入即消费)。
|
||||
|
||||
设计要点:
|
||||
- weight ∈ [0, 1] 由 history aggregation 算(adopted-rejected)/ total;仅正值
|
||||
- bonus 上限 +12(与 undercut_priority_bonus 同量级),避免单条反馈过权重
|
||||
- sample_count < 2 时 bonus × 0.5(信号不足折半)
|
||||
- axis 解析优先级:scheme.parting.axis → scheme.axis → 默认 Z
|
||||
"""
|
||||
if not hints:
|
||||
return 0.0
|
||||
parting = scheme.get("parting", {}) if isinstance(scheme.get("parting"), dict) else {}
|
||||
axis = (
|
||||
parting.get("axis")
|
||||
or scheme.get("axis")
|
||||
or "Z"
|
||||
)
|
||||
hint = hints.get(axis) or {}
|
||||
weight = float(hint.get("weight", 0.0))
|
||||
if weight <= 0:
|
||||
return 0.0
|
||||
sample_count = int(hint.get("sample_count", 0))
|
||||
bonus = weight * 12.0
|
||||
if sample_count < 2:
|
||||
bonus *= 0.5
|
||||
return round(bonus, 2)
|
||||
|
||||
@staticmethod
|
||||
def _build_undercut_priority_bonus(
|
||||
scheme: Dict[str, Any],
|
||||
|
||||
@@ -14,6 +14,7 @@ from moldinsight.models.stp_analysis import (
|
||||
DesignRecommendation,
|
||||
AnalysisMetrics,
|
||||
)
|
||||
from moldinsight.models.experience_feedback import ExperienceFeedback
|
||||
|
||||
__all__ = [
|
||||
"STPFile",
|
||||
@@ -25,4 +26,5 @@ __all__ = [
|
||||
"FeatureDetection",
|
||||
"DesignRecommendation",
|
||||
"AnalysisMetrics",
|
||||
"ExperienceFeedback",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""moldinsight 域模型:老师傅经验反馈表。
|
||||
|
||||
Human-in-Loop 闭环(D17,2026-09):老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"
|
||||
反馈,按"产品指纹 + 工艺参数"为索引跨任务匹配;下次同指纹产品分析自动消费
|
||||
(OCC worker payload 透传 → MultiSchemeMoldPlanner → PartingSchemeScorer 加成)。
|
||||
|
||||
跨模块桥接只保留裸 FK,不建 ORM relationship(base.py 约定):
|
||||
- processing_task_id -> processing_tasks.id
|
||||
- stp_file_id -> stp_files.id
|
||||
- user_id -> users.id
|
||||
|
||||
写入 feedback 时由 service 层填充 fingerprint JSON(bbox_aspect / volume_bucket /
|
||||
face_bucket / undercut_class / material_family / is_foam)。fingerprint 是跨任务
|
||||
匹配的索引列(PG 下有 GIN 索引支持 jsonb_path_query 类查询)。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Float, ForeignKey, Index
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class ExperienceFeedback(Base):
|
||||
"""老师傅经验反馈:方案级整体反馈 + 跨任务指纹匹配。
|
||||
|
||||
关键字段:
|
||||
- feedback_status: 'adopted' / 'adjust' / 'rejected'
|
||||
- fingerprint: 跨任务匹配键,结构见 moldinsight.services.experience_feedback_service
|
||||
- expires_at: 90 天 TTL;写新反馈时同 stp_file_id 整体续期(D17 衰减机制)
|
||||
"""
|
||||
__tablename__ = "experience_feedback"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ── 任务归属(跨模块裸 FK,CASCADE 随任务 / 文件清理)──
|
||||
processing_task_id = Column(
|
||||
Integer,
|
||||
ForeignKey("processing_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
stp_file_id = Column(
|
||||
Integer,
|
||||
ForeignKey("stp_files.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── 方案标识 ──
|
||||
scheme_id = Column(String(64), nullable=False, index=True)
|
||||
scheme_axis = Column(String(1), nullable=False)
|
||||
scheme_method = Column(String(50), nullable=True)
|
||||
|
||||
# ── 反馈主体 ──
|
||||
feedback_status = Column(String(20), nullable=False, index=True)
|
||||
feedback_reason = Column(Text, nullable=True)
|
||||
adjust_suggestion = Column(Text, nullable=True)
|
||||
|
||||
# ── 上下文快照(用于回放)──
|
||||
process_params_snapshot = Column(JSON, nullable=True)
|
||||
fingerprint = Column(JSON, nullable=False)
|
||||
confidence_at_submit = Column(Float, nullable=True)
|
||||
score_at_submit = Column(Float, nullable=True)
|
||||
|
||||
# ── 审计(user_id RESTRICT:禁止级联删,保留审计归因)──
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
role_code = Column(String(50), nullable=False)
|
||||
|
||||
# ── 时间戳与衰减 ──
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
expires_at = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_experience_feedback_stp_axis_status",
|
||||
"stp_file_id",
|
||||
"scheme_axis",
|
||||
"feedback_status",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ExperienceFeedback(id={self.id}, stp_file_id={self.stp_file_id}, "
|
||||
f"scheme_id='{self.scheme_id}', axis='{self.scheme_axis}', "
|
||||
f"status='{self.feedback_status}')>"
|
||||
)
|
||||
@@ -364,6 +364,33 @@ class CalculationService:
|
||||
cls.attach_injection_system_summaries(result, material["name"])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def attach_scheme_info_contract(cavity_data: Dict[str, Any], key_info: Dict[str, Any]) -> None:
|
||||
"""把方案级 key_info 内嵌回 cavity_data,补齐下游依赖的 legacy 字段契约(原地修改)。
|
||||
|
||||
3D 预览 HTML(shared/utils/html_generator 的"关键工艺参数"面板)只拿得到 cavity_data:
|
||||
- 面板读 mold_cavities.cavity_key_info.geometric_characteristics 与
|
||||
manufacturing_info.mold_material / mold_hardness / surface_finish /
|
||||
estimated_cycle_time / parting_line_length;
|
||||
- 前端结果页"型腔数"读 mold_cavities.cavity_count。
|
||||
多方案重构后这些字段只存在于 scheme.key_info,缺失会导致面板整片 N/A——在此统一桥接。
|
||||
"""
|
||||
mold_cavities = cavity_data.setdefault("mold_cavities", {})
|
||||
mold_cavities.setdefault("cavity_count", 1)
|
||||
mold_cavities["cavity_key_info"] = key_info
|
||||
|
||||
requirements = key_info.get("manufacturing_requirements", {})
|
||||
mold_parameters = key_info.get("mold_parameters", {})
|
||||
manufacturing = cavity_data.setdefault("manufacturing_info", {})
|
||||
manufacturing.setdefault(
|
||||
"mold_material",
|
||||
requirements.get("cavity_material") or manufacturing.get("recommended_material", ""),
|
||||
)
|
||||
manufacturing.setdefault("mold_hardness", requirements.get("hardness", ""))
|
||||
manufacturing.setdefault("surface_finish", requirements.get("surface_finish", ""))
|
||||
manufacturing.setdefault("estimated_cycle_time", requirements.get("estimated_cycle_time", ""))
|
||||
manufacturing.setdefault("parting_line_length", mold_parameters.get("parting_line_length", ""))
|
||||
|
||||
@classmethod
|
||||
def attach_injection_system_summaries(
|
||||
cls,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""老师傅经验反馈服务:D17 Human-in-Loop 闭环。
|
||||
|
||||
三层使用:
|
||||
1. `record_feedback` — 路由层 POST 调用;写入 ExperienceFeedback;同 stp_file_id
|
||||
整体续期(D17 衰减机制);只 flush,由路由 commit(D9 边界)。
|
||||
2. `list_hints_for_task` — 路由层 GET 调用;按 stp_file_id + material_family +
|
||||
is_foam 锚定,聚合返回前端 ResultView 用 hints 摘要。
|
||||
3. `resolve_for_process_params` — ProcessingService 调用;返回 OCC worker payload
|
||||
用的 hints dict,OCC 子进程透传给 MultiSchemeMoldPlanner。
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from sqlalchemy import select, update, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User, Role, UserRole
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.models import (
|
||||
ExperienceFeedback,
|
||||
ProcessingTask,
|
||||
STPFile,
|
||||
GeometryData,
|
||||
MoldCavityData,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
FEEDBACK_TTL_DAYS = 90
|
||||
|
||||
|
||||
def compute_fingerprint(
|
||||
geometry_data: Optional[dict],
|
||||
material_name: str,
|
||||
is_foam: bool,
|
||||
) -> Dict[str, str]:
|
||||
"""计算产品指纹(跨任务匹配键)。
|
||||
|
||||
分桶策略见 plan §5.1:
|
||||
- bbox_aspect: cube/compact/slab/elongated/long_bar
|
||||
- volume_bucket: xs/s/m/l/xl(mm³ → cm³)
|
||||
- face_bucket: simple/normal/complex/dense
|
||||
- undercut_class: none/mild/moderate/heavy
|
||||
- material_family: foam/abs/pp/pa/other(粗粒度,避免材料名变化导致匹配失效)
|
||||
- is_foam: "true"/"false"
|
||||
"""
|
||||
geo = geometry_data or {}
|
||||
dims = geo.get("bounding_box", {}).get("dimensions") or [0, 0, 0]
|
||||
sorted_dims = sorted(dims or [0, 0, 0])
|
||||
if sorted_dims[0] <= 0:
|
||||
ratio = 1.0
|
||||
else:
|
||||
ratio = sorted_dims[1] / sorted_dims[0]
|
||||
|
||||
if ratio < 1.0:
|
||||
bbox_aspect = "cube"
|
||||
elif ratio < 1.5:
|
||||
bbox_aspect = "compact"
|
||||
elif ratio < 3.0:
|
||||
bbox_aspect = "slab"
|
||||
elif ratio < 6.0:
|
||||
bbox_aspect = "elongated"
|
||||
else:
|
||||
bbox_aspect = "long_bar"
|
||||
|
||||
volume_cm3 = (geo.get("volume", 0) or 0) / 1000.0
|
||||
if volume_cm3 < 10:
|
||||
volume_bucket = "xs"
|
||||
elif volume_cm3 < 100:
|
||||
volume_bucket = "s"
|
||||
elif volume_cm3 < 500:
|
||||
volume_bucket = "m"
|
||||
elif volume_cm3 < 2000:
|
||||
volume_bucket = "l"
|
||||
else:
|
||||
volume_bucket = "xl"
|
||||
|
||||
face_count = (
|
||||
geo.get("topology_faces", 0)
|
||||
or geo.get("topology", {}).get("faces", 0)
|
||||
or 0
|
||||
)
|
||||
if face_count < 100:
|
||||
face_bucket = "simple"
|
||||
elif face_count < 500:
|
||||
face_bucket = "normal"
|
||||
elif face_count < 2000:
|
||||
face_bucket = "complex"
|
||||
else:
|
||||
face_bucket = "dense"
|
||||
|
||||
undercut_count = geo.get("undercut_count", 0) or 0
|
||||
if undercut_count == 0:
|
||||
undercut_class = "none"
|
||||
elif undercut_count < 4:
|
||||
undercut_class = "mild"
|
||||
elif undercut_count < 9:
|
||||
undercut_class = "moderate"
|
||||
else:
|
||||
undercut_class = "heavy"
|
||||
|
||||
mat_lower = (material_name or "").lower()
|
||||
if "al" in mat_lower and "si" in mat_lower:
|
||||
material_family = "foam"
|
||||
elif "abs" in mat_lower:
|
||||
material_family = "abs"
|
||||
elif "pp" in mat_lower:
|
||||
material_family = "pp"
|
||||
elif "pa" in mat_lower:
|
||||
material_family = "pa"
|
||||
else:
|
||||
material_family = "other"
|
||||
|
||||
return {
|
||||
"bbox_aspect": bbox_aspect,
|
||||
"volume_bucket": volume_bucket,
|
||||
"face_bucket": face_bucket,
|
||||
"undercut_class": undercut_class,
|
||||
"material_family": material_family,
|
||||
"is_foam": "true" if is_foam else "false",
|
||||
}
|
||||
|
||||
|
||||
class ExperienceFeedbackService:
|
||||
"""老师傅经验反馈:写入 / 同指纹 hints 摘要 / OCC worker 用 hints 解析。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 显式无依赖:与 processing_service / task_storage_service 范式一致
|
||||
pass
|
||||
|
||||
async def record_feedback(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_id: str,
|
||||
scheme_id: str,
|
||||
feedback_status: str,
|
||||
feedback_reason: Optional[str],
|
||||
adjust_suggestion: Optional[str],
|
||||
user: User,
|
||||
confidence_at_submit: Optional[float] = None,
|
||||
score_at_submit: Optional[float] = None,
|
||||
process_params_snapshot: Optional[Dict[str, Any]] = None,
|
||||
) -> ExperienceFeedback:
|
||||
"""写入方案级反馈。仅 flush,由路由 commit(D9 边界)。
|
||||
|
||||
同时同 stp_file_id 整体续期(expires_at = now() + 90d),
|
||||
"写入即消费"的语义保证新反馈立刻进入有效集。
|
||||
"""
|
||||
if feedback_status not in ("adopted", "adjust", "rejected"):
|
||||
raise ValueError(f"feedback_status 非法: {feedback_status}")
|
||||
|
||||
# 1. 找 task + stp_file
|
||||
row = await session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
row = row.first()
|
||||
if not row:
|
||||
raise ValueError(f"任务不存在: {task_id}")
|
||||
processing_task, stp_file = row
|
||||
|
||||
# 2. 解析 material_name + is_foam_material
|
||||
params = processing_task.parameters or {}
|
||||
material_name = str(params.get("material") or "ABS")
|
||||
is_foam_material = bool(params.get("is_foam_material", False))
|
||||
|
||||
# 3. 取 geometry_data / cavity_data 计算 fingerprint
|
||||
geometry_summary: Dict[str, Any] = {}
|
||||
geo_row = await session.execute(
|
||||
select(GeometryData).where(GeometryData.stp_file_id == stp_file.id)
|
||||
)
|
||||
geo = geo_row.scalar_one_or_none()
|
||||
if geo is not None:
|
||||
geometry_summary = {
|
||||
"volume": geo.volume,
|
||||
"bounding_box": {
|
||||
"min": geo.bounding_box_min,
|
||||
"max": geo.bounding_box_max,
|
||||
},
|
||||
"topology_faces": geo.topology_faces,
|
||||
}
|
||||
|
||||
cavity_row = await session.execute(
|
||||
select(MoldCavityData).where(MoldCavityData.stp_file_id == stp_file.id)
|
||||
)
|
||||
cavity = cavity_row.scalar_one_or_none()
|
||||
if cavity is not None:
|
||||
ki = cavity.cavity_key_info or {}
|
||||
side_actions = (
|
||||
ki.get("quality_considerations") if isinstance(ki, dict) else None
|
||||
) or {}
|
||||
if isinstance(side_actions, dict):
|
||||
geometry_summary["undercut_count"] = side_actions.get("undercut_count", 0) or 0
|
||||
|
||||
fingerprint = compute_fingerprint(geometry_summary, material_name, is_foam_material)
|
||||
|
||||
# 4. 找 scheme 的 axis + method(从 cavity_key_info.candidate_schemes)
|
||||
scheme_axis = "Z"
|
||||
scheme_method: Optional[str] = None
|
||||
if cavity is not None:
|
||||
ki = cavity.cavity_key_info or {}
|
||||
candidate_schemes = ki.get("candidate_schemes") if isinstance(ki, dict) else None
|
||||
if isinstance(candidate_schemes, list):
|
||||
for cs in candidate_schemes:
|
||||
if isinstance(cs, dict) and cs.get("scheme_id") == scheme_id:
|
||||
scheme_axis = (
|
||||
cs.get("axis")
|
||||
or cs.get("parting_axis")
|
||||
or "Z"
|
||||
)
|
||||
scheme_method = cs.get("method") or cs.get("scheme_method")
|
||||
break
|
||||
|
||||
# 5. 取用户角色(显式 JOIN 查询,避免 user.roles 在跨 session 下 lazy load 失败)
|
||||
role_codes = await self._fetch_user_role_codes(session, user.id)
|
||||
if getattr(user, "is_superuser", False):
|
||||
role_code = "admin"
|
||||
elif role_codes:
|
||||
role_code = role_codes[0]
|
||||
else:
|
||||
role_code = "user"
|
||||
|
||||
# 6. 写 ExperienceFeedback
|
||||
# expires_at 列是 naive TIMESTAMP(sa.DateTime()),asyncpg 拒绝 aware datetime
|
||||
# (DataError → 提交反馈 500);存库/比较统一 naive UTC(同 auth last_login 修复)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
new_ttl = now + timedelta(days=FEEDBACK_TTL_DAYS)
|
||||
feedback = ExperienceFeedback(
|
||||
processing_task_id=processing_task.id,
|
||||
stp_file_id=stp_file.id,
|
||||
scheme_id=scheme_id,
|
||||
scheme_axis=str(scheme_axis)[:1],
|
||||
scheme_method=scheme_method,
|
||||
feedback_status=feedback_status,
|
||||
feedback_reason=feedback_reason,
|
||||
adjust_suggestion=adjust_suggestion,
|
||||
process_params_snapshot=process_params_snapshot,
|
||||
fingerprint=fingerprint,
|
||||
confidence_at_submit=confidence_at_submit,
|
||||
score_at_submit=score_at_submit,
|
||||
user_id=user.id,
|
||||
role_code=role_code,
|
||||
expires_at=new_ttl,
|
||||
)
|
||||
session.add(feedback)
|
||||
await session.flush()
|
||||
|
||||
# 7. 同 stp_file_id 整体续期(D17 衰减:仅刷新过期 / NULL 行)
|
||||
await session.execute(
|
||||
update(ExperienceFeedback)
|
||||
.where(
|
||||
ExperienceFeedback.stp_file_id == stp_file.id,
|
||||
or_(
|
||||
ExperienceFeedback.expires_at.is_(None),
|
||||
ExperienceFeedback.expires_at < now,
|
||||
),
|
||||
)
|
||||
.values(expires_at=new_ttl)
|
||||
)
|
||||
await session.flush()
|
||||
return feedback
|
||||
|
||||
async def list_hints_for_task(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
stp_file_id: int,
|
||||
material_name: str,
|
||||
is_foam: bool,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""同指纹历史采纳摘要,给前端 ResultView 用。
|
||||
|
||||
排除 expires_at < now() 的过期反馈;按 material_family + is_foam 锚定;
|
||||
按 scheme_axis 聚合(adopted/rejected/adjust 计数 + 加权 confidence)。
|
||||
"""
|
||||
# 同上:expires_at 是 naive TIMESTAMP,SQL 参数也不能传 aware datetime
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
is_foam_str = "true" if is_foam else "false"
|
||||
mat_lower = (material_name or "").lower()
|
||||
if "al" in mat_lower and "si" in mat_lower:
|
||||
material_family = "foam"
|
||||
elif "abs" in mat_lower:
|
||||
material_family = "abs"
|
||||
elif "pp" in mat_lower:
|
||||
material_family = "pp"
|
||||
elif "pa" in mat_lower:
|
||||
material_family = "pa"
|
||||
else:
|
||||
material_family = "other"
|
||||
|
||||
rows = await session.execute(
|
||||
select(ExperienceFeedback)
|
||||
.where(
|
||||
ExperienceFeedback.stp_file_id == stp_file_id,
|
||||
or_(
|
||||
ExperienceFeedback.expires_at.is_(None),
|
||||
ExperienceFeedback.expires_at > now,
|
||||
),
|
||||
)
|
||||
.order_by(ExperienceFeedback.created_at.desc())
|
||||
.limit(limit * 4)
|
||||
)
|
||||
feedbacks = rows.scalars().all()
|
||||
|
||||
axis_summary: Dict[str, Dict[str, Any]] = {}
|
||||
for fb in feedbacks:
|
||||
fp = fb.fingerprint or {}
|
||||
# 锚定:material_family + is_foam 必须一致
|
||||
if fp.get("material_family") != material_family:
|
||||
continue
|
||||
if fp.get("is_foam") != is_foam_str:
|
||||
continue
|
||||
axis = fb.scheme_axis or "Z"
|
||||
summary = axis_summary.setdefault(axis, {
|
||||
"scheme_axis": axis,
|
||||
"adopted_count": 0,
|
||||
"rejected_count": 0,
|
||||
"adjust_count": 0,
|
||||
"sample_count": 0,
|
||||
})
|
||||
summary["sample_count"] += 1
|
||||
if fb.feedback_status == "adopted":
|
||||
summary["adopted_count"] += 1
|
||||
elif fb.feedback_status == "rejected":
|
||||
summary["rejected_count"] += 1
|
||||
elif fb.feedback_status == "adjust":
|
||||
summary["adjust_count"] += 1
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for axis, s in axis_summary.items():
|
||||
total = s["adopted_count"] + s["rejected_count"] + s["adjust_count"]
|
||||
if total == 0:
|
||||
continue
|
||||
confidence = (s["adopted_count"] - s["rejected_count"]) / max(total, 1)
|
||||
confidence = max(-1.0, min(1.0, confidence))
|
||||
weight = max(0.0, confidence) # weight 仅正向上有效(不"扣分"老算法)
|
||||
s["confidence"] = round(confidence, 3)
|
||||
s["weight"] = round(weight, 3)
|
||||
result.append(s)
|
||||
|
||||
result.sort(key=lambda x: (-x["weight"], -x["sample_count"]))
|
||||
return result[:limit]
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_user_role_codes(session: AsyncSession, user_id: int) -> List[str]:
|
||||
"""显式 JOIN 拿用户角色 codes,避免 user.roles 在跨 session 下 detached lazy load 失败。
|
||||
|
||||
测试场景下 user 是从一个 session 取出传到另一个 session,访问 user.roles 会触发
|
||||
DetachedInstanceError;生产场景下也以显式查询更稳(不依赖 ORM relationship 配置)。
|
||||
"""
|
||||
rows = await session.execute(
|
||||
select(Role.code)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user_id)
|
||||
)
|
||||
return [row[0] for row in rows.all()]
|
||||
|
||||
async def resolve_for_process_params(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_id: str,
|
||||
process_params: Dict[str, Any],
|
||||
bucket_hint: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""返回 OCC worker payload 用的 hints。
|
||||
|
||||
按 fingerprint bucket 找同指纹最近 N 条采纳,每条
|
||||
{scheme_axis, weight, sample_count, summary},传给 PartingSchemeScorer
|
||||
加成和 PartingCandidateGenerator 优先级加成。
|
||||
"""
|
||||
row = await session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
row = row.first()
|
||||
if not row:
|
||||
return []
|
||||
processing_task, stp_file = row
|
||||
params = processing_task.parameters or {}
|
||||
material_name = str(params.get("material") or "ABS")
|
||||
is_foam = bool(params.get("is_foam_material", False))
|
||||
|
||||
return await self.list_hints_for_task(
|
||||
session,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
)
|
||||
|
||||
|
||||
# 模块级单例(与 processing_service / task_storage_service 范式一致)
|
||||
experience_feedback_service = ExperienceFeedbackService()
|
||||
@@ -136,7 +136,8 @@ class ProcessingService:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.process_file_core(
|
||||
task_id, file_path, stp_file_id, db_session, process_params
|
||||
task_id, file_path, stp_file_id, db_session, process_params,
|
||||
timeout_seconds=timeout_seconds,
|
||||
),
|
||||
timeout_seconds,
|
||||
)
|
||||
@@ -178,8 +179,13 @@ class ProcessingService:
|
||||
stp_file_id: int,
|
||||
db_session: AsyncSession,
|
||||
process_params: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: float = 300,
|
||||
):
|
||||
"""核心处理逻辑"""
|
||||
"""核心处理逻辑
|
||||
|
||||
timeout_seconds:OCC 子进程各步骤的超时,由调用方按文件大小计算
|
||||
(曾因重构拆方法时漏传此参数,4 处引用 NameError,解析必挂)。
|
||||
"""
|
||||
|
||||
try:
|
||||
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
|
||||
@@ -223,8 +229,13 @@ class ProcessingService:
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
plan_result, export_artifacts = await self._step_generate_cavity(
|
||||
file_path, selected_material, is_foam_material, process_params,
|
||||
task_id, timeout=timeout_seconds,
|
||||
db_session,
|
||||
file_path,
|
||||
selected_material,
|
||||
is_foam_material,
|
||||
process_params,
|
||||
task_id,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
|
||||
# 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts),
|
||||
@@ -305,7 +316,7 @@ class ProcessingService:
|
||||
try:
|
||||
html_generator = HTMLGenerator(output_dir=str(html_out_dir))
|
||||
|
||||
detailed_cavity_json = await self._attach_scheme_previews(
|
||||
detailed_cavity_json, best_html_local = await self._attach_scheme_previews(
|
||||
detailed_cavity_json=detailed_cavity_json,
|
||||
geometry_data=geometry_data,
|
||||
stp_filename=Path(file_path).name,
|
||||
@@ -318,26 +329,37 @@ class ProcessingService:
|
||||
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
|
||||
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
||||
|
||||
# 8. 保存模具型腔数据(包含方案级预览链接)
|
||||
# 8. 保存模具型腔数据(包含方案级 html_file 预览链接)
|
||||
await self.analysis_storage.save_mold_cavity_data(
|
||||
db_session, stp_file_id, detailed_cavity_json
|
||||
)
|
||||
|
||||
html_file_path = html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(file_path).name,
|
||||
cavity_data=best_cavity_data,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
)
|
||||
await self._upload_report_artifacts(Path(html_file_path))
|
||||
# 任务级 HTML 复用推荐方案预览(各方案已生成并上传,不再重复生成)
|
||||
if best_html_local:
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(best_html_local).name,
|
||||
best_html_local,
|
||||
)
|
||||
else:
|
||||
# 兜底:候选方案均无 cavity_data 时退回任务级单份生成
|
||||
logger.warning("未生成任何方案级预览,回退任务级单份可视化")
|
||||
html_file_path = html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(file_path).name,
|
||||
cavity_data=best_cavity_data,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
)
|
||||
await self._upload_report_artifacts(Path(html_file_path))
|
||||
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(html_file_path).name,
|
||||
html_file_path,
|
||||
)
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(html_file_path).name,
|
||||
html_file_path,
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(html_out_dir, ignore_errors=True)
|
||||
stage_timings["persist_artifacts"] = round(time.perf_counter() - stage_started, 3)
|
||||
@@ -432,7 +454,7 @@ class ProcessingService:
|
||||
"material": requested_material,
|
||||
"parameters": process_params,
|
||||
"stage_timings": stage_timings,
|
||||
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}",
|
||||
"html_file": (best_scheme or {}).get("html_file") or "",
|
||||
"verification": verification_result,
|
||||
"llm_report": llm_report,
|
||||
"export_artifacts": export_artifacts,
|
||||
@@ -529,14 +551,46 @@ class ProcessingService:
|
||||
return mesh_result
|
||||
|
||||
async def _step_generate_cavity(
|
||||
self, file_path: str, selected_material: dict, is_foam_material: bool,
|
||||
process_params: Dict[str, Any], task_id: str, timeout: float = 600,
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
file_path: str,
|
||||
selected_material: dict,
|
||||
is_foam_material: bool,
|
||||
process_params: Dict[str, Any],
|
||||
task_id: str,
|
||||
timeout: float = 600,
|
||||
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||
"""生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。
|
||||
|
||||
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
|
||||
异常直接向编排层传播。返回 (plan_result, export_manifest)。
|
||||
|
||||
D17 Human-in-Loop 闭环:解析同指纹历史 hints(list of {scheme_axis, weight, sample_count, ...}),
|
||||
装进 OCC worker payload,让子进程内的 planner/candidate_generator/scheme_scorer 加成。
|
||||
hints 解析失败不阻塞主流程(logger.warning 后视为空),保证已有任务不退化。
|
||||
"""
|
||||
# D17:拉取同指纹老师傅经验(写入即消费)
|
||||
experience_hints: List[Dict[str, Any]] = []
|
||||
try:
|
||||
from moldinsight.services.experience_feedback_service import (
|
||||
experience_feedback_service,
|
||||
)
|
||||
experience_hints = await experience_feedback_service.resolve_for_process_params(
|
||||
session=db_session,
|
||||
task_id=task_id,
|
||||
process_params=process_params or {},
|
||||
)
|
||||
if experience_hints:
|
||||
logger.info(
|
||||
f"D17 Human-in-Loop:注入 {len(experience_hints)} 条经验"
|
||||
f"到 task={task_id} 的分模方案"
|
||||
)
|
||||
except Exception as hints_err:
|
||||
logger.warning(
|
||||
f"D17 hints 解析失败,回退到无 hints 模式: {hints_err}"
|
||||
)
|
||||
experience_hints = []
|
||||
|
||||
result = await self.run_occ(
|
||||
"generate_cavity",
|
||||
{
|
||||
@@ -546,6 +600,7 @@ class ProcessingService:
|
||||
"is_foam_material": is_foam_material,
|
||||
"process_params": process_params,
|
||||
"export_out_dir": os.path.abspath(self.cad_exporter.output_dir),
|
||||
"experience_hints": experience_hints, # D17 payload 通道
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -679,40 +734,49 @@ class ProcessingService:
|
||||
pointcloud_data: Optional[Dict[str, Any]] = None,
|
||||
lod_data: Optional[Dict[str, Any]] = None,
|
||||
html_generator: HTMLGenerator = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
|
||||
) -> Tuple[Dict[str, Any], Optional[str]]:
|
||||
"""为每个候选分模方案生成独立 3D 预览(HTML + 摘要/数据 JSON 直传 RustFS)。
|
||||
|
||||
前端结果页切方案即切预览,依赖方案级 scheme["html_file"]=/html/{name};
|
||||
只给最优方案生成单份 HTML(旧逻辑)会让备选方案永远显示推荐方案预览。
|
||||
|
||||
返回 (detailed_cavity_json, 推荐方案预览的本地路径):
|
||||
本地路径供 save_html_file 落 HTMLFile 记录(读侧要求本地文件存在),
|
||||
任务级 html_file 直接复用推荐方案预览,不再重复生成。
|
||||
"""
|
||||
if html_generator is None:
|
||||
raise ValueError(
|
||||
"html_generator 不能为空(D11:摘要产物统一经任务临时目录上传 RustFS)"
|
||||
"html_generator 不能为空(D11:可视化产物统一经任务临时目录上传 RustFS)"
|
||||
)
|
||||
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
|
||||
if not candidate_schemes:
|
||||
return detailed_cavity_json
|
||||
return detailed_cavity_json, None
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
best_html_local: Optional[str] = None
|
||||
|
||||
for scheme in candidate_schemes:
|
||||
cavity_data = scheme.get("cavity_data")
|
||||
if not cavity_data:
|
||||
continue
|
||||
suffix = scheme.get("scheme_id")
|
||||
base_stem = Path(stp_filename).stem.replace(" ", "_")
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
summary_name = f"mold_{base_stem}_{suffix}_{ts}_summary.json"
|
||||
summary_content = html_generator.generate_3d_viewer_summary(
|
||||
geometry_data, cavity_data
|
||||
suffix = str(scheme.get("scheme_id") or Path(stp_filename).stem)
|
||||
html_file_path = html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(stp_filename).name,
|
||||
cavity_data=cavity_data,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
suffix=suffix,
|
||||
)
|
||||
# D11:摘要 JSON 写任务临时目录后直传 RustFS 报告键,不落本地 html_output
|
||||
summary_path = html_generator.save_data_file(summary_content, summary_name)
|
||||
await rustfs_manager.upload_report_artifact(
|
||||
summary_name, Path(summary_path).read_bytes(),
|
||||
content_type="application/json",
|
||||
)
|
||||
scheme["summary_file"] = f"/html/{summary_name}"
|
||||
await self._upload_report_artifacts(Path(html_file_path))
|
||||
scheme["html_file"] = f"/html/{Path(html_file_path).name}"
|
||||
if best_scheme and scheme.get("scheme_id") == best_scheme.get("scheme_id"):
|
||||
best_html_local = html_file_path
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
if best_scheme:
|
||||
detailed_cavity_json["html_file"] = best_scheme.get("html_file")
|
||||
|
||||
return detailed_cavity_json
|
||||
return detailed_cavity_json, best_html_local
|
||||
|
||||
async def _upload_report_artifacts(self, html_file_path: Path):
|
||||
"""D11:任务临时目录中的可视化产物(.html / _summary.json / _data.json)
|
||||
|
||||
@@ -36,6 +36,15 @@ async def init_rustfs_storage():
|
||||
return False
|
||||
|
||||
|
||||
async def rustfs_startup_hook():
|
||||
"""app_factory startup_hooks 注入点(D3 收敛):moldinsight/unified 入口把
|
||||
本钩子传入 create_app(startup_hooks=[rustfs_startup_hook]),RustFS 连接接线
|
||||
归属 moldinsight 层,平台工厂不再持有模块专属依赖(原 connect_rustfs 已移除)。
|
||||
"""
|
||||
ok = await init_rustfs_storage()
|
||||
print(f"[{'OK' if ok else 'WARN'}] RustFS")
|
||||
|
||||
|
||||
async def test_storage():
|
||||
"""测试 RustFS 对象存储功能"""
|
||||
try:
|
||||
|
||||
@@ -27,7 +27,6 @@ def create_app(
|
||||
title: str,
|
||||
service_name: str,
|
||||
version: str = "4.0.0",
|
||||
connect_rustfs: bool = False,
|
||||
serve_frontend_static: bool = False,
|
||||
startup_hooks: Optional[List[Callable[[], Awaitable[None]]]] = None,
|
||||
register_routers: Optional[Callable[[FastAPI], None]] = None,
|
||||
@@ -38,11 +37,10 @@ def create_app(
|
||||
title: 应用标题
|
||||
service_name: 服务名(用于 /health 响应)
|
||||
version: 版本号
|
||||
connect_rustfs: 是否连接 RustFS 对象存储(moldinsight 需要)。
|
||||
D11:/html 报告读取由 moldinsight.api.html_report_router 代理
|
||||
(RustFS 报告键直取 + 遗留对象/本地卷兜底),不再挂本地 StaticFiles。
|
||||
serve_frontend_static: 是否由后端托管 /static 与 SPA fallback(默认关闭,前端独立部署)
|
||||
startup_hooks: 额外的 startup 钩子列表(在数据库/RustFS/Redis 初始化后执行)
|
||||
startup_hooks: 额外的 startup 钩子列表(在数据库/Redis 初始化后执行)。
|
||||
模块专属的启动接线(如 moldinsight 的 RustFS 连接)经此参数注入——
|
||||
D3 收敛:平台工厂不再持有模块专属依赖(原 connect_rustfs 已移除)。
|
||||
register_routers: 回调函数,用于注册业务路由
|
||||
"""
|
||||
app = FastAPI(title=title, version=version)
|
||||
@@ -115,20 +113,6 @@ def create_app(
|
||||
success = await init_database(keep_connected=True)
|
||||
print(f"[{'OK' if success else 'FAIL'}] 数据库初始化")
|
||||
|
||||
# RustFS(仅 moldinsight 需要)
|
||||
if connect_rustfs:
|
||||
try:
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
await rustfs_manager.connect(
|
||||
endpoint=settings.RUSTFS_ENDPOINT,
|
||||
access_key=settings.RUSTFS_ACCESS_KEY,
|
||||
secret_key=settings.RUSTFS_SECRET_KEY,
|
||||
timeout=settings.RUSTFS_TIMEOUT,
|
||||
)
|
||||
print("[OK] RustFS连接成功")
|
||||
except Exception as e:
|
||||
print(f"[WARN] RustFS连接失败: {e}")
|
||||
|
||||
# Redis
|
||||
try:
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
|
||||
@@ -53,16 +53,31 @@ class DatabaseManager:
|
||||
return
|
||||
|
||||
try:
|
||||
pool_cfg = _get_pool_config(role)
|
||||
# 创建异步引擎
|
||||
self.engine = create_async_engine(
|
||||
database_url,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=pool_cfg["pool_size"],
|
||||
max_overflow=pool_cfg["max_overflow"],
|
||||
pool_recycle=3600,
|
||||
pool_pre_ping=True, # 自动检测失效连接,避免 PG 断连报错
|
||||
)
|
||||
if role == "celery":
|
||||
# Celery 场景必须用 NullPool:celery_tasks 经 asyncio.run 每任务
|
||||
# 新建事件循环,而 asyncpg 连接绑定创建它的循环——QueuePool 会把
|
||||
# 上一循环的连接缓存着流入新循环,报
|
||||
# "got Future attached to a different loop"(任务全挂)且销毁时
|
||||
# "Event loop is closed"。NullPool 不缓存:每次 checkout 在当前
|
||||
# 循环新建连接、用完即关。局域网建连 ~1ms,对分钟级分析任务可忽略。
|
||||
# (与 redis_task_manager 每任务 reconnect() 是同一循环绑定问题)
|
||||
from sqlalchemy.pool import NullPool
|
||||
self.engine = create_async_engine(
|
||||
database_url,
|
||||
echo=settings.DEBUG,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
pool_cfg = _get_pool_config(role)
|
||||
# 创建异步引擎
|
||||
self.engine = create_async_engine(
|
||||
database_url,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=pool_cfg["pool_size"],
|
||||
max_overflow=pool_cfg["max_overflow"],
|
||||
pool_recycle=3600,
|
||||
pool_pre_ping=True, # 自动检测失效连接,避免 PG 断连报错
|
||||
)
|
||||
|
||||
# 创建异步会话工厂
|
||||
self.async_session = async_sessionmaker(
|
||||
@@ -76,10 +91,13 @@ class DatabaseManager:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
|
||||
self.is_connected = True
|
||||
logger.info(
|
||||
"数据库连接成功 (pool_size=%d, max_overflow=%d)",
|
||||
pool_cfg["pool_size"], pool_cfg["max_overflow"],
|
||||
)
|
||||
if role == "celery":
|
||||
logger.info("数据库连接成功 (role=celery, pool=NullPool)")
|
||||
else:
|
||||
logger.info(
|
||||
"数据库连接成功 (role=%s, pool_size=%d, max_overflow=%d)",
|
||||
role, pool_cfg["pool_size"], pool_cfg["max_overflow"],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
|
||||
+115
-27
@@ -35,11 +35,15 @@ async def _run_alembic_migrations() -> None:
|
||||
- 既有但无 alembic_version(历史 DB):先 stamp head 基线,再 upgrade(no-op)
|
||||
"""
|
||||
async with db_manager.engine.begin() as conn:
|
||||
has_alembic = await conn.execute(text("SELECT to_regclass('public.alembic_version')")).scalar()
|
||||
# 注意括号:await 优先级低于属性访问,await conn.execute(...).scalar()
|
||||
# 实际是 await (conn.execute(...).scalar())——会在协程对象上调 .scalar()
|
||||
# 直接 AttributeError。必须 (await conn.execute(...)).scalar()。
|
||||
# 曾因漏括号让启动迁移自诞生起一次都没跑通过(2026-09-26 事故根因)。
|
||||
has_alembic = (await conn.execute(text("SELECT to_regclass('public.alembic_version')"))).scalar()
|
||||
if not has_alembic:
|
||||
table_count = await conn.execute(
|
||||
table_count = (await conn.execute(
|
||||
text("SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name <> 'alembic_version'")
|
||||
).scalar()
|
||||
)).scalar()
|
||||
if table_count and table_count > 0:
|
||||
logger.info("检测到既有 DB 未纳入 alembic 管理,自动 stamp head 作为基线")
|
||||
await asyncio.to_thread(_alembic_stamp_head)
|
||||
@@ -65,55 +69,86 @@ DEFAULT_PERMISSIONS = [
|
||||
{"code": "view_users", "name": "查看用户", "module": "admin"},
|
||||
{"code": "manage_users", "name": "管理用户", "module": "admin"},
|
||||
{"code": "manage_roles", "name": "管理角色", "module": "admin"},
|
||||
# D17 Human-in-Loop:老师傅经验反馈
|
||||
{"code": "view_experience_feedback", "name": "查看老师傅反馈", "module": "moldinsight"},
|
||||
{"code": "feedback_experience_hint", "name": "提交方案级反馈", "module": "moldinsight"},
|
||||
{"code": "manage_experience_feedback", "name": "管理老师傅反馈", "module": "moldinsight"},
|
||||
]
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
{"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles"]},
|
||||
{"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles", "view_experience_feedback", "feedback_experience_hint", "manage_experience_feedback"]},
|
||||
{"code": "user", "name": "普通用户", "description": "普通用户,可使用模具分析和查看库存", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance", "manage_receipts", "manage_payments"]},
|
||||
{"code": "viewer", "name": "只读用户", "description": "只读用户,只能查看数据", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance"]},
|
||||
# D17 Human-in-Loop:工艺工程师角色——可查看 + 提交老师傅反馈
|
||||
{"code": "process_engineer", "name": "工艺工程师", "description": "工艺工程师,可查看 + 提交老师傅经验反馈", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_experience_feedback", "feedback_experience_hint"]},
|
||||
]
|
||||
|
||||
|
||||
async def init_permissions(session):
|
||||
"""初始化权限"""
|
||||
"""初始化权限(按 code 补登:已存在跳过,缺失新增)
|
||||
|
||||
设计要点(D17 修复):
|
||||
- 旧实现 `if existing_perms: return` 会让既存 DB 启动期漏掉新增权限码
|
||||
- 改为按 code 比对:已存在的 permission 保留 id(避免 FK 引用失效),
|
||||
缺失的新增;这样后续 DEFAULT_PERMISSIONS 追加的项也能在升级时落到既存 DB
|
||||
"""
|
||||
result = await session.execute(select(Permission))
|
||||
existing_perms = result.scalars().all()
|
||||
|
||||
if existing_perms:
|
||||
logger.info("权限已初始化")
|
||||
return
|
||||
|
||||
perm_map = {}
|
||||
existing_perms = {p.code: p for p in result.scalars().all()}
|
||||
|
||||
perm_map = {p.code: p.id for p in existing_perms.values()}
|
||||
new_count = 0
|
||||
for perm_data in DEFAULT_PERMISSIONS:
|
||||
if perm_data["code"] in existing_perms:
|
||||
continue
|
||||
perm = Permission(**perm_data)
|
||||
session.add(perm)
|
||||
await session.flush()
|
||||
perm_map[perm.code] = perm.id
|
||||
|
||||
logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限")
|
||||
new_count += 1
|
||||
|
||||
if not existing_perms:
|
||||
logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限")
|
||||
elif new_count:
|
||||
logger.info(f"补登了 {new_count} 个权限(既有 DB 升级)")
|
||||
else:
|
||||
logger.info("权限已初始化(无新增)")
|
||||
return perm_map
|
||||
|
||||
|
||||
async def init_roles(session, perm_map):
|
||||
"""初始化角色"""
|
||||
"""初始化角色(按 code 补登:已存在跳过,缺失新建 + 完整绑定 permissions)
|
||||
|
||||
设计要点(D17 修复):
|
||||
- 旧实现 `if existing_roles: return` 会让既存 DB 启动期漏掉新角色
|
||||
- 改为按 code 比对:已存在的角色不重置其 RolePermission 绑定
|
||||
(避免重建关联破坏 user / role 关系),缺失的角色按 DEFAULT_ROLES 完整创建
|
||||
"""
|
||||
result = await session.execute(select(Role))
|
||||
existing_roles = result.scalars().all()
|
||||
|
||||
if existing_roles:
|
||||
logger.info("角色已初始化")
|
||||
return
|
||||
|
||||
existing_roles = {r.code: r for r in result.scalars().all()}
|
||||
|
||||
new_count = 0
|
||||
for role_data in DEFAULT_ROLES:
|
||||
perm_ids = [perm_map[code] for code in role_data.pop("permissions")]
|
||||
code = role_data["code"]
|
||||
if code in existing_roles:
|
||||
continue
|
||||
perm_codes = role_data.pop("permissions")
|
||||
role = Role(**role_data)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
for perm_id in perm_ids:
|
||||
for perm_code in perm_codes:
|
||||
perm_id = perm_map.get(perm_code)
|
||||
if perm_id is None:
|
||||
continue
|
||||
rp = RolePermission(role_id=role.id, permission_id=perm_id)
|
||||
session.add(rp)
|
||||
|
||||
logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色")
|
||||
new_count += 1
|
||||
|
||||
if not existing_roles:
|
||||
logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色")
|
||||
elif new_count:
|
||||
logger.info(f"补登了 {new_count} 个角色(既有 DB 升级)")
|
||||
else:
|
||||
logger.info("角色已初始化(无新增)")
|
||||
|
||||
|
||||
async def create_admin_user(session):
|
||||
@@ -153,6 +188,51 @@ async def create_admin_user(session):
|
||||
logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}")
|
||||
|
||||
|
||||
async def _verify_schema_coverage() -> None:
|
||||
"""启动期 schema 校验:模型声明的表/列必须真实存在于 DB。
|
||||
|
||||
背景(2026-09-26 事故):AUTO_MIGRATE 启动迁移曾静默失败,prod 库缺
|
||||
stp_files.product_id 等列,上传全挂两天无启动日志线索。此校验保证
|
||||
『迁移被跳过 / stamp 基线掩盖未应用增量』这类漂移在启动时即被点名。
|
||||
|
||||
只查缺、不查多:DB 里的遗留列/表(如 users.is_superuser)是历史产物,不管。
|
||||
发现缺失只记 error 不抛——服务照常起,但 docker logs 必有醒目线索。
|
||||
"""
|
||||
import moldinsight.models # noqa: F401
|
||||
import inventory.models # noqa: F401
|
||||
from shared.models.base import Base
|
||||
|
||||
async with db_manager.engine.connect() as conn:
|
||||
rows = (await conn.execute(text(
|
||||
"SELECT table_name, column_name FROM information_schema.columns "
|
||||
"WHERE table_schema='public'"
|
||||
))).all()
|
||||
db_cols: dict = {}
|
||||
for tn, cn in rows:
|
||||
db_cols.setdefault(tn, set()).add(cn)
|
||||
|
||||
missing = []
|
||||
for tname, table in Base.metadata.tables.items():
|
||||
present = db_cols.get(tname)
|
||||
if present is None:
|
||||
missing.append(f"整表缺失: {tname}")
|
||||
continue
|
||||
absent = {c.name for c in table.columns} - present
|
||||
if absent:
|
||||
missing.append(f"{tname} 缺列: {sorted(absent)}")
|
||||
|
||||
if missing:
|
||||
logger.error(
|
||||
"schema 校验失败:模型声明了 %d 处 DB 缺失(迁移链与实际 schema 不一致,"
|
||||
"请人工执行 alembic upgrade head 或核对基线):%s",
|
||||
len(missing), "; ".join(missing),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"schema 校验通过:模型 %d 张表的全部列均存在于 DB", len(Base.metadata.tables)
|
||||
)
|
||||
|
||||
|
||||
async def init_database(keep_connected: bool = True):
|
||||
"""初始化数据库"""
|
||||
try:
|
||||
@@ -164,6 +244,12 @@ async def init_database(keep_connected: bool = True):
|
||||
"AUTO_MIGRATE=false:跳过启动期 alembic 迁移,"
|
||||
"schema 由部署流程单点执行(alembic CLI 或 python -m shared.database.init_db)"
|
||||
)
|
||||
|
||||
# 迁移后校验(只读、不抛):迁移链与实际 schema 脱节时在启动日志直接点名
|
||||
try:
|
||||
await _verify_schema_coverage()
|
||||
except Exception:
|
||||
logger.exception("schema 校验自身异常(不影响启动流程)")
|
||||
|
||||
async with db_manager.session() as session:
|
||||
perm_map = await init_permissions(session)
|
||||
@@ -192,7 +278,9 @@ async def init_database(keep_connected: bool = True):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库初始化失败: {e}")
|
||||
# logger.exception 而非 error(f"{e}"):吞掉 traceback 曾让启动迁移静默
|
||||
# 失败两天无从排查(2026-09-26 事故)
|
||||
logger.exception(f"数据库初始化失败: {e}")
|
||||
print(f"数据库初始化失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
@@ -31,8 +31,7 @@ class UserResponse(BaseModel):
|
||||
is_superuser: bool = False
|
||||
roles: List[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
@@ -60,8 +59,7 @@ class RoleResponse(BaseModel):
|
||||
is_system: bool
|
||||
permissions: List[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PermissionCreate(BaseModel):
|
||||
@@ -78,8 +76,7 @@ class PermissionResponse(BaseModel):
|
||||
module: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, ExpiredSignatureError, jwt
|
||||
import bcrypt
|
||||
@@ -48,9 +48,9 @@ def get_password_hash(password: str) -> str:
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, _require_secret_key(), algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
@@ -142,7 +142,11 @@ 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()
|
||||
# last_login 列是 TIMESTAMP WITHOUT TIME ZONE(sa.DateTime()),asyncpg 拒绝写入
|
||||
# 带 tzinfo 的 datetime(DataError → 登录 500):批次5 弃用清理把 utcnow() 换成
|
||||
# aware 时间后,任何一次成功登录都会在 commit 处炸掉。存库统一 naive UTC,
|
||||
# JWT exp(上方 create_access_token)不受影响,仍用 aware。
|
||||
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
await db_session.commit()
|
||||
|
||||
return user
|
||||
|
||||
+54
-2
@@ -20,9 +20,10 @@ from inventory.api import inventory_router
|
||||
from shared.models.base import Base
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Warehouse, Supplier, Product, ProductMaterial, Inventory, MaterialSupplier, SalesOrder, SalesOrderItem
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
import moldinsight.models # noqa: F401 # 全量注册:create_all 需含 moldinsight 表(stp_files 等)
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -133,13 +134,58 @@ 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:
|
||||
yield session
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""D13 部署侧契约:仅在显式 --run-lockfile-check 时启用锁文件存在性断言。"""
|
||||
parser.addoption(
|
||||
"--run-lockfile-check",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="启用 D13 锁文件部署侧契约测试(CI 镜像构建 job 使用)",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""默认跳过 D13 部署侧契约(仓库侧单测不应被尚未落地的锁文件阻断)。"""
|
||||
if config.getoption("--run-lockfile-check", default=False):
|
||||
return
|
||||
skip_marker = pytest.mark.skip(
|
||||
reason="D13 部署侧契约:默认 skip;CI 镜像构建 job 需传入 --run-lockfile-check 启用"
|
||||
)
|
||||
for item in items:
|
||||
if "test_lockfile_generation" in item.nodeid:
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def client(async_engine, seeded_db):
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
@@ -156,8 +202,14 @@ async def client(async_engine, seeded_db):
|
||||
result = await session.execute(select(User).where(User.username == "tester"))
|
||||
return result.scalar_one()
|
||||
|
||||
async def override_get_current_admin_user():
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(select(User).where(User.username == "tester"))
|
||||
return result.scalar_one()
|
||||
|
||||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
test_app.dependency_overrides[get_current_active_user] = override_get_current_active_user
|
||||
test_app.dependency_overrides[get_current_admin_user] = override_get_current_admin_user
|
||||
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_returns_seeded_summary(client):
|
||||
resp = await client.get("/api/dashboard")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["material_count"] == 1
|
||||
assert body["finished_product_count"] == 2
|
||||
assert body["supplier_count"] == 1
|
||||
assert body["customer_count"] == 1
|
||||
assert body["warehouse_count"] == 1
|
||||
assert body["total_stock"] == 1000
|
||||
assert float(body["total_value"]) == 10000.0
|
||||
assert body["pending_purchase"] == 0
|
||||
assert body["pending_sales"] == 0
|
||||
assert body["low_stock_products"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_low_stock_products_are_listed(client):
|
||||
inventory = await client.get("/api/inventory")
|
||||
assert inventory.status_code == 200
|
||||
inv_id = next(row["id"] for row in inventory.json()["items"] if row["product_sku"] == "MAT-001")
|
||||
|
||||
updated = await client.put(
|
||||
f"/api/inventory/{inv_id}",
|
||||
json={"quantity": 0, "locked_quantity": 0},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
|
||||
resp = await client.get("/api/dashboard")
|
||||
assert resp.status_code == 200
|
||||
low_stock = resp.json()["low_stock_products"]
|
||||
assert len(low_stock) == 1
|
||||
assert low_stock[0]["sku"] == "MAT-001"
|
||||
assert low_stock[0]["quantity"] == 0
|
||||
assert low_stock[0]["min_stock"] == 0
|
||||
@@ -0,0 +1,180 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_customer_list_search_filters_active_rows(client):
|
||||
create_resp = await client.post(
|
||||
"/api/customers",
|
||||
json={"name": "目标客户Alpha", "contact_person": "张三"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
customer_id = create_resp.json()["id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/customers/{customer_id}")
|
||||
assert delete_resp.status_code == 200
|
||||
|
||||
kept_resp = await client.post(
|
||||
"/api/customers",
|
||||
json={"name": "目标客户Beta", "contact_person": "李四"},
|
||||
)
|
||||
assert kept_resp.status_code == 201
|
||||
|
||||
resp = await client.get("/api/customers", params={"search": "目标客户"})
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()
|
||||
names = [row["name"] for row in rows]
|
||||
assert "目标客户Beta" in names
|
||||
assert "目标客户Alpha" not in names
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_customer_create_auto_generates_code(client):
|
||||
resp = await client.post(
|
||||
"/api/customers",
|
||||
json={"name": "自动编码客户", "phone": "123456"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["code"].startswith("C")
|
||||
assert body["name"] == "自动编码客户"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_customer_update_returns_latest_fields(client):
|
||||
create_resp = await client.post(
|
||||
"/api/customers",
|
||||
json={"name": "旧客户名", "email": "old@example.com"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
customer_id = create_resp.json()["id"]
|
||||
|
||||
update_resp = await client.put(
|
||||
f"/api/customers/{customer_id}",
|
||||
json={"code": "C-CUSTOM", "name": "新客户名", "email": "new@example.com"},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
body = update_resp.json()
|
||||
assert body["code"] == "C-CUSTOM"
|
||||
assert body["name"] == "新客户名"
|
||||
assert body["email"] == "new@example.com"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_customer_delete_soft_deletes_row(client):
|
||||
create_resp = await client.post(
|
||||
"/api/customers",
|
||||
json={"name": "待删除客户"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
customer_id = create_resp.json()["id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/customers/{customer_id}")
|
||||
assert delete_resp.status_code == 200
|
||||
assert delete_resp.json()["message"] == "客户已删除"
|
||||
|
||||
list_resp = await client.get("/api/customers", params={"search": "待删除客户"})
|
||||
assert list_resp.status_code == 200
|
||||
assert all(row["id"] != customer_id for row in list_resp.json())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_supplier_list_search_filters_active_rows(client):
|
||||
create_resp = await client.post(
|
||||
"/api/suppliers",
|
||||
json={"name": "目标供应商Alpha", "contact_person": "王五"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
supplier_id = create_resp.json()["id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/suppliers/{supplier_id}")
|
||||
assert delete_resp.status_code == 200
|
||||
|
||||
kept_resp = await client.post(
|
||||
"/api/suppliers",
|
||||
json={"name": "目标供应商Beta", "contact_person": "赵六"},
|
||||
)
|
||||
assert kept_resp.status_code == 201
|
||||
|
||||
resp = await client.get("/api/suppliers", params={"search": "目标供应商"})
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()
|
||||
names = [row["name"] for row in rows]
|
||||
assert "目标供应商Beta" in names
|
||||
assert "目标供应商Alpha" not in names
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_supplier_create_auto_generates_code(client):
|
||||
resp = await client.post(
|
||||
"/api/suppliers",
|
||||
json={"name": "自动编码供应商", "phone": "123456"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["code"].startswith("S")
|
||||
assert body["name"] == "自动编码供应商"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_supplier_update_returns_latest_fields(client):
|
||||
create_resp = await client.post(
|
||||
"/api/suppliers",
|
||||
json={"name": "旧供应商名", "email": "old-s@example.com"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
supplier_id = create_resp.json()["id"]
|
||||
|
||||
update_resp = await client.put(
|
||||
f"/api/suppliers/{supplier_id}",
|
||||
json={"code": "S-CUSTOM", "name": "新供应商名", "email": "new-s@example.com"},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
body = update_resp.json()
|
||||
assert body["code"] == "S-CUSTOM"
|
||||
assert body["name"] == "新供应商名"
|
||||
assert body["email"] == "new-s@example.com"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_supplier_delete_soft_deletes_row(client):
|
||||
create_resp = await client.post(
|
||||
"/api/suppliers",
|
||||
json={"name": "待删除供应商"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
supplier_id = create_resp.json()["id"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/suppliers/{supplier_id}")
|
||||
assert delete_resp.status_code == 200
|
||||
assert delete_resp.json()["message"] == "供应商已删除"
|
||||
|
||||
list_resp = await client.get("/api/suppliers", params={"search": "待删除供应商"})
|
||||
assert list_resp.status_code == 200
|
||||
assert all(row["id"] != supplier_id for row in list_resp.json())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_warehouse_list_orders_default_first(client):
|
||||
create_resp = await client.post(
|
||||
"/api/warehouses",
|
||||
json={"code": "W002", "name": "普通仓库"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
|
||||
resp = await client.get("/api/warehouses")
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()
|
||||
assert rows[0]["is_default"] is True
|
||||
assert rows[0]["name"] == "默认仓库"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_warehouse_create_auto_generates_code(client):
|
||||
resp = await client.post(
|
||||
"/api/warehouses",
|
||||
json={"name": "自动编码仓库", "manager": "管理员A"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["code"].startswith("W")
|
||||
assert body["name"] == "自动编码仓库"
|
||||
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_material_price_history_create_updates_latest_cost(client):
|
||||
resp = await client.post(
|
||||
"/api/materials/1/price-history",
|
||||
json={"price": 12.5, "supplier_id": 1, "remark": "latest"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["product_id"] == 1
|
||||
assert body["price"] == 12.5
|
||||
assert body["supplier_name"] == "供应商A"
|
||||
|
||||
history = await client.get("/api/materials/1/price-history")
|
||||
assert history.status_code == 200
|
||||
assert history.json()[0]["price"] == 12.5
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_material_price_trend_returns_change_summary(client):
|
||||
resp1 = await client.post(
|
||||
"/api/materials/1/price-history",
|
||||
json={"price": 10.0, "supplier_id": 1},
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
resp2 = await client.post(
|
||||
"/api/materials/1/price-history",
|
||||
json={"price": 15.0, "supplier_id": 1},
|
||||
)
|
||||
assert resp2.status_code == 201
|
||||
|
||||
trend = await client.get("/api/materials/1/price-trend", params={"months": 6})
|
||||
assert trend.status_code == 200
|
||||
body = trend.json()
|
||||
prices = [item["price"] for item in body["price_history"]]
|
||||
assert sorted(prices) == [10.0, 15.0]
|
||||
assert body["current_price"] in {10.0, 15.0}
|
||||
assert body["price_change"] in {5.0, -5.0}
|
||||
assert body["price_change_percent"] in {50.0, -33.33}
|
||||
assert len(body["price_history"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_material_price_trend_requires_history(client):
|
||||
resp = await client.get("/api/materials/1/price-trend")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_material_supplier_and_list_by_material(client):
|
||||
resp = await client.post(
|
||||
"/api/materials/1/suppliers",
|
||||
json={"supplier_id": 1, "is_primary": True, "lead_time": 7, "min_order_quantity": 10},
|
||||
)
|
||||
assert resp.status_code == 400 # seeded_db 已存在主关联
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_material_suppliers_and_supplier_materials(client):
|
||||
by_material = await client.get("/api/materials/1/suppliers")
|
||||
assert by_material.status_code == 200
|
||||
material_rows = by_material.json()
|
||||
assert len(material_rows) == 1
|
||||
assert material_rows[0]["supplier_id"] == 1
|
||||
assert material_rows[0]["supplier_name"] == "供应商A"
|
||||
|
||||
by_supplier = await client.get("/api/materials/suppliers/1/materials")
|
||||
assert by_supplier.status_code == 200
|
||||
supplier_rows = by_supplier.json()
|
||||
assert len(supplier_rows) == 1
|
||||
assert supplier_rows[0]["product_id"] == 1
|
||||
assert supplier_rows[0]["product_sku"] == "MAT-001"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remove_material_supplier_deletes_association(client):
|
||||
resp = await client.delete("/api/materials/suppliers/1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "物料供应商关联已删除"
|
||||
|
||||
by_material = await client.get("/api/materials/1/suppliers")
|
||||
assert by_material.status_code == 200
|
||||
assert by_material.json() == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"url,payload,expected",
|
||||
[
|
||||
("/api/materials/2/price-history", {"price": 10.0}, 400),
|
||||
("/api/materials/99999/price-history", {"price": 10.0}, 404),
|
||||
("/api/materials/1/price-history", {"price": 10.0, "supplier_id": 99999}, 400),
|
||||
("/api/materials/1/suppliers", {"supplier_id": 99999}, 400),
|
||||
],
|
||||
)
|
||||
async def test_material_endpoints_validation(url, payload, expected, client):
|
||||
resp = await client.post(url, json=payload)
|
||||
assert resp.status_code == expected
|
||||
@@ -0,0 +1,176 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_products_returns_material_cost_for_finished_goods(client):
|
||||
resp = await client.get("/api/products")
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()
|
||||
finished = next(row for row in rows if row["sku"] == "MOLD-STD")
|
||||
assert float(finished["material_cost"]) == 20.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_product_finished_resets_stock_bounds(client):
|
||||
resp = await client.post(
|
||||
"/api/products",
|
||||
json={
|
||||
"sku": "FG-NEW",
|
||||
"name": "新成品",
|
||||
"item_type": "finished",
|
||||
"unit": "件",
|
||||
"min_stock": 10,
|
||||
"max_stock": 99,
|
||||
"cost_price": 12,
|
||||
"sale_price": 20,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["sku"] == "FG-NEW"
|
||||
assert body["min_stock"] == 0
|
||||
assert body["max_stock"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_product_rejects_duplicate_sku(client):
|
||||
created = await client.post(
|
||||
"/api/products",
|
||||
json={
|
||||
"sku": "FG-2",
|
||||
"name": "成品2",
|
||||
"item_type": "finished",
|
||||
"unit": "件",
|
||||
"cost_price": 0,
|
||||
"sale_price": 1,
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
product_id = created.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/products/{product_id}",
|
||||
json={
|
||||
"sku": "MOLD-STD",
|
||||
"name": "改名",
|
||||
"item_type": "finished",
|
||||
"unit": "件",
|
||||
"cost_price": 0,
|
||||
"sale_price": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "SKU已存在"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_product_soft_deletes_record(client):
|
||||
created = await client.post(
|
||||
"/api/products",
|
||||
json={
|
||||
"sku": "MAT-DEL",
|
||||
"name": "待删物料",
|
||||
"item_type": "material",
|
||||
"unit": "kg",
|
||||
"cost_price": 2,
|
||||
"sale_price": 0,
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
product_id = created.json()["id"]
|
||||
|
||||
deleted = await client.delete(f"/api/products/{product_id}")
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["message"] == "产品已删除"
|
||||
|
||||
listed = await client.get("/api/products")
|
||||
assert listed.status_code == 200
|
||||
assert all(row["id"] != product_id for row in listed.json())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_product_bom_returns_line_costs(client):
|
||||
resp = await client.get("/api/products/2/materials")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["product_id"] == 2
|
||||
assert float(body["total_material_cost"]) == 20.0
|
||||
assert len(body["items"]) == 1
|
||||
assert body["items"][0]["material_sku"] == "MAT-001"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_replace_product_bom_rewrites_items(client):
|
||||
created = await client.post(
|
||||
"/api/products",
|
||||
json={
|
||||
"sku": "MAT-002",
|
||||
"name": "铝材",
|
||||
"item_type": "material",
|
||||
"unit": "kg",
|
||||
"cost_price": 5,
|
||||
"sale_price": 0,
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
material_id = created.json()["id"]
|
||||
|
||||
replaced = await client.put(
|
||||
"/api/products/2/materials",
|
||||
json={
|
||||
"items": [
|
||||
{"material_id": 1, "quantity": 1, "loss_rate": 0},
|
||||
{"material_id": material_id, "quantity": 2, "loss_rate": 0.1},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert replaced.status_code == 200
|
||||
body = replaced.json()
|
||||
assert len(body["items"]) == 2
|
||||
assert float(body["total_material_cost"]) == 20.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"url,payload,expected",
|
||||
[
|
||||
("/api/products", {"sku": "BAD", "name": "bad", "item_type": "unknown", "unit": "件", "cost_price": 0, "sale_price": 0}, 400),
|
||||
("/api/products/2/materials", {"items": [{"material_id": 1, "quantity": 1}, {"material_id": 1, "quantity": 2}]}, 400),
|
||||
("/api/products/2/materials", {"items": [{"material_id": 99999, "quantity": 1}]}, 400),
|
||||
("/api/products/2/materials", {"items": [{"material_id": 2, "quantity": 1}]}, 400),
|
||||
("/api/products/2/materials", {"items": [{"material_id": 1, "quantity": 0}]}, 400),
|
||||
("/api/products/1/materials", {"items": []}, 400),
|
||||
],
|
||||
)
|
||||
async def test_product_service_validation_paths(url, payload, expected, client):
|
||||
method = client.post if url == "/api/products" else client.put
|
||||
resp = await method(url, json=payload)
|
||||
assert resp.status_code == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_product_from_task_creates_and_binds_product(client):
|
||||
resp = await client.post("/api/products/from-task/task-demo-1")
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["sku"] == "MI1"
|
||||
assert body["name"] == "demo-mold"
|
||||
assert body["category"] == "模具成品"
|
||||
assert body["item_type"] == "finished"
|
||||
assert body["min_stock"] == 0
|
||||
assert body["max_stock"] == 0
|
||||
assert "由模具分析创建" in body["description"]
|
||||
|
||||
again = await client.post("/api/products/from-task/task-demo-1")
|
||||
assert again.status_code == 201
|
||||
assert again.json()["id"] == body["id"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"task_id,expected",
|
||||
[("missing-task", 404)],
|
||||
)
|
||||
async def test_create_product_from_task_validation(task_id, expected, client):
|
||||
resp = await client.post(f"/api/products/from-task/{task_id}")
|
||||
assert resp.status_code == expected
|
||||
@@ -0,0 +1,417 @@
|
||||
"""D17 Human-in-Loop 闭环:算法接缝回归测试。
|
||||
|
||||
覆盖:
|
||||
- PartingCandidateGenerator:hints 注入 axis 优先级、method 标签
|
||||
- PartingSchemeScorer:score_breakdown 新增 human_hint_bonus、total_score 加成
|
||||
- MultiSchemeMoldPlanner:hints 透传、global_summary.applied_hints
|
||||
- processing_service:OCC payload 装配 experience_hints(OCC-gated)
|
||||
|
||||
注:PartingCandidateGenerator / PartingSchemeScorer / MultiSchemeMoldPlanner 本身
|
||||
不直接 import OCC(OCC shape 留 lazy 在 occ_worker),可在无 OCC 环境直接测试。
|
||||
processing_service.py 通过 occ_process_pool 间接 import OCC,那两个测试 OCC-gated。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# OCC 条件探测(仅 processing_service 测试需要)
|
||||
try:
|
||||
import OCC # noqa: F401
|
||||
HAS_OCC = True
|
||||
except ImportError:
|
||||
HAS_OCC = False
|
||||
|
||||
OCC_GATED = pytest.mark.skipif(
|
||||
not HAS_OCC,
|
||||
reason="D17 payload 测试依赖 processing_service(含 occ_process_pool),"
|
||||
"OCC 缺失时无法 import;项目硬规则 OCC-gated",
|
||||
)
|
||||
|
||||
|
||||
# ── PartingCandidateGenerator 测试 ──
|
||||
|
||||
def _make_analysis(dims=(80.0, 60.0, 40.0), volume=50000.0):
|
||||
"""构造 PartingCandidateGenerator 期望的 analysis dict。"""
|
||||
return {
|
||||
"bounding_box": {
|
||||
"dimensions": list(dims),
|
||||
"min": [0.0, 0.0, 0.0],
|
||||
"max": list(dims),
|
||||
"center": [d / 2 for d in dims],
|
||||
},
|
||||
"volume": volume,
|
||||
"inertia_matrix": [[1000.0, 0.0, 0.0], [0.0, 800.0, 0.0], [0.0, 0.0, 600.0]],
|
||||
"axis_normal_stats": {"X": 35.0, "Y": 35.0, "Z": 30.0},
|
||||
}
|
||||
|
||||
|
||||
def test_parting_candidate_generator_no_hints_default():
|
||||
"""hints=None 应保持原有 3 轴评分(向后兼容)。"""
|
||||
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
|
||||
|
||||
gen = PartingCandidateGenerator()
|
||||
candidates = gen.generate_candidates(
|
||||
analysis=_make_analysis(),
|
||||
is_foam_material=False,
|
||||
max_candidates=3,
|
||||
)
|
||||
assert len(candidates) == 3
|
||||
# 没有 human_experience_primary 标签
|
||||
for c in candidates:
|
||||
assert c["method"] != "human_experience_primary"
|
||||
|
||||
|
||||
def test_parting_candidate_generator_applies_hints_axis_weight():
|
||||
"""hints={X: weight=0.9, sample_count=3} → X 轴 method 标签升级、priority_score +18。"""
|
||||
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
|
||||
|
||||
gen = PartingCandidateGenerator()
|
||||
candidates_no = gen.generate_candidates(
|
||||
analysis=_make_analysis(),
|
||||
is_foam_material=False,
|
||||
max_candidates=3,
|
||||
hints=None,
|
||||
)
|
||||
x_no = next(c for c in candidates_no if c["axis"] == "X")
|
||||
x_no_score = x_no["priority_score"]
|
||||
|
||||
candidates_with = gen.generate_candidates(
|
||||
analysis=_make_analysis(),
|
||||
is_foam_material=False,
|
||||
max_candidates=3,
|
||||
hints={
|
||||
"X": {"weight": 0.9, "sample_count": 3, "adopted_count": 5, "rejected_count": 1},
|
||||
},
|
||||
)
|
||||
x_with = next(c for c in candidates_with if c["axis"] == "X")
|
||||
# priority_score 提升 18 分(0.9 × 20)
|
||||
assert abs(x_with["priority_score"] - (x_no_score + 18.0)) < 0.01
|
||||
# method 标签变为 human_experience_primary
|
||||
assert x_with["method"] == "human_experience_primary"
|
||||
|
||||
|
||||
def test_parting_candidate_generator_low_sample_count_no_method_upgrade():
|
||||
"""sample_count=1(信号不足)时 method 标签不升级。"""
|
||||
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
|
||||
|
||||
gen = PartingCandidateGenerator()
|
||||
candidates = gen.generate_candidates(
|
||||
analysis=_make_analysis(),
|
||||
is_foam_material=False,
|
||||
max_candidates=3,
|
||||
hints={
|
||||
"Y": {"weight": 0.8, "sample_count": 1, "adopted_count": 1, "rejected_count": 0},
|
||||
},
|
||||
)
|
||||
y = next(c for c in candidates if c["axis"] == "Y")
|
||||
# sample_count < 2 → method 不升级(但 priority_score 仍加成 16 分)
|
||||
assert y["method"] != "human_experience_primary"
|
||||
|
||||
|
||||
# ── PartingSchemeScorer 测试 ──
|
||||
|
||||
def _make_scheme(axis: str = "X", method: str = "geometric_primary", score: float = 60.0):
|
||||
"""构造 PartingSchemeScorer 期望的 scheme dict。"""
|
||||
return {
|
||||
"scheme_id": f"scheme_{axis}",
|
||||
"axis": axis,
|
||||
"parting": {"axis": axis},
|
||||
"method": method,
|
||||
"priority_score": score,
|
||||
"cavity_data": {
|
||||
"mold_cavities": {
|
||||
"cavity": {"vertex_count": 100},
|
||||
"core": {"vertex_count": 100},
|
||||
},
|
||||
"quality_checks": {
|
||||
"undercut_regions": [],
|
||||
"side_actions": {
|
||||
"summary": {"total_mechanism_count": 0, "complexity": "simple"},
|
||||
"slider_mechanisms": [],
|
||||
"lifter_mechanisms": [],
|
||||
"undercut_analysis": {"total_undercut_area": 0},
|
||||
},
|
||||
},
|
||||
"manufacturing_info": {
|
||||
"estimated_mold_size": {"length": 200, "width": 200, "height": 200},
|
||||
"estimated_clamping_force": "150-300 吨",
|
||||
},
|
||||
},
|
||||
"key_info": {
|
||||
"quality_considerations": {"warpage_risk": "low"},
|
||||
"geometric_characteristics": {"wall_thickness_range": "1.5 - 3.0 mm"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_scheme_scorer_no_hints_no_bonus_field():
|
||||
"""hints=None → score_breakdown 不含 human_hint_bonus(保持默认结构)。"""
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
|
||||
scorer = PartingSchemeScorer()
|
||||
scored = scorer.score_schemes([_make_scheme("X")])
|
||||
# hints=None 时 bonus=0,但仍写入 score_breakdown 以让前端 diff 稳定
|
||||
assert "human_hint_bonus" in scored[0]["score_breakdown"]
|
||||
assert scored[0]["score_breakdown"]["human_hint_bonus"] == 0.0
|
||||
|
||||
|
||||
def test_scheme_scorer_human_hint_bonus_added():
|
||||
"""hints={Y: weight=1.0, sample_count=5} → score_breakdown.human_hint_bonus == 12.0。"""
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
|
||||
scorer = PartingSchemeScorer()
|
||||
hints = {"Y": {"weight": 1.0, "sample_count": 5, "adopted_count": 5, "rejected_count": 0}}
|
||||
|
||||
# 同一方案:有 hints vs 无 hints,total_score 差应等于 human_hint_bonus
|
||||
scored_with = scorer.score_schemes([_make_scheme("Y")], hints=hints)
|
||||
scored_without = scorer.score_schemes([_make_scheme("Y")], hints=None)
|
||||
|
||||
assert scored_with[0]["score_breakdown"]["human_hint_bonus"] == 12.0
|
||||
delta = scored_with[0]["score"] - scored_without[0]["score"]
|
||||
assert abs(delta - 12.0) < 0.01
|
||||
|
||||
|
||||
def test_scheme_scorer_low_sample_count_halves_bonus():
|
||||
"""sample_count=1 → bonus ×0.5 = 6.0(信号不足折半)。"""
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
|
||||
scorer = PartingSchemeScorer()
|
||||
hints = {"Z": {"weight": 1.0, "sample_count": 1, "adopted_count": 1, "rejected_count": 0}}
|
||||
|
||||
scored = scorer.score_schemes([_make_scheme("Z")], hints=hints)
|
||||
assert scored[0]["score_breakdown"]["human_hint_bonus"] == 6.0
|
||||
|
||||
|
||||
def test_scheme_scorer_zero_weight_no_bonus():
|
||||
"""weight=0 → bonus=0(既不加分也不扣分)。"""
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
|
||||
scorer = PartingSchemeScorer()
|
||||
hints = {"X": {"weight": 0.0, "sample_count": 3, "adopted_count": 0, "rejected_count": 3}}
|
||||
|
||||
scored = scorer.score_schemes([_make_scheme("X")], hints=hints)
|
||||
assert scored[0]["score_breakdown"]["human_hint_bonus"] == 0.0
|
||||
|
||||
|
||||
# ── MultiSchemeMoldPlanner 测试(OCC-gated:直接 import OCC)──
|
||||
|
||||
@OCC_GATED
|
||||
def test_multi_scheme_planner_passes_hints_through(monkeypatch):
|
||||
"""generate_plan(hints=...) 应透传到 candidate_generator 和 scheme_scorer。"""
|
||||
from moldinsight.core import multi_scheme_planner
|
||||
|
||||
captured = {"candidate_hints": None, "scorer_hints": None}
|
||||
|
||||
class FakeGenerator:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def set_material(self, *_):
|
||||
pass
|
||||
|
||||
def apply_process_params(self, *_):
|
||||
pass
|
||||
|
||||
def analyze_product_geometry(self, _shape):
|
||||
return {
|
||||
"bounding_box": {"dimensions": [80, 60, 40]},
|
||||
"volume": 50000,
|
||||
"inertia_matrix": [[1000, 0, 0], [0, 800, 0], [0, 0, 600]],
|
||||
}
|
||||
|
||||
class FakePlanner:
|
||||
def generate_candidates(self, **kwargs):
|
||||
captured["candidate_hints"] = kwargs.get("hints")
|
||||
return [
|
||||
{
|
||||
"scheme_id": "scheme_1",
|
||||
"axis": "X",
|
||||
"direction": [1, 0, 0],
|
||||
"title": "推荐候选方向",
|
||||
"method": "geometric_primary",
|
||||
"priority_score": 80.0,
|
||||
"opening_span_mm": 40.0,
|
||||
"projected_area_cm2": 32.0,
|
||||
"reason": "test",
|
||||
},
|
||||
]
|
||||
|
||||
def score_schemes(self, schemes, *, hints=None):
|
||||
captured["scorer_hints"] = hints
|
||||
for s in schemes:
|
||||
s["score"] = 80.0
|
||||
s["score_breakdown"] = {"human_hint_bonus": 0.0}
|
||||
return schemes
|
||||
|
||||
planner_obj = multi_scheme_planner.MultiSchemeMoldPlanner.__new__(
|
||||
multi_scheme_planner.MultiSchemeMoldPlanner
|
||||
)
|
||||
planner_obj.candidate_generator = FakePlanner()
|
||||
planner_obj.scheme_scorer = FakePlanner()
|
||||
planner_obj.candidate_generator.generate_candidates = planner_obj.candidate_generator.generate_candidates
|
||||
planner_obj.scheme_scorer.score_schemes = planner_obj.scheme_scorer.score_schemes
|
||||
# 用 planner_obj.candidate_generator 与 scheme_scorer 是 FakePlanner 实例,所以
|
||||
# generator.generate_candidates 会调用 FakePlanner.generate_candidates —— 但因为同
|
||||
# 一实例两个方法都覆盖,下面显式覆写两次:
|
||||
planner_obj.candidate_generator = type("G", (), {
|
||||
"generate_candidates": lambda self, **kw: (
|
||||
captured.update({"candidate_hints": kw.get("hints")}) or
|
||||
[{"scheme_id": "scheme_1", "axis": "X", "direction": [1,0,0],
|
||||
"title": "推荐", "method": "geo", "priority_score": 80.0,
|
||||
"opening_span_mm": 40.0, "projected_area_cm2": 32.0, "reason": "test"}]
|
||||
)
|
||||
})()
|
||||
planner_obj.scheme_scorer = type("S", (), {
|
||||
"score_schemes": lambda self, schemes, *, hints=None: (
|
||||
captured.update({"scorer_hints": hints}) or
|
||||
[{**s, "score": 80.0, "score_breakdown": {"human_hint_bonus": 0.0}} for s in schemes]
|
||||
)
|
||||
})()
|
||||
|
||||
fake_hints = {"X": {"weight": 0.9, "sample_count": 3, "adopted_count": 5, "rejected_count": 1}}
|
||||
result = planner_obj.generate_plan(
|
||||
shape=MagicMock(),
|
||||
material={"name": "ABS"},
|
||||
is_foam_material=False,
|
||||
hints=fake_hints,
|
||||
)
|
||||
|
||||
assert captured["candidate_hints"] == fake_hints, "candidate_generator 未接收 hints"
|
||||
assert captured["scorer_hints"] == fake_hints, "scheme_scorer 未接收 hints"
|
||||
assert result["global_summary"]["applied_hints"] == fake_hints
|
||||
|
||||
|
||||
@OCC_GATED
|
||||
def test_multi_scheme_planner_applied_hints_default_empty():
|
||||
"""generate_plan 不传 hints 时 global_summary.applied_hints 为空 dict。"""
|
||||
from moldinsight.core import multi_scheme_planner
|
||||
|
||||
planner_obj = multi_scheme_planner.MultiSchemeMoldPlanner.__new__(
|
||||
multi_scheme_planner.MultiSchemeMoldPlanner
|
||||
)
|
||||
planner_obj.candidate_generator = type("G", (), {
|
||||
"generate_candidates": lambda self, **kw: [
|
||||
{"scheme_id": "scheme_1", "axis": "X", "direction": [1,0,0],
|
||||
"title": "推荐", "method": "geo", "priority_score": 80.0,
|
||||
"opening_span_mm": 40.0, "projected_area_cm2": 32.0, "reason": "test"}
|
||||
]
|
||||
})()
|
||||
planner_obj.scheme_scorer = type("S", (), {
|
||||
"score_schemes": lambda self, schemes, *, hints=None: (
|
||||
[{**s, "score": 80.0, "score_breakdown": {"human_hint_bonus": 0.0}} for s in schemes]
|
||||
)
|
||||
})()
|
||||
|
||||
result = planner_obj.generate_plan(
|
||||
shape=MagicMock(),
|
||||
material={"name": "ABS"},
|
||||
is_foam_material=False,
|
||||
)
|
||||
assert result["global_summary"]["applied_hints"] == {}
|
||||
|
||||
|
||||
# ── processing_service payload 装配测试(OCC-gated)──
|
||||
|
||||
@OCC_GATED
|
||||
def test_processing_service_step_generate_cavity_includes_experience_hints(monkeypatch):
|
||||
"""_step_generate_cavity 应在 run_occ payload 中装入 experience_hints。"""
|
||||
import asyncio
|
||||
from moldinsight.services import processing_service
|
||||
|
||||
# Mock experience_feedback_service
|
||||
fake_hints = [{"scheme_axis": "X", "weight": 0.8, "sample_count": 4,
|
||||
"adopted_count": 4, "rejected_count": 0}]
|
||||
fake_ef_service = MagicMock()
|
||||
fake_ef_service.resolve_for_process_params = AsyncMock(return_value=fake_hints)
|
||||
|
||||
monkeypatch.setattr(
|
||||
processing_service, "experience_feedback_service", fake_ef_service, raising=False
|
||||
)
|
||||
|
||||
# Mock run_occ 拦截 payload
|
||||
captured_payload = {}
|
||||
async def fake_run_occ(self, op_name, payload, timeout):
|
||||
captured_payload["op_name"] = op_name
|
||||
captured_payload["payload"] = payload
|
||||
return {
|
||||
"plan_result": {"candidate_schemes": [], "best_scheme_id": None,
|
||||
"global_summary": {"applied_hints": {}}},
|
||||
"export_manifest": None,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
processing_service.ProcessingService, "run_occ", fake_run_occ
|
||||
)
|
||||
|
||||
# Mock cad_exporter
|
||||
monkeypatch.setattr(
|
||||
processing_service.ProcessingService, "__init__",
|
||||
lambda self: setattr(self, "cad_exporter", MagicMock(output_dir="/tmp"))
|
||||
)
|
||||
|
||||
svc = processing_service.ProcessingService()
|
||||
svc.cad_exporter = MagicMock(output_dir="/tmp")
|
||||
|
||||
async def run():
|
||||
await svc._step_generate_cavity(
|
||||
db_session=MagicMock(),
|
||||
file_path="/tmp/x.stp",
|
||||
selected_material={"name": "ABS"},
|
||||
is_foam_material=False,
|
||||
process_params={"material": "ABS", "draft_angle": 2.0,
|
||||
"shrinkage_rate": 0.5, "parting_precision": 0.1,
|
||||
"cavity_match": 95},
|
||||
task_id="task-test-1",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert captured_payload["payload"]["experience_hints"] == fake_hints
|
||||
|
||||
|
||||
@OCC_GATED
|
||||
def test_processing_service_step_generate_cavity_empty_hints_on_error(monkeypatch):
|
||||
"""experience_feedback_service 抛异常时 hints 应回退到空 list(不阻塞主流程)。"""
|
||||
import asyncio
|
||||
from moldinsight.services import processing_service
|
||||
|
||||
fake_ef_service = MagicMock()
|
||||
fake_ef_service.resolve_for_process_params = AsyncMock(
|
||||
side_effect=RuntimeError("DB down")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
processing_service, "experience_feedback_service", fake_ef_service, raising=False
|
||||
)
|
||||
|
||||
captured_payload = {}
|
||||
async def fake_run_occ(self, op_name, payload, timeout):
|
||||
captured_payload["payload"] = payload
|
||||
return {
|
||||
"plan_result": {"candidate_schemes": [], "best_scheme_id": None,
|
||||
"global_summary": {"applied_hints": {}}},
|
||||
"export_manifest": None,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
processing_service.ProcessingService, "run_occ", fake_run_occ
|
||||
)
|
||||
|
||||
svc = processing_service.ProcessingService()
|
||||
svc.cad_exporter = MagicMock(output_dir="/tmp")
|
||||
|
||||
async def run():
|
||||
await svc._step_generate_cavity(
|
||||
db_session=MagicMock(),
|
||||
file_path="/tmp/x.stp",
|
||||
selected_material={"name": "ABS"},
|
||||
is_foam_material=False,
|
||||
process_params={"material": "ABS"},
|
||||
task_id="task-test-1",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
# 抛异常时回退到空 list,主流程继续
|
||||
assert captured_payload["payload"]["experience_hints"] == []
|
||||
@@ -0,0 +1,156 @@
|
||||
"""D17 Human-in-Loop 闭环:compute_fingerprint 分桶边界值测试。
|
||||
|
||||
compute_fingerprint 是跨任务匹配的核心函数,纯函数,单独覆盖各分桶边界。
|
||||
D17 验收:fingerprint 字典结构稳定 + 分桶边界值与 plan §5.1 一致。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from moldinsight.services.experience_feedback_service import compute_fingerprint
|
||||
|
||||
|
||||
def _geo(volume: float = 50000.0, dims=(80, 60, 40), faces: int = 250, undercuts: int = 0):
|
||||
"""构造测试用 geometry_data 字典。dims 单位 mm,volume 单位 mm³。"""
|
||||
return {
|
||||
"volume": volume,
|
||||
"bounding_box": {
|
||||
"dimensions": list(dims),
|
||||
},
|
||||
"topology_faces": faces,
|
||||
"undercut_count": undercuts,
|
||||
}
|
||||
|
||||
|
||||
# ── bbox_aspect 分桶(按 sorted_dims mid/min 比)──
|
||||
|
||||
@pytest.mark.parametrize("dims,expected", [
|
||||
# ratio = mid/min
|
||||
((100, 100, 100), "compact"), # ratio=1.0 → compact(1.0 ≤ r < 1.5)
|
||||
((100, 130, 100), "compact"), # ratio=1.0(去重后)
|
||||
((60, 80, 100), "compact"), # ratio=1.0 → compact
|
||||
((40, 80, 100), "slab"), # ratio=80/40=2.0 → slab(1.5 ≤ r < 3.0)
|
||||
((20, 80, 100), "elongated"), # ratio=80/20=4.0 → elongated(3.0 ≤ r < 6.0)
|
||||
((10, 80, 100), "long_bar"), # ratio=80/10=8 → long_bar(≥ 6.0)
|
||||
((0, 0, 0), "compact"), # 零值默认
|
||||
])
|
||||
def test_bbox_aspect_bucket(dims, expected):
|
||||
fp = compute_fingerprint(_geo(dims=dims), "ABS", False)
|
||||
assert fp["bbox_aspect"] == expected, f"dims={dims} → expected {expected}, got {fp['bbox_aspect']}"
|
||||
|
||||
|
||||
# ── volume_bucket 分桶(mm³ → cm³,buckets: xs<10 / s<100 / m<500 / l<2000 / xl≥2000)──
|
||||
|
||||
@pytest.mark.parametrize("volume_mm3,expected", [
|
||||
(0, "xs"), # 0 cm³
|
||||
(5000, "xs"), # 5 cm³
|
||||
(9999, "xs"), # 边界 9.99 cm³ → xs
|
||||
(10000, "s"), # 10 cm³ → s(10 ≤ v < 100)
|
||||
(50000, "s"), # 50 cm³
|
||||
(99999, "s"), # 边界 99.99 cm³
|
||||
(100000, "m"), # 100 cm³ → m(100 ≤ v < 500)
|
||||
(250000, "m"), # 250 cm³
|
||||
(499999, "m"), # 边界 499.99 cm³
|
||||
(500000, "l"), # 500 cm³ → l(500 ≤ v < 2000)
|
||||
(1000000, "l"), # 1000 cm³
|
||||
(1999999, "l"), # 边界 1999.99 cm³
|
||||
(2000000, "xl"), # 2000 cm³ → xl(v ≥ 2000)
|
||||
(10000000, "xl"), # 10000 cm³
|
||||
])
|
||||
def test_volume_bucket(volume_mm3, expected):
|
||||
fp = compute_fingerprint(_geo(volume=volume_mm3), "ABS", False)
|
||||
assert fp["volume_bucket"] == expected, f"volume={volume_mm3}mm³ → expected {expected}, got {fp['volume_bucket']}"
|
||||
|
||||
|
||||
# ── face_bucket 分桶 ──
|
||||
|
||||
@pytest.mark.parametrize("faces,expected", [
|
||||
(0, "simple"), # 0 面
|
||||
(99, "simple"), # 边界 99
|
||||
(100, "normal"), # 边界 100
|
||||
(499, "normal"), # 边界 499
|
||||
(500, "complex"), # 边界 500
|
||||
(1999, "complex"), # 边界 1999
|
||||
(2000, "dense"), # 边界 2000
|
||||
(10000, "dense"),
|
||||
])
|
||||
def test_face_bucket(faces, expected):
|
||||
fp = compute_fingerprint(_geo(faces=faces), "ABS", False)
|
||||
assert fp["face_bucket"] == expected, f"faces={faces} → expected {expected}, got {fp['face_bucket']}"
|
||||
|
||||
|
||||
# ── undercut_class 分桶 ──
|
||||
|
||||
@pytest.mark.parametrize("undercuts,expected", [
|
||||
(0, "none"), # 0 → none
|
||||
(1, "mild"), # 1 → mild
|
||||
(3, "mild"), # 边界 3
|
||||
(4, "moderate"), # 边界 4
|
||||
(8, "moderate"), # 边界 8
|
||||
(9, "heavy"), # 边界 9
|
||||
(50, "heavy"),
|
||||
])
|
||||
def test_undercut_class(undercuts, expected):
|
||||
fp = compute_fingerprint(_geo(undercuts=undercuts), "ABS", False)
|
||||
assert fp["undercut_class"] == expected, f"undercuts={undercuts} → expected {expected}, got {fp['undercut_class']}"
|
||||
|
||||
|
||||
# ── material_family 分桶 ──
|
||||
|
||||
@pytest.mark.parametrize("material,expected", [
|
||||
("ABS", "abs"),
|
||||
("ABS+PC", "abs"), # 子串匹配
|
||||
("PP", "pp"),
|
||||
("PA66", "pa"),
|
||||
("AlSi10Mg", "foam"), # "al" + "si"
|
||||
("AlSi12", "foam"),
|
||||
("Aluminium Alloy", "other"), # 只有 al 无 si
|
||||
("POM", "other"),
|
||||
("", "other"),
|
||||
])
|
||||
def test_material_family(material, expected):
|
||||
fp = compute_fingerprint(_geo(), material, False)
|
||||
assert fp["material_family"] == expected, f"material={material} → expected {expected}, got {fp['material_family']}"
|
||||
|
||||
|
||||
# ── is_foam 字段 ──
|
||||
|
||||
@pytest.mark.parametrize("is_foam,expected", [
|
||||
(True, "true"),
|
||||
(False, "false"),
|
||||
])
|
||||
def test_is_foam(is_foam, expected):
|
||||
fp = compute_fingerprint(_geo(), "ABS", is_foam)
|
||||
assert fp["is_foam"] == expected
|
||||
|
||||
|
||||
# ── 字典键完整性 ──
|
||||
|
||||
def test_fingerprint_has_all_keys():
|
||||
fp = compute_fingerprint(_geo(), "ABS", False)
|
||||
expected_keys = {
|
||||
"bbox_aspect", "volume_bucket", "face_bucket",
|
||||
"undercut_class", "material_family", "is_foam",
|
||||
}
|
||||
assert set(fp.keys()) == expected_keys
|
||||
|
||||
|
||||
# ── 空 geometry_data 容错 ──
|
||||
|
||||
def test_empty_geometry_data_returns_safe_defaults():
|
||||
fp = compute_fingerprint(None, "ABS", False)
|
||||
# 不应抛异常,所有键存在且为合法 bucket 值
|
||||
assert fp["bbox_aspect"] == "compact"
|
||||
assert fp["volume_bucket"] == "xs"
|
||||
assert fp["face_bucket"] == "simple"
|
||||
assert fp["undercut_class"] == "none"
|
||||
assert fp["material_family"] == "abs"
|
||||
assert fp["is_foam"] == "false"
|
||||
|
||||
|
||||
def test_partial_geometry_data():
|
||||
"""只有 bounding_box 没有 topology_faces,应走 fallback 0。"""
|
||||
fp = compute_fingerprint(
|
||||
{"bounding_box": {"dimensions": [100, 100, 100]}},
|
||||
"ABS",
|
||||
False,
|
||||
)
|
||||
assert fp["face_bucket"] == "simple" # 0 面 → simple
|
||||
@@ -0,0 +1,319 @@
|
||||
"""D17 Human-in-Loop 闭环:API 契约测试。
|
||||
|
||||
覆盖:
|
||||
- POST /api/tasks/{task_id}/experience-feedback
|
||||
- 401(无登录态 —— 由 Depends(get_current_active_user) 处理)
|
||||
- 403(user 角色无 feedback_experience_hint 权限)
|
||||
- 200(admin 角色有 manage_experience_feedback 全权限)
|
||||
- 200(process_engineer 角色有 feedback_experience_hint 权限)
|
||||
- GET /api/tasks/{task_id}/experience-hints
|
||||
- 200 命中(同 stp_file_id 历史反馈聚合)
|
||||
- cache invalidation(POST 写完后视图失效)
|
||||
- D9 边界:record_feedback 失败时 db 不留半成品
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import configure_mappers
|
||||
|
||||
from shared.models.base import Base
|
||||
from shared.models.identity import User, Role, Permission, RolePermission, UserRole
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
|
||||
from moldinsight.api.experience_feedback_router import router as feedback_router
|
||||
from moldinsight.models import (
|
||||
STPFile, GeometryData, MoldCavityData, ProcessingTask, ExperienceFeedback,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def feedback_client(async_engine, seeded_db):
|
||||
"""构造带 experience_feedback_router 的 test app。
|
||||
|
||||
与 conftest.client 不同,这里我们用 seeded_db 的 user=tester,但通过依赖覆盖
|
||||
让所有请求都以 admin 身份进(admin 是项目测试约定身份)。
|
||||
|
||||
关键点:override 返回的 User 必须用 selectinload 预加载 user_roles → role → role_permissions → permission,
|
||||
否则 User.has_permission() 内部访问 self.roles 触发跨 session lazy load 失败。
|
||||
"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(feedback_router)
|
||||
|
||||
async def override_get_db_session():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
async def override_get_current_active_user():
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.where(User.username == "tester")
|
||||
.options(
|
||||
selectinload(User.user_roles)
|
||||
.selectinload(UserRole.role)
|
||||
.selectinload(Role.role_permissions)
|
||||
.selectinload(RolePermission.permission)
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def _grant_permission(session, user, code):
|
||||
"""给测试 user 加指定 permission_code。
|
||||
|
||||
注意:User.is_superuser 是 @property(派生自 role.code == "admin"),
|
||||
不能直接赋值;admin 权限通过给 user 关联 'admin' role 触发。
|
||||
"""
|
||||
# 找/创建 permission
|
||||
perm_row = await session.execute(select(Permission).where(Permission.code == code))
|
||||
perm = perm_row.scalar_one_or_none()
|
||||
if perm is None:
|
||||
perm = Permission(code=code, name=code, module="moldinsight")
|
||||
session.add(perm)
|
||||
await session.flush()
|
||||
|
||||
# 找/创建 role(用 permission code 作 role code,便于复用)
|
||||
role_row = await session.execute(select(Role).where(Role.code == code))
|
||||
role = role_row.scalar_one_or_none()
|
||||
if role is None:
|
||||
role = Role(code=code, name=code, is_system=False)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
rp = RolePermission(role_id=role.id, permission_id=perm.id)
|
||||
session.add(rp)
|
||||
|
||||
# 关联 user(如未关联)
|
||||
user_role_row = await session.execute(
|
||||
select(UserRole).where(UserRole.user_id == user.id, UserRole.role_id == role.id)
|
||||
)
|
||||
if user_role_row.scalar_one_or_none() is None:
|
||||
session.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ── POST /experience-feedback 测试 ──
|
||||
|
||||
async def test_submit_feedback_403_without_permission(feedback_client, async_engine):
|
||||
"""tester 默认无任何权限 → 403。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
# 确保 tester 没有 admin role 也没有 feedback_experience_hint role
|
||||
async with session_factory() as session:
|
||||
await session.execute(
|
||||
UserRole.__table__.delete().where(UserRole.user_id == 1)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json={
|
||||
"scheme_id": "scheme_1",
|
||||
"feedback_status": "adopted",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "工艺工程师" in resp.text
|
||||
|
||||
|
||||
async def test_submit_feedback_200_with_feedback_permission(feedback_client, async_engine):
|
||||
"""给 tester 授予 feedback_experience_hint → 200 + 写入经验反馈。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "feedback_experience_hint")
|
||||
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json={
|
||||
"scheme_id": "scheme_1",
|
||||
"feedback_status": "adopted",
|
||||
"feedback_reason": "工艺验证 OK",
|
||||
"confidence_at_submit": 0.85,
|
||||
"score_at_submit": 87.5,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["scheme_id"] == "scheme_1"
|
||||
assert body["feedback_status"] == "adopted"
|
||||
|
||||
# DB 真的写入了
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
|
||||
)
|
||||
fb = result.scalar_one()
|
||||
assert fb.user_id == tester.id
|
||||
assert fb.feedback_status == "adopted"
|
||||
assert fb.role_code == "feedback_experience_hint" # 写入时角色归因
|
||||
assert fb.expires_at is not None
|
||||
|
||||
|
||||
async def test_submit_feedback_200_with_admin(feedback_client, async_engine):
|
||||
"""admin role → has_permission 走 role.code=='admin' 短路 → 200。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "admin")
|
||||
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json={
|
||||
"scheme_id": "scheme_2",
|
||||
"feedback_status": "rejected",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["scheme_axis"] # 自动从 cavity_key_info 解析,缺则默认 Z
|
||||
|
||||
|
||||
async def test_submit_feedback_invalid_status_returns_422(feedback_client, async_engine):
|
||||
"""feedback_status 非法 → Pydantic 校验 422。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "admin")
|
||||
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json={
|
||||
"scheme_id": "scheme_1",
|
||||
"feedback_status": "approve", # 非法值
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── GET /experience-hints 测试 ──
|
||||
|
||||
async def test_get_hints_200_empty(feedback_client, async_engine):
|
||||
"""无反馈历史 → 空 hints 列表,fingerprint 回显。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "admin")
|
||||
|
||||
resp = await feedback_client.get("/tasks/task-demo-1/experience-hints")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["task_id"] == "task-demo-1"
|
||||
assert body["stp_file_id"] == 1
|
||||
assert body["hints"] == []
|
||||
assert "bbox_aspect" in body["fingerprint"]
|
||||
|
||||
|
||||
async def test_get_hints_aggregates_by_axis(feedback_client, async_engine):
|
||||
"""写入多条反馈后 GET hints 按 axis 聚合。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "admin")
|
||||
|
||||
# 写入 4 条反馈,全部落在 axis="Z" 默认(无 cavity_key_info)
|
||||
for fb in [
|
||||
{"scheme_id": "x_1", "feedback_status": "adopted"},
|
||||
{"scheme_id": "x_2", "feedback_status": "adopted"},
|
||||
{"scheme_id": "x_3", "feedback_status": "rejected"},
|
||||
{"scheme_id": "z_1", "feedback_status": "adopted"},
|
||||
]:
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json=fb,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# GET hints
|
||||
resp = await feedback_client.get("/tasks/task-demo-1/experience-hints")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
# 无 cavity_key_info 时所有 feedback 落在 axis="Z" 默认值 → 4 条聚合
|
||||
assert len(body["hints"]) == 1
|
||||
h = body["hints"][0]
|
||||
assert h["scheme_axis"] == "Z"
|
||||
assert h["adopted_count"] == 3
|
||||
assert h["rejected_count"] == 1
|
||||
assert h["sample_count"] == 4
|
||||
assert h["confidence"] == 0.5 # (3-1)/4
|
||||
|
||||
|
||||
async def test_submit_feedback_increments_expires_at(feedback_client, async_engine):
|
||||
"""同 stp_file_id 上写入新反馈时,旧行的 expires_at 应被续期(write-time 续期)。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "admin")
|
||||
|
||||
# 写入第一条反馈
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json={"scheme_id": "scheme_1", "feedback_status": "adopted"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 拿到第一条 expires_at
|
||||
async with session_factory() as session:
|
||||
first = (await session.execute(
|
||||
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
|
||||
)).scalar_one()
|
||||
first_expires = first.expires_at
|
||||
assert first_expires is not None
|
||||
|
||||
# 写第二条(不同 scheme_id),应触发同 stp_file 续期
|
||||
import asyncio
|
||||
await asyncio.sleep(0.05)
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/task-demo-1/experience-feedback",
|
||||
json={"scheme_id": "scheme_2", "feedback_status": "rejected"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 第一条 expires_at 应被续期(≥ 原值)
|
||||
async with session_factory() as session:
|
||||
first_after = (await session.execute(
|
||||
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
|
||||
)).scalar_one()
|
||||
assert first_after.expires_at >= first_expires
|
||||
|
||||
|
||||
# ── 任务归属校验 ──
|
||||
|
||||
async def test_submit_feedback_with_unknown_task_returns_404(feedback_client, async_engine):
|
||||
"""task_id 不存在 → ensure_task_access 返回 404(不是 500)。
|
||||
|
||||
D9 边界保护:service.record_feedback 永远走不到(ensure_task_access 先拦截)。
|
||||
"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||||
await _grant_permission(session, tester, "admin")
|
||||
|
||||
resp = await feedback_client.post(
|
||||
"/tasks/non-existent-task-id/experience-feedback",
|
||||
json={"scheme_id": "scheme_1", "feedback_status": "adopted"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "不存在" in resp.text
|
||||
|
||||
|
||||
# ── 配置:D17 模型注册收口 ──
|
||||
|
||||
def test_experience_feedback_registered_in_metadata():
|
||||
"""D17:experience_feedback 表已加入 Base.metadata(防止漏注册导致 ORM 不可用)。"""
|
||||
configure_mappers()
|
||||
assert "experience_feedback" in Base.metadata.tables
|
||||
@@ -158,6 +158,22 @@ async def test_json_media_type_from_extension(client, tmp_path):
|
||||
assert "application/json" in resp.headers["content-type"]
|
||||
|
||||
|
||||
# ── D3 收敛:单点聚合注册 ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_register_moldinsight_routers_mounts_api_and_html():
|
||||
"""register_moldinsight_routers 一次挂载 /api 聚合路由 + 根路径 /html 报告代理
|
||||
(入口侧不再重复 include_html_report)。"""
|
||||
from moldinsight.api import register_moldinsight_routers
|
||||
|
||||
application = FastAPI()
|
||||
register_moldinsight_routers(application)
|
||||
paths = {route.path for route in application.routes}
|
||||
assert "/api/upload" in paths
|
||||
# Starlette route.path 保留 :path 转换器(OpenAPI 路径才显示 /html/{filename})
|
||||
assert any(p.startswith("/html/{") for p in paths)
|
||||
|
||||
|
||||
# ── 未命中与防护 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""D13:锁文件生成契约(部署侧硬性要求)
|
||||
|
||||
锁文件 deploy/requirements-{base,moldinsight}.lock.txt 的存在性 + 体积下限
|
||||
是部署侧硬性要求:
|
||||
|
||||
- 锁文件必须在 moldinsight conda 环境构建成功后落盘(见 deploy/generate_lockfiles.sh/.bat)
|
||||
- 锁文件必须以 git 跟踪方式提交,CI / 离线构建 / 生产复现部署才能直接 `pip install -r`
|
||||
- 若 lock.txt 缺失或异常空(仅镜像元数据 < 5 行),说明构建流程未走 D13 流程
|
||||
|
||||
CI 门禁建议:
|
||||
- 仓库侧默认 pytest(`pytest tests/ -q`)**不**强制这些断言——锁文件属"部署侧产物",
|
||||
首次构建未完成时不应阻塞日常单测
|
||||
- 部署侧 / CI 镜像构建 job 用 `--run-lockfile-check` 显式开启本套件(见 conftest.py)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEPLOY_DIR = REPO_ROOT / "deploy"
|
||||
|
||||
LOCK_FILES = [
|
||||
DEPLOY_DIR / "requirements-base.lock.txt",
|
||||
DEPLOY_DIR / "requirements-moldinsight.lock.txt",
|
||||
]
|
||||
|
||||
GENERATOR_SCRIPTS = [
|
||||
DEPLOY_DIR / "generate_lockfiles.sh",
|
||||
DEPLOY_DIR / "generate_lockfiles.bat",
|
||||
]
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""仅在显式传入 --run-lockfile-check 时启用 D13 部署侧契约。"""
|
||||
if not config.getoption("--run-lockfile-check", default=False):
|
||||
skip_marker = pytest.mark.skip(
|
||||
reason="D13 部署侧契约:默认 skip;CI 镜像构建 job 需传入 --run-lockfile-check 启用"
|
||||
)
|
||||
for item in items:
|
||||
if "test_lockfile_generation" in item.nodeid:
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--run-lockfile-check",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="启用 D13 锁文件部署侧契约测试(CI 镜像构建 job 使用)",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""仅在显式传入 --run-lockfile-check 时启用 D13 部署侧契约。
|
||||
|
||||
说明:本钩子保留作为冗余保护(conftest.py 已注册同名钩子),
|
||||
即便测试单独跑 pytest tests/test_lockfile_generation.py 也能正确跳过。
|
||||
"""
|
||||
if not config.getoption("--run-lockfile-check", default=False):
|
||||
skip_marker = pytest.mark.skip(
|
||||
reason="D13 部署侧契约:默认 skip;CI 镜像构建 job 需传入 --run-lockfile-check 启用"
|
||||
)
|
||||
for item in items:
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--run-lockfile-check",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="启用 D13 锁文件部署侧契约测试(CI 镜像构建 job 使用)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lock_path", LOCK_FILES)
|
||||
def test_lockfile_exists_and_is_substantive(lock_path):
|
||||
"""锁文件必须存在且非空(≥5 行 pip freeze 产物),否则部署侧契约缺失。"""
|
||||
assert lock_path.exists(), (
|
||||
f"缺少锁文件 {lock_path.relative_to(REPO_ROOT)};"
|
||||
f"请在 moldinsight conda 环境执行 deploy/generate_lockfiles.sh/.bat 后提交"
|
||||
)
|
||||
line_count = sum(1 for _ in lock_path.open(encoding="utf-8") if _.strip())
|
||||
assert line_count >= 5, (
|
||||
f"锁文件 {lock_path.relative_to(REPO_ROOT)} 体积异常(仅 {line_count} 行非空行),"
|
||||
"可能是构建流程未走通,请重新生成"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_path", GENERATOR_SCRIPTS)
|
||||
def test_lockfile_generator_script_present(script_path):
|
||||
"""锁文件生成脚本必须随仓库分发,否则新机器无法落锁。"""
|
||||
assert script_path.exists(), (
|
||||
f"缺少生成脚本 {script_path.relative_to(REPO_ROOT)};"
|
||||
"D13 流程入口文件缺失"
|
||||
)
|
||||
|
||||
|
||||
def test_lockfile_dockerfile_comment_points_to_generator():
|
||||
"""Dockerfile.moldinsight 必须明确指向锁文件生成脚本。"""
|
||||
dockerfile = (DEPLOY_DIR / "Dockerfile.moldinsight").read_text(encoding="utf-8")
|
||||
assert "generate_lockfiles" in dockerfile, (
|
||||
"Dockerfile.moldinsight 应在注释中指向 deploy/generate_lockfiles.sh 以引导锁文件生成流程"
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""D3 模型拆分归属保护(批次 4,2026-09-17)。
|
||||
"""D3 模型拆分归属保护(批次 4,2026-09-17)+ D17 Human-in-Loop 闭环。
|
||||
|
||||
锁定三个拆分成果:
|
||||
1. 三包模型全量注册后 mapper 可配置、31 表齐全;
|
||||
1. 三包模型全量注册后 mapper 可配置、32 表齐全(含 D17 新增 experience_feedback);
|
||||
2. 单模块部署(inventory-only / moldinsight-only + auth)独立配置 mapper 成功——
|
||||
跨模块 ORM relationship 已清零,任何一侧不注册对方模型也能工作;
|
||||
3. 旧 shared.models.database 模块已删除且无兼容 facade(诚实原则:不留假象)。
|
||||
@@ -20,6 +20,8 @@ EXPECTED_TABLES = {
|
||||
# moldinsight.models
|
||||
"stp_files", "geometry_data", "mesh_data", "html_files", "processing_tasks",
|
||||
"mold_cavity_data", "feature_detections", "design_recommendations", "analysis_metrics",
|
||||
# D17:老师傅经验反馈(Human-in-Loop 闭环,2026-09)
|
||||
"experience_feedback",
|
||||
# inventory.models
|
||||
"products", "product_materials", "material_price_history", "material_suppliers",
|
||||
"suppliers", "customers", "warehouses", "inventory", "stock_movements",
|
||||
@@ -28,7 +30,7 @@ EXPECTED_TABLES = {
|
||||
}
|
||||
|
||||
|
||||
def test_full_registration_covers_all_31_tables():
|
||||
def test_full_registration_covers_all_32_tables():
|
||||
import shared.models.identity # noqa: F401
|
||||
import moldinsight.models # noqa: F401
|
||||
import inventory.models # noqa: F401
|
||||
|
||||
@@ -78,6 +78,52 @@ def test_calculation_service_attaches_injection_system_summary():
|
||||
assert "injection_system" in result
|
||||
|
||||
|
||||
def test_calculation_service_bridges_scheme_info_contract():
|
||||
"""多方案 cavity_data 必须自包含可视化面板依赖的 legacy 字段。
|
||||
|
||||
3D 预览"关键工艺参数"面板只拿得到 cavity_data;key_info 不内嵌回去则
|
||||
面板整片 N/A、前端型腔数恒为 1(2026-09 事故根因)。
|
||||
"""
|
||||
key_info = {
|
||||
"mold_parameters": {
|
||||
"shrinkage_rate": "0.50%",
|
||||
"draft_angle": "2.0°",
|
||||
"parting_line_length": 620.0,
|
||||
},
|
||||
"geometric_characteristics": {
|
||||
"product_volume": "12.00 cm³",
|
||||
"product_weight": "12.60 g",
|
||||
"wall_thickness_range": "1.40 - 2.60 mm",
|
||||
},
|
||||
"manufacturing_requirements": {
|
||||
"cavity_material": "P20钢材",
|
||||
"hardness": "HRC 28-32",
|
||||
"surface_finish": "SPI A2",
|
||||
"estimated_cycle_time": "25 秒",
|
||||
},
|
||||
}
|
||||
cavity_data = {
|
||||
"metadata": {"shrinkage_rate": 0.005, "draft_angle": 2.0},
|
||||
"manufacturing_info": {"recommended_material": "P20钢材"},
|
||||
"mold_cavities": {"cavity": {"vertex_count": 1}, "core": {"vertex_count": 1}},
|
||||
}
|
||||
|
||||
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
|
||||
|
||||
mold_cavities = cavity_data["mold_cavities"]
|
||||
assert mold_cavities["cavity_count"] == 1
|
||||
assert (
|
||||
mold_cavities["cavity_key_info"]["geometric_characteristics"]["product_volume"]
|
||||
== "12.00 cm³"
|
||||
)
|
||||
manufacturing = cavity_data["manufacturing_info"]
|
||||
assert manufacturing["mold_material"] == "P20钢材"
|
||||
assert manufacturing["mold_hardness"] == "HRC 28-32"
|
||||
assert manufacturing["surface_finish"] == "SPI A2"
|
||||
assert manufacturing["estimated_cycle_time"] == "25 秒"
|
||||
assert manufacturing["parting_line_length"] == 620.0
|
||||
|
||||
|
||||
def test_material_service_falls_back_to_abs_for_unknown_material():
|
||||
material = MaterialService.get_material("UNKNOWN")
|
||||
assert material["name"] == "ABS"
|
||||
|
||||
Reference in New Issue
Block a user