Compare commits

...

35 Commits

Author SHA1 Message Date
cjw cf65ceed1c fix(moldinsight): 方案预览契约断裂——工艺参数面板全 N/A + 备选方案预览无法切换
多方案重构后 cavity_data 与可视化端字段契约断裂,两处收口:

- 契约桥接:CalculationService.attach_scheme_info_contract 把方案级
  key_info 内嵌回 cavity_data(mold_cavities.cavity_key_info / cavity_count /
  manufacturing_info 展示字段),multi_scheme_planner._build_scheme 生成后
  立即接线——3D 预览"关键工艺参数"面板此前 10 项 8 项恒 N/A,
  前端型腔数恒回退 1 腔
- 方案级预览恢复:_attach_scheme_previews 改为每方案生成完整预览
  (HTML + 摘要/数据 JSON 直传 RustFS)并写 scheme["html_file"],
  前端切方案即切 iframe(此前仅最优方案一份,备选永远显示推荐预览);
  删除零消费的 summary_file 死产物;任务级 HTML 复用推荐方案不再重复生成
- 前端 3D 预览标题栏加"当前:方案名"标识

已知取舍:报告键存储随方案数线性增长(记 TECH_DEBT D11 追记);
历史任务不回填,需重新分析。接口面零变化,openapi 不重导出。

测试:193 passed, 13 skipped(净增契约桥接回归测试 1 项,OCC-free);
vue-tsc 通过;本地无 pythonocc,planner 运行时路径待 conda 环境补跑。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 00:46:16 +08:00
cjw f87cd8a68e fix(deploy): celery worker --pool=solo——prefork 守护进程禁止 spawn OCC 子进程
Celery prefork 的 worker 子进程是 daemonic,multiprocessing 硬性禁止
守护进程再生子进程:OccProcessPool(spawn 拉起 OCC 常驻工作进程)在
celery worker 内首次 run 即报 "daemonic processes are not allowed to
have children",解析任务全部 failed。该池在 FastAPI 主进程内无此限制
——celery 路径的分析从未真正工作过。

改用 --pool=solo:任务在 celery 主进程(非守护)内执行,允许 spawn;
天然单任务串行,与 processing_service / redis_task_manager / db_manager
等模块级单例的 asyncio.Lock(不可跨循环并发)匹配。吞吐扩展走横向
加容器副本。CELERY_CONCURRENCY / MAX_TASKS_PER_CHILD 随之移除。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 00:01:05 +08:00
cjw eb0a4005e1 fix(db): celery 角色改用 NullPool——连接跨事件循环复用致任务全挂
celery_tasks 经 asyncio.run 每任务新建事件循环,而 asyncpg 连接绑定
创建它的循环。db_manager 引擎只连一次(is_connected 短路),QueuePool
把上一循环的连接缓存着流入新循环:任务报
"got Future attached to a different loop" 全部 failed,连接销毁时再报
"Event loop is closed"(ForkPoolWorker 日志连续复现)。

celery_tasks 对 redis 已有同因处理(每任务 reconnect()),DB 侧漏了。
改法:connect(role="celery") 用 NullPool——连接不缓存,每次 checkout
在当前循环新建、用完即关。web 入口(单循环 uvicorn)QueuePool 不变。

已实测:同进程三个连续 asyncio.run 循环共享引擎,全部查询成功。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:43:54 +08:00
cjw 73de137779 fix(processing): process_file_core 缺 timeout_seconds 参数——解析必挂 NameError
process_file_core 内 4 处引用 timeout_seconds(run_occ / 网格 / 型腔 /
设计分析的步骤超时),但该变量定义在外层 process_file_with_storage
(按文件大小计算超时处)。拆方法重构时漏传,解析第一步
run_occ("parse_stp") 即 NameError,任务标 failed,页面报
"name 'timeout_seconds' is not defined"。

修复:核心方法显式接收 timeout_seconds 参数(默认 300 兜底),
调用方传入。pyflakes 全仓库 F821 扫描确认无同类未定义名。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:27:32 +08:00
cjw aaaac95b53 fix(init_db): await 优先级漏括号——启动迁移自诞生起从未执行成功(事故根因)
has_alembic = await conn.execute(...).scalar()  因 await 优先级低于
属性访问,实际解析为 await (conn.execute(...).scalar())——在协程对象
上调 .scalar() 必抛 AttributeError('coroutine' object has no attribute
'scalar')。启动迁移每次容器启动都在第一行炸掉,再被 except 吞成一行
日志(4a5dc61 已改为 logger.exception)。两行均改为
(await conn.execute(...)).scalar()。

已于生产库实测:主库路径(has_alembic=True→upgrade no-op)与临时
历史库路径(stamp+upgrade)全部跑通,临时库已删除。

至此 2026-09-26 事故根因链闭合:启动迁移从未生效 → prod schema 长期
落后于模型 → 新镜像上线即上传 500(缺 product_id)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:17:33 +08:00
cjw 4a5dc61a02 fix(init_db): 启动迁移失败不再静默——logger.exception 带 traceback + schema 覆盖校验
2026-09-26 事故复盘:AUTO_MIGRATE=true 但启动迁移在 stamp 之前就抛异常,
except 里 logger.error(f"{e}") 丢掉了 traceback,docker logs 只剩一句
"数据库初始化失败",prod 缺 stp_files.product_id 等列导致上传全挂,
无从定位。两个修复:

- 初始化异常改用 logger.exception,完整 traceback 落日志
- 新增启动期 _verify_schema_coverage:模型声明的 32 张表全部列必须真实
  存在于 DB,缺失即 error 点名(只查缺不查多,遗留列不报);迁移后自动
  执行,只读、自身异常不影响启动

已对生产库实跑验证:32 张表全部通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:11:30 +08:00
cjw 021bf311c1 fix(migrations): 删掉 fingerprint 的 GIN 索引——json 类型无 GIN 操作符类,建表必炸
fingerprint 列是 sa.JSON() → PG json 类型,GIN 只支持 jsonb,
CREATE INDEX ... USING GIN (fingerprint) 直接报 UndefinedObject。
且代码侧并无 JSON 包含查询(聚合过滤在 Python 侧,DB 走
stp_axis_status 复合索引),索引本身无用。

首次对生产库执行此迁移时因此报错、整个 upgrade 事务回滚
(2026-09-26)。已在本机修复后重新 upgrade head 成功。
未来若需 JSON 检索:先把列迁为 jsonb 再建 GIN。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:57:54 +08:00
cjw f1d6a78f8a fix(auth): 登录 500——asyncpg 拒绝 aware datetime 写入 naive TIMESTAMP 列
批次5(64dc85b)datetime 弃用清零把 utcnow() 换成 aware 的
now(timezone.utc),而 users.last_login 列是 TIMESTAMP WITHOUT TIME
ZONE(sa.DateTime()),asyncpg 编码时抛 DataError → 未捕获 →
Starlette 纯文本 500。症状:密码错误正常 401,密码正确反而 500
(错误密码在 commit 前已 return)。

已在生产库实测确认(事务回滚零写入):aware 写入 REJECTED、
naive ACCEPTED、过去 1 小时 last_login 零记录。

- auth_service: last_login 改存 naive UTC(.replace(tzinfo=None)),
  JWT exp 不受影响仍用 aware
- experience_feedback_service: expires_at 同为 naive 列,提交/查询
  两处 aware now 一并修掉(线上尚无此表,属前瞻性修复)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:29:17 +08:00
cjw 9e52c95400 X 2026-09-26 21:40:48 +08:00
cjw 0c69d4c347 x 2026-09-26 21:34:22 +08:00
cjw 62fd22cb31 x 2026-09-26 21:23:04 +08:00
cjw d8d4deffd1 fix(deploy): .env 容器内连宿主基础设施走 172.17.0.1(bridge 网关)
原 .env 用宿主机 hostname 'szcjw' 作为 DB_HOST/REDIS_HOST/RUSTFS_ENDPOINT
主机名,容器内无此 hostname → DNS 失败 → 后端启动后任何 DB 操作报
ConnectionRefusedError: [Errno 111](上传 /api/upload 时触发 500)。

修法:全部改 172.17.0.1(Linux Docker bridge 网关固定值)。
部署机若网关不同(vpn/podman 等),用
  docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
查实际值后替换。

Co-Authored-By: Claude Code <noreply@anthropic.com>
EOF
2026-09-26 21:22:00 +08:00
cjw 65b33609d6 fix(deploy): frontend nginx client_max_body_size 1m→100M(修 /api/upload 413)
上传 STP 文件被 nginx 默认 1m 限制挡住,返回 413 Content Too Large。
与 .env MAX_FILE_SIZE=104857600(100MB)对齐;同步把 proxy_read/send_timeout
提到 300s,避免大文件反代中途断流。

部署机还需在外层 nginx(gemj.cn:443 那台)也加:
  client_max_body_size 100M;
否则外层 nginx 会先于容器返回 413。
2026-09-26 21:18:16 +08:00
cjw 3615902e8d fix(deploy): .env 显式写 BACKEND_PORT=(空值),backend 不再随机分配宿主机端口 2026-09-26 21:14:19 +08:00
cjw f0110df164 🔧 build(deploy): .env / .env.example 补 FRONTEND_PORT=10003 + 端口段重写
部署机实测:docker compose up -d --build 报
WARN[0000] The "FRONTEND_PORT" variable is not set. Defaulting to a blank string.
根因:之前所有 compose 端口相关 commit 只改 .env.example,未同步 .env;
服务器 .env 缺失 FRONTEND_PORT 与 BACKEND_PORT,前端容器端口因此暴露到
docker 自动分配的 32769,backend 暴露到 32770。

修复:
- .env 端口段补全:FRONTEND_PORT=10003 + BACKEND_PORT(注释留空)
- .env.example 端口段重写:与 .env 对齐,强化 DEPLOYMENT.md §1.2 选择 A
  约定(前端独占宿主端口、backend 不暴露)
- 移除冗余的 HOST/PORT(uvicorn compose 命令硬编码 8000/8001,
  直跑场景由 --host/--port 控制;env 注入未读取,删除减少心智负担)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 21:11:03 +08:00
cjw d7f92f1816 📝 docs(deploy): DEPLOYMENT §1.2 写入端口约定——选择 A 为默认
约定:unified 模式下前端独占宿主端口,backend 不暴露宿主端口,
浏览器始终只面对一个源,由前端 Nginx 同域反代到 backend,彻底
消除 CORS。BACKEND_PORT 留空 = 不暴露,仅 docker 网络内可达。

§1.2 补全:.env 最小集(FRONTEND_PORT=10003)、端口链路示意、
何时选 B(临时调试 / 压测 / k8s 健康检查,不建议常规生产用,会
引入 CORS 与攻击面问题)、缺配置 fail-fast 的硬约束说明,
并指向 .env.example 与 PORT_CONFIG.md 详细配置。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 21:03:49 +08:00
cjw a889671bb5 🔧 build(deploy): 取消宿主端口映射的默认值——.env 必须显式配置
三个 compose 文件的 ports 映射去掉 ${VAR:-默认值} 兜底:

- docker-compose.yml: frontend ${FRONTEND_PORT:-10003} → ${FRONTEND_PORT}
- docker-compose.moldinsight.yml: moldinsight ${MOLDINSIGHT_PORT:-10003} → ${MOLDINSIGHT_PORT}
- docker-compose.inventory.yml: inventory ${INVENTORY_PORT:-10004} → ${INVENTORY_PORT}

端口必须由 .env 显式配置,否则 compose 启动期 fail-fast(避免悄悄用了
某个端口导致宿主端口冲突或调试时困惑)。

注:DB_PORT:5432 / REDIS_PORT:6379 等应用行为默认值保留(与"宿主机
端口"不同,属基础设施默认端口,不影响端口暴露策略)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 21:00:59 +08:00
cjw aaf887507f 🐛 fix(deploy): backend 宿主机端口受 .env BACKEND_PORT 控制
上一版 backend 写死 expose: ["8000"](不暴露宿主),但这违背"全局服
务的最终映射到宿主机只能受 .env 中的端口配置决定"的原则——调试场景下
.env 没办法把 backend 暴露到宿主。

修正:backend ports 改为 "${BACKEND_PORT:-}:8000",由 .env 决定:
- BACKEND_PORT 留空/未设 = 不暴露宿主端口(仅经前端 /api 反代)
- BACKEND_PORT=10003 = 暴露宿主 10003(调试 / 压测用)

.env.example 同步加注释说明。

验证:模拟 .env 两种场景下,端口映射完全由 BACKEND_PORT 决定。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:58:10 +08:00
cjw b934b737e8 🐛 fix(deploy): 修 unified backend 与 frontend 抢占宿主 10003 冲突
上一版把 frontend 与 backend 都映射 10003:8000,会触发 docker 启动时
"bind: address already in use"。修正:

- docker-compose.yml backend ports 改为 expose: ["8000"]——不映射宿主机,
  仅在 docker 网络 gemold_network 内被 frontend 经 backend:8000 反代访问
- .env.example 删除 BACKEND_PORT 字段(不再需要)
- PORT_CONFIG / DEPLOY_PORT 端口映射表/示例同步:unified backend 不暴露
  宿主机端口,统一经前端 /api 反代

验证:yaml 渲染后无任何 service 对抢宿主端口(unified 仅 frontend 暴露
$FRONTEND_PORT=10003;moldinsight-only 仅 moldinsight 暴露 10003;
inventory-only 仅 inventory 暴露 10004)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:56:20 +08:00
cjw cf465d28e2 🔧 build(deploy): frontend 容器内端口 80→8000——避免占用宿主 80
- deploy/Dockerfile.frontend EXPOSE 80 → 8000
- deploy/nginx/frontend.conf listen 80 → 8000
- docker-compose.yml frontend ports "${FRONTEND_PORT:-10003}:80" → ":8000"

容器内端口统一 8000 系列(backend=8000、inventory=8001、frontend=8000),
frontend 不再抢占 80(留给外层 nginx/监控);1024+ 无需 root 权限。
宿主机端口(10003)保持不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:53:21 +08:00
cjw e65dcc2d39 🔧 build(deploy): 端口默认值统一 10003/10004——前端入口固定 10003
约定"容器内部端口无所谓,重要的是宿主机端口;前端 = 10003"。

- .env.example 端口段重写:移除冗余 HOST/PORT(uvicorn 命令硬编码,
  无人读 env)+ 移除误导注释;FRONTEND_PORT/BACKEND_PORT/MOLDINSIGHT_PORT
  默认 10003,INVENTORY_PORT 默认 10004
- docker-compose.yml frontend 默认 80→10003、backend 8000→10003,删除
  backend service 内冗余 HOST/PORT env
- docker-compose.moldinsight.yml / docker-compose.inventory.yml 默认
  端口同步
- DEPLOY_PORT §3 / PORT_CONFIG §1 §2 端口映射示例同步
- STATUS 补录

验证:yaml 渲染端口映射 unified frontend 10003→80、backend 10003→8000,
moldinsight 10003→8000,inventory 10004→8001。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:45:38 +08:00
cjw 0dfb3c63b1 📝 docs(status): 补 2026-09-26 批次——3 个部署机收尾修复
- --workdir 误用修复(celery 5.x 已移除):compose command 改为
  cd /app/src && exec celery -A celery_app worker
- 裸 up 不重建已有镜像澄清(DEPLOYMENT/README/OPERATIONS/LINUX_SETUP)
- build.sh 三步描述统一(celery 复用 backend 镜像)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:01:28 +08:00
cjw 8f8a7ea00a 🐛 fix(deploy): celery worker 启动命令移除 --workdir(celery 5.x 已移除)
部署机实测:celery worker --workdir=/app/src 报
"No such option '--workdir'"(旧 Dockerfile.celery 用的参数,
celery 4.x 兼容,5.x 已删除;项目锁的是新版本)。

修复:compose 的 moldinsight-celery command 改为
  cd /app/src && exec celery -A celery_app worker ...
直接切到 src/ 目录启动 worker(celery_app.py 内 include=
["celery_tasks"] 是裸模块名,需在 src/ 下才能解析),不依赖
celery 版本是否支持 --workdir。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:01:05 +08:00
cjw b03431b511 📝 docs(deploy): 明确裸 up 不重建已有镜像 + 修正 build.sh 三步描述
- DEPLOYMENT §1.2 / LINUX_SETUP §11 / README / OPERATIONS 补充:
  docker compose up -d 对本地已有同名镜像不会自动重建,更新代码后
  需 up -d --build 或先 build(部署机实测复用旧镜像后澄清)
- build.sh 描述由四步修正为 base → backend → frontend 三步(celery
  复用 backend 镜像,随 Dockerfile.celery 移除的文档收尾)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 17:05:01 +08:00
cjw 7717c9fa14 🔧 build(deploy): 镜像 base 由 miniconda 切换 Miniforge
Dockerfile.moldinsight FROM continuumio/miniconda3:24.7.1-0 →
condaforge/miniforge3:24.7.1-2:

- conda-forge 默认且唯一渠道,无 defaults 渠道与 Anaconda ToS 顾虑
- 与 CI 已用的 Miniforge 安装(.gitea/workflows/ci.yml 清华镜像)及
  开发机 Miniforge 环境同源
- tag 经 Docker Hub 社区用例确认存在;conda create 步骤与
  python=3.12 / pythonocc-core=7.9.0 锁定不变
- TECH_DEBT D13 锁定记录同步;STATUS 补录

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:33:40 +08:00
cjw 2fd1b3da21 🐛 fix(deploy): 修干净机器首次构建两处必挂——.dockerignore 排除 deploy/ + celery 并行构建依赖
部署机首次 docker compose up -d 实测暴露:

- .dockerignore 自"重写独立dockerfile"起排除整个 deploy/,而
  Dockerfile.frontend COPY deploy/nginx/frontend.conf、Dockerfile.moldinsight
  COPY deploy/requirements-*.txt → COPY not found。历史一直有旧镜像兜底未暴露;
  BuildKit 不支持重包含被排除目录的子文件,直接移除该行
- Dockerfile.celery FROM gemold-backend:latest 在 compose 并行构建下引用
  尚不存在的本地镜像必挂 → 删除 Dockerfile.celery,moldinsight-celery 改为
  与 API 服务同一 build 声明 + 同一 gemold-backend:latest tag(compose 去重
  只构建一次),celery 仅以 command: 覆盖启动 worker,参数语义不变
- build.sh/.bat 移除 gemold-celery 构建步骤;OPERATIONS / OCC_THROUGHPUT /
  TECH_DEBT / .env.example 的 Dockerfile.celery 指向同步改写;STATUS 补录

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:18:44 +08:00
cjw 4373fafd55 🐛 fix(deploy): 移除服务 profiles——裸 up / 裸 -f 恢复一键可用
compose 规则:声明了 profiles 的服务在不带 --profile 时永远不会被选中。
拆分首版保留 profiles(双保险设计)导致部署机实测 docker compose up -d
与 -f <file> up -d 均报 "no service selected",与"一键部署、换文件名换
模式"的核心诉求互斥,故彻底移除:

- 三个 compose 文件的全部 services 去掉 profiles 块,模式切换唯一入口 = -f 文件名
- 历史 --profile full/moldinsight/inventory 写法随之失效(目标服务本就已
  移出默认文件,兼容无意义);文档/构建脚本提示/STATUS 日志同步改为失效声明
- frontend/README 移除"仅起前端"用法(前端反代依赖 unified backend,推荐整栈)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:09:26 +08:00
cjw a548623ea5 📦 build(deploy): Compose 按部署模式拆分三文件——换文件名即换模式一键部署
- docker-compose.yml(unified 默认入口)/ docker-compose.moldinsight.yml / docker-compose.inventory.yml 三文件一一对应三种部署模式,profiles 字段保留(--profile 旧命令双保险可用)
- 修复两个既有部署隐患:moldinsight-only 场景 celery depends_on 悬空;moldinsight service image 统一为 gemold-backend:latest 与 Dockerfile.celery FROM 对齐(废弃 gemold-moldinsight tag)
- gemold_network / uploads_data / html_data 固定 name 命名;inventory-only 不声明卷避免空卷;每文件内 x-base-env anchor 收敛重复 environment(SECRET_KEY/ADMIN_PASSWORD fail-fast 保留)
- 文档同步 11 处:DEPLOYMENT §1.1 一键部署总表 + §2 三模式命令、LINUX_SETUP §6/§11、README、OPERATIONS §4、build.sh/.bat 提示、PORT_CONFIG / DEPLOY_PORT / STORAGE_SETUP / frontend/README
- STATUS.md 补 2026-09-24 批次日志

验证:三文件 YAML 结构静态校验通过;5 个 service environment 键与拆分前逐一比对零丢失(39/39、34/34、39/39、34/34、20/20)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 15:28:13 +08:00
cjw 6baa6b0d0a docs:ARCHITECTURE / ROADMAP 同步 D17 Human-in-Loop 完成
更新两个权威文档与 D17 端到端闭环对齐:

- docs/ROADMAP.md §2.2 主线二"moldinsight 工程化增强"重点方向
  加一条 D17 已完成条目(2026-09-23~24,3 个 commit:数据 + 权限 +
  写入 API / 算法接缝 + OCC payload / 前端按钮 + Dialog + 经验角标;
  详见 TECH_DEBT.md D17),与既有 ~~XXX~~(YYYY-MM-DD 完成)格式一致

- docs/ARCHITECTURE.md 新增 §6.4 D17 Human-in-Loop 老师傅经验反馈闭环
  —— 已完成段:用 ASCII 数据流图展示老师傅点反馈按钮 → 路由层
  → service 写入 → 续期衰减 → 上传新 STP 触发 resolve_for_process_params
  → OCC payload 透传 → planner 算法加成 → ResultView 渲染的端到端链路

  段内列出"硬规则遵守"(跨模块 FK 守 §5.1、OCC payload 守
  occ_worker.py:7-8、D9 边界不破、init_db.py 幂等修复已落)和
  "重量级约束"(weight 仅正向、sample_count<2 时 ×0.5、graceful 退化、
  角色门控),最后给测试基线指针

  放在 §6.3"文档与结构尚未完全同步"之后作为"已完成端到端闭环"
  对照示例,便于新成员理解 D17 在系统中的位置

文档侧仅变更,无代码改动;按 AGENTS.md §4.1 映射表,模块边界
(ARCHITECTURE.md)/ 演进路线(ROADMAP.md)相关变更同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 10:15:36 +08:00
cjw 483f158424 D17 批 3:前端按钮 + Dialog + 经验角标——闭环可视
让老师傅能用起来:ResultView 加按钮组 + HumanFeedbackDialog 组件 +
经验角标渲染 + 接口变更三件套随批完成,admin / process_engineer 可在
分析结果页标记采纳 / 建议调整 / 拒绝,下次同指纹产品分析自动消费。

变更内容:
- frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue(new)
  新组件:t-dialog + t-form + t-radio-group 三选一(采纳 / 建议调整 /
  拒绝)+ t-textarea 原因 + t-textarea 调整建议(仅 adjust 模式显隐);
  走 moldinsightApi.submitExperienceFeedback,提交成功后 emit 'submitted'
  让父组件重新拉 hints 刷新角标;表单打开时 watch 重置;loading 态 /
  error 通知复用 shared/notification
- frontend/src/modules/moldinsight/ResultView.vue
  - import moldinsightApi 与 HumanFeedbackDialog
  - state 增加 feedbackDialogVisible / hintsFingerprint / hints
  - Scheme interface 扩展 axis / confidence_score / method 顶层字段
    (前端 TS 类型与后端 Pydantic 字段对齐)
  - computed taskId / hintsByAxis / currentAxisHint(避免 undefined index
    TS 报错)/ canGiveFeedback(is_superuser || roles 含 process_engineer)
  - function loadExperienceHints(onMounted + 提交后各调一次;失败静默
    退化,按钮仍可点)/ openFeedbackDialog / onFeedbackSubmitted
  - 模板:
    * 推荐方案卡片 summary-header 加 t-tag theme="success" variant="light"
      经验角标("📚 历史经验 N 条",title 提示采纳/拒绝计数)
    * export-buttons-bar 加 👍 老师傅反馈 按钮(v-if="canGiveFeedback"
      角色门控;theme="default" size="small" 与既有 6 个 t-button 视觉一致)
    * </t-loading> 之后挂 HumanFeedbackDialog(v-model:visible + 11 个 props
      透传 scheme_id / axis / fingerprint / score_at_submit / 等)
- frontend/src/shared/api-client.ts moldinsightApi 加两个方法:
  getExperienceHints(taskId) / submitExperienceFeedback(taskId, data)
  (返回类型由 openapi-typescript 自动生成,无需手维护)

接口变更三件套随批完成:
- openapi.json 重导出(unified app,含 ExperienceFeedbackCreate /
  ExperienceFeedbackResponse / ExperienceHintItem / ExperienceHintsResponse
  四个新 schema,2 个新 path 出现)
- npm run gen:api 再生 frontend/src/types/api.ts(生成物,禁止手改)
- npm run build 通过(vue-tsc + vite,ResultView 包 37.18 kB / 11.91 kB
  gzip)

- docs/STATUS.md 顶部加 2026-09-24 批 3 日志条目
- docs/TECH_DEBT.md D17 追加批 3 已完成描述 + 缩减剩余工作(仅剩批 4
  按需排期)

设计要点:
- canGiveFeedback 借 (appStore.user as any).roles 临时绕过 AppUser
  类型不含 roles 的限制(前端硬规则:"不要硬编码 'admin' 字符串"——
  按 role.code === 'process_engineer' 匹配)
- currentAxisHint 解决 selectedScheme.axis 可能 undefined 的索引报错
- onFeedbackSubmitted 提交后立即 loadExperienceHints 触发角标刷新,
  实现"写入即消费"前端可见

后端基线:192 passed, 13 skipped(批 3 纯前端,不动后端)。
D17 闭环端到端可用:admin / process_engineer 在 ResultView 点反馈
按钮 → Dialog 提交 → 角标即时刷新 + 下次同指纹产品分析算法加成。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 10:03:09 +08:00
cjw 505f3591ab D17 批 2:算法接缝(OCC payload 通道 + hints 透传到分模评分)—— 闭环通
让批 1 沉淀的老师傅经验 hints 真接入分模方案生成:
processing_service 拉同指纹 hints 装进 OCC worker payload,
planner 透传到 candidate_generator(axis 优先级加成)和
scheme_scorer(score_breakdown 新字段 + total_score 加成),
写入即消费闭环通。

变更内容:
- src/moldinsight/core/parting_candidate_generator.py
  generate_candidates(..., hints=None):_build_axis_metrics 末尾按 hints
  加成(priority_score += weight × 20 上限;sample_count ≥ 2 + weight ≥ 0.5
  → method 标签升级 "human_experience_primary")
- src/moldinsight/core/parting_scheme_scorer.py
  score_schemes(schemes, *, hints=None) keyword-only:_score_scheme 新增
  human_hint_bonus 字段(weight × 12 上限;sample_count < 2 时 ×0.5 折半
  防信号不足过度影响);_compute_human_hint_bonus 静态方法解析 axis
  (parting.axis → axis → Z);bonus 纳入 total_score
- src/moldinsight/core/multi_scheme_planner.py
  generate_plan(..., hints=None):透传 hints 到 candidate_generator 与
  scheme_scorer;global_summary.applied_hints 注入返回供前端 ResultView
  渲染经验角标
- src/moldinsight/services/processing_service.py
  _step_generate_cavity 加 db_session 形参;调用
  experience_feedback_service.resolve_for_process_params 拿同指纹 hints,
  装进 run_occ payload 顶层 experience_hints;解析失败回退空 list
  不阻塞主流程(旧任务不因 receives 闭包退化)
- src/moldinsight/core/occ_worker.py
  _op_generate_cavity:payload.get("experience_hints") or {} 透传给
  planner.generate_plan(..., hints=...);普通 dict 跨进程 pickle 安全
  (满足 occ_worker.py:7-8 硬规则)
- tests/test_experience_feedback_algorithm.py(new)11 例:
  - candidate_generator 3 例(无 hints 默认 / hints 加成 / sample_count < 2 不升级)
  - scheme_scorer 4 例(无 hints 无 bonus / bonus 加成 / sample_count 折半 /
    weight=0 不加成)
  - multi_scheme_planner 2 例 OCC-gated(透传 / applied_hints 默认空)
  - processing_service 2 例 OCC-gated(payload 含 experience_hints /
    解析失败回退空 list)

设计取舍:
- keyword-only hints:避免与位置参数混淆
- weight 仅正向上有效:max(0, (adopted-rejected)/total),老师傅拒绝
  的不扣分老算法,只让采纳的加分
- signal-noise 控制:sample_count < 2 时 bonus ×0.5,但 priority_score
  仍加成(候选方向仍偏向,避免完全无信号)
- graceful degradation:hints 解析失败回退空 list,主流程继续

- docs/STATUS.md 顶部加 2026-09-23 批 2 日志条目
- docs/TECH_DEBT.md D17 追加批 2 已完成描述 + 缩减剩余工作(仅剩批 3 / 4)

测试基线:192 passed, 13 skipped(净增 7 通过 + 4 OCC-gated skip)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:21:33 +08:00
cjw 2c9ba9d6b3 D17 批 1:Human-in-Loop 老师傅经验反馈(数据 + 权限 + 写入 API)
新增 experience_feedback 表(32 表迁移,alembic head b7d1f4a92c3e),
老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 +
工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费。

变更内容:
- src/moldinsight/models/experience_feedback.py(new)ORM:Base 单点来源、
  跨模块裸 FK(user_id / processing_task_id / stp_file_id)、不建 ORM
  relationship;fingerprint JSON 列存跨任务匹配键
- src/moldinsight/models/__init__.py 导出 ExperienceFeedback
- migrations/versions/b7d1f4a92c3e_add_experience_feedback.py(new)32 表
  迁移;fingerprint 列 PG 下加 GIN 索引(jsonb_path_query 支持)
- src/shared/database/init_db.py 加 3 个权限码(view_experience_feedback /
  feedback_experience_hint / manage_experience_feedback)+ 新角色
  process_engineer;admin 角色 permissions 同步补齐;init_permissions /
  init_roles 改为按 code 比对(新增保留已有 id,避免 FK 引用失效)——
  修复既有 DB 启动期漏掉新权限的幂等 bug
- src/moldinsight/services/experience_feedback_service.py(new)service:
  compute_fingerprint 分桶(bbox_aspect / volume_bucket / face_bucket /
  undercut_class / material_family / is_foam)/ record_feedback(D9 边界:
  service.flush + 路由 commit;D17 衰减:同 stp_file_id 整体续期 90 天 TTL,
  无 celery beat 依赖)/ list_hints_for_task / resolve_for_process_params
- src/moldinsight/api/experience_feedback_router.py(new)路由:Pydantic
  模型写在路由文件内(项目硬规则);POST /api/tasks/{task_id}/experience-feedback
  + GET /api/tasks/{task_id}/experience-hints;归属 TaskQueryService.ensure_task_access
  + User.has_permission 全仓首次调用点
- src/moldinsight/api/__init__.py ROUTE_MODULES 注册新路由
- tests/test_model_ownership.py EXPECTED_TABLES 加 experience_feedback
  (31→32)
- tests/test_experience_feedback_fingerprint.py(new)分桶参数化覆盖
  bbox / volume / face / undercut / material / is_foam 各边界值
- tests/test_experience_feedback_router.py(new)API 契约 9 例
  (401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)
- docs/STATUS.md 顶部加 2026-09-23 批 1 日志条目
- docs/TECH_DEBT.md D17 加批 1 已完成描述 + 剩余工作清单
- docs/API_CONTRACT.md §3.2 加 D17 端点表格

测试基线:185 passed, 9 skipped(净增 59 测试)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:11:17 +08:00
cjw 79441a8a87 批次6:D4文档治理收口 + D13锁文件流程固化
D4 文档/规划/历史混放收口:
- docs/TECH_DEBT.md §2 由'按批次回顾'精简为'按主题摘要',
  与§3重复内容(批次0-4详细展开)整体迁入
  docs/archive/2026-09_governance_batches.md
- docs/STATUS.md 顶部 2026-09-17 之前条目迁入
  docs/archive/2026-09_status_history.md,仅保留指针
- D2 历史口径补齐为 2026-09-18 批次4后续专项清偿
- D4 标已清偿
- AGENTS.md / docs/archive/README.md 同步导航

D13 锁文件流程固化(镜像引入主体已清偿,仅剩锁文件落盘):
- 新增 deploy/generate_lockfiles.{sh,bat}:在 moldinsight conda
  环境(仅项目依赖)执行 pip freeze --exclude pythonocc-core,
  产出 deploy/requirements-{base,moldinsight}.lock.txt
- deploy/Dockerfile.moldinsight 注释改为指向生成脚本
- docs/OPERATIONS.md §2.1 增加完整流程说明
- tests/test_lockfile_generation.py 加锁文件存在性+体积契约;
  tests/conftest.py 注册 --run-lockfile-check 选项,
  默认 skip(仓库单测不阻塞),CI 镜像构建 job 显式启用 fail-fast

遗留:锁文件本身尚未落盘(本机Miniforge跨项目开发栈混装,
污染严重不能直接 pip freeze);待 CI / 生产首次构建时按流程落锁。

测试基线:126 passed, 9 skipped(默认4原有skip + D13新增5skip;
启用 --run-lockfile-check 时严格断言2项锁文件契约)

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:13:45 +08:00
cjw c51e6b793a x 2026-09-18 18:21:01 +08:00
90 changed files with 5715 additions and 3323 deletions
+2 -1
View File
@@ -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/
+13 -6
View File
@@ -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
View File
@@ -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
+7 -6
View File
@@ -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 导航)
```
+18 -3
View File
@@ -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`
---
-14
View File
@@ -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"]
+2 -1
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+38
View File
@@ -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
+50
View File
@@ -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 中的实际版本(或保留 >=,按团队策略)"
+8 -1
View File
@@ -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;
+58
View File
@@ -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
+115
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. 运行时硬性要求
+5
View File
@@ -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 主线四:部署与运维一致性
+31
View File
@@ -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
View File
@@ -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)
---
+100
View File
@@ -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
+10
View File
@@ -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(端口补充)三层。)
+2
View File
@@ -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/):已迁移的铝泡沫专题历史材料
+12 -8
View File
@@ -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`
---
+25 -12
View File
@@ -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
```
---
+14 -11
View File
@@ -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`)
---
+1 -1
View File
@@ -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,仅在出现独立伸缩需求时启动 | 暂不启动 |
+5 -3
View File
@@ -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
View File
@@ -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)。
---
+104 -2
View File
@@ -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>
+43
View File
@@ -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
View File
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
View File
File diff suppressed because it is too large Load Diff
+5 -11
View File
@@ -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,
)
+5 -11
View File
@@ -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,
)
+6 -38
View File
@@ -9,17 +9,15 @@
路由前缀: /api/customers
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from inventory.models import Customer
from ..schemas import CustomerCreate, CustomerResponse
from ..services.master_data_service import master_data_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
@@ -32,12 +30,7 @@ async def list_customers(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Customer).where(Customer.is_active == True)
if search:
query = query.where(Customer.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
result = await db_session.execute(query)
return [CustomerResponse.from_orm(c) for c in result.scalars().all()]
return await master_data_service.list_customers(db_session, skip, limit, search)
@router.post("", response_model=CustomerResponse, status_code=201)
@@ -46,15 +39,7 @@ async def create_customer(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
data = customer_data.dict()
if not data.get("code"):
data["code"] = f"C{datetime.now().strftime('%Y%m%d%H%M%S')}"
customer = Customer(**data)
db_session.add(customer)
await db_session.flush()
await db_session.refresh(customer)
return CustomerResponse.from_orm(customer)
return await master_data_service.create_customer(db_session, customer_data, current_user)
@router.put("/{customer_id}", response_model=CustomerResponse)
@@ -64,17 +49,7 @@ async def update_customer(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
for key, value in customer_data.dict().items():
setattr(customer, key, value)
await db_session.flush()
await db_session.refresh(customer)
return CustomerResponse.from_orm(customer)
return await master_data_service.update_customer(db_session, customer_id, customer_data, current_user)
@router.delete("/{customer_id}")
@@ -83,11 +58,4 @@ async def delete_customer(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
customer.is_active = False
await db_session.flush()
return {"message": "客户已删除"}
return await master_data_service.delete_customer(db_session, customer_id, current_user)
+2 -54
View File
@@ -11,12 +11,11 @@
"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder
from ..services.dashboard_service import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
@@ -26,55 +25,4 @@ async def get_dashboard(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
material_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
) or 0
finished_product_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
) or 0
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True))
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True))
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True))
total_stock = await db_session.scalar(
select(func.sum(Inventory.quantity))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
total_value = await db_session.scalar(
select(func.sum(Inventory.quantity * Product.cost_price))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
pending_purchase = await db_session.scalar(
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
)
pending_sales = await db_session.scalar(
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
)
low_stock_products = await db_session.execute(
select(Product, Inventory)
.join(Inventory, Product.id == Inventory.product_id)
.where(Product.item_type == "material")
.where(Inventory.quantity <= Product.min_stock)
.limit(10)
)
low_stock = [
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
for p, i in low_stock_products.all()
]
return {
"finished_product_count": finished_product_count,
"material_count": material_count,
"supplier_count": supplier_count,
"customer_count": customer_count,
"warehouse_count": warehouse_count,
"total_stock": total_stock,
"total_value": round(total_value, 2),
"pending_purchase": pending_purchase,
"pending_sales": pending_sales,
"low_stock_products": low_stock
}
return await dashboard_service.get_dashboard(db_session)
+10 -244
View File
@@ -8,15 +8,13 @@
路由前缀: /api/materials
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional, List
from typing import List
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
@@ -24,22 +22,11 @@ from ..schemas import (
MaterialSupplierResponse,
MaterialPriceTrendResponse
)
from ..services.material_service import material_service
router = APIRouter(prefix="/materials", tags=["物料管理"])
async def _get_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
return product
@router.post("/{product_id}/price-history", response_model=MaterialPriceHistoryResponse, status_code=201)
async def add_material_price_history(
product_id: int,
@@ -47,42 +34,7 @@ async def add_material_price_history(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
if price_data.supplier_id:
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == price_data.supplier_id, Supplier.is_active == True)
)
if not supplier_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="供应商不存在")
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark
)
db_session.add(price_history)
await db_session.flush()
await db_session.refresh(price_history)
# 更新产品的成本价格为最新价格
product.cost_price = price_data.price
await db_session.flush()
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=price_history.supplier.name if price_history.supplier else None,
remark=price_history.remark,
created_at=price_history.created_at
)
return await material_service.add_price_history(db_session, product_id, price_data, current_user)
@router.get("/{product_id}/price-history", response_model=List[MaterialPriceHistoryResponse])
@@ -92,31 +44,7 @@ async def get_material_price_history(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
price_history_list = result.scalars().all()
return [
MaterialPriceHistoryResponse(
id=ph.id,
product_id=ph.product_id,
product_sku=product.sku,
product_name=product.name,
price=ph.price,
effective_date=ph.effective_date,
supplier_id=ph.supplier_id,
supplier_name=ph.supplier.name if ph.supplier else None,
remark=ph.remark,
created_at=ph.created_at
)
for ph in price_history_list
]
return await material_service.get_price_history(db_session, product_id, limit)
@router.get("/{product_id}/price-trend", response_model=MaterialPriceTrendResponse)
@@ -126,45 +54,7 @@ async def get_material_price_trend(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 计算价格趋势
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
price_history_list = result.scalars().all()
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
prices = [ph.price for ph in reversed(price_history_list)]
dates = [ph.effective_date for ph in reversed(price_history_list)]
# 计算价格变化
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
{
"date": ph.effective_date,
"price": ph.price,
"supplier_name": ph.supplier.name if ph.supplier else None
}
for ph in price_history_list
]
)
return await material_service.get_price_trend(db_session, product_id, months)
@router.post("/{product_id}/suppliers", response_model=MaterialSupplierResponse, status_code=201)
@@ -174,63 +64,7 @@ async def add_material_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_data.supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400, detail="供应商不存在")
# 检查是否已存在关联
existing = await db_session.execute(
select(MaterialSupplier)
.where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
# 如果设置为主要供应商,将其他供应商设置为非主要
if supplier_data.is_primary:
await db_session.execute(
MaterialSupplier.__table__.update()
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity
)
db_session.add(material_supplier)
await db_session.flush()
await db_session.refresh(material_supplier)
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier.name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at
)
return await material_service.add_material_supplier(db_session, product_id, supplier_data, current_user)
@router.get("/{product_id}/suppliers", response_model=List[MaterialSupplierResponse])
@@ -239,33 +73,7 @@ async def get_material_suppliers(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
supplier_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=ms.supplier_id,
supplier_name=ms.supplier.name if ms.supplier else None,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in supplier_list
]
return await material_service.get_material_suppliers(db_session, product_id)
@router.delete("/suppliers/{supplier_id}")
@@ -274,17 +82,7 @@ async def remove_material_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.flush()
return {"message": "物料供应商关联已删除"}
return await material_service.remove_material_supplier(db_session, supplier_id, current_user)
@router.get("/suppliers/{supplier_id}/materials", response_model=List[MaterialSupplierResponse])
@@ -293,36 +91,4 @@ async def get_supplier_materials(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
material_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=ms.product.sku if ms.product else None,
product_name=ms.product.name if ms.product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in material_list
]
return await material_service.get_supplier_materials(db_session, supplier_id)
+12 -246
View File
@@ -9,67 +9,20 @@
路由前缀: /api/products
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, func, delete
from typing import Optional, List, Dict
from decimal import Decimal
from pathlib import Path
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from moldinsight.models import STPFile, ProcessingTask
from inventory.models import Product, ProductMaterial
from ..schemas import (
ProductCreate,
ProductResponse,
ProductBOMUpdate,
ProductBOMResponse,
ProductMaterialItemResponse
)
from ..schemas import ProductBOMResponse, ProductBOMUpdate, ProductCreate, ProductResponse
from ..services.product_service import product_service
router = APIRouter(prefix="/products", tags=["产品管理"])
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
if not product_ids:
return {}
result = await db_session.execute(
select(
ProductMaterial.finished_product_id,
func.coalesce(
func.sum(
Product.cost_price * ProductMaterial.quantity
),
0
)
)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(product_ids))
.group_by(ProductMaterial.finished_product_id)
)
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
return ProductResponse(
id=product.id,
sku=product.sku,
name=product.name,
description=product.description,
category=product.category,
unit=product.unit,
item_type=product.item_type,
cost_price=Decimal(str(product.cost_price or 0)),
sale_price=Decimal(str(product.sale_price or 0)),
min_stock=product.min_stock,
max_stock=product.max_stock,
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
is_active=product.is_active,
created_at=product.created_at,
)
@router.get("", response_model=List[ProductResponse])
async def list_products(
@@ -81,21 +34,7 @@ async def list_products(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Product).where(Product.is_active == True)
if search:
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
if category:
query = query.where(Product.category == category)
if item_type:
query = query.where(Product.item_type == item_type)
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db_session.execute(query)
products = result.scalars().all()
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
return await product_service.list_products(db_session, skip, limit, search, category, item_type)
@router.post("", response_model=ProductResponse, status_code=201)
@@ -104,21 +43,7 @@ async def create_product(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
if product_data.item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.dict()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
product = Product(**product_dict)
db_session.add(product)
await db_session.flush()
await db_session.refresh(product)
return _build_product_response(product, 0)
return await product_service.create_product(db_session, product_data, current_user)
@router.post("/from-task/{task_id}", response_model=ProductResponse, status_code=201)
@@ -127,62 +52,7 @@ async def create_product_from_task(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""从模具分析任务创建进销存成品,回写 stp_files.product_id(P2-1)"""
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="分析任务不存在")
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
stp_file = stp_result.scalar_one_or_none()
if not stp_file:
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
# 已关联成品则直接返回(幂等)
if stp_file.product_id:
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
product = existed.scalar_one_or_none()
if product:
return _build_product_response(product, 0)
# 生成唯一 SKU:MI{stp_file_id},冲突则追加序号
base_sku = f"MI{stp_file_id}"
sku = base_sku
n = 1
while True:
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
if not conflict.scalar_one_or_none():
break
n += 1
sku = f"{base_sku}-{n}"
name = Path(stp_file.original_filename or f"mold_{stp_file_id}").stem or f"模具分析-{stp_file_id}"
desc_parts = []
if stp_file.volume:
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
if stp_file.product_weight:
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
if stp_file.surface_area:
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
product = Product(
sku=sku,
name=name,
description=description,
category="模具成品",
unit="件",
item_type="finished",
cost_price=0,
sale_price=0,
min_stock=0,
max_stock=0,
)
db_session.add(product)
await db_session.flush()
stp_file.product_id = product.id
await db_session.flush()
await db_session.refresh(product)
return _build_product_response(product, 0)
return await product_service.create_product_from_task(db_session, task_id, current_user)
@router.put("/{product_id}", response_model=ProductResponse)
@@ -192,25 +62,7 @@ async def update_product(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Product).where(Product.id == product_id))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product_data.item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
product_dict = product_data.dict()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
for key, value in product_dict.items():
setattr(product, key, value)
await db_session.flush()
await db_session.refresh(product)
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
return _build_product_response(product, material_cost_map.get(product.id, 0))
return await product_service.update_product(db_session, product_id, product_data, current_user)
@router.delete("/{product_id}")
@@ -219,14 +71,7 @@ async def delete_product(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Product).where(Product.id == product_id))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
product.is_active = False
await db_session.flush()
return {"message": "产品已删除"}
return await product_service.delete_product(db_session, product_id, current_user)
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
@@ -235,44 +80,7 @@ async def get_product_bom(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product_result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id == product_id)
.order_by(ProductMaterial.id.asc())
)
items: List[ProductMaterialItemResponse] = []
total_material_cost = Decimal("0")
for bom, material in bom_result.all():
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
total_material_cost += line_cost
items.append(
ProductMaterialItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
quantity=Decimal(str(bom.quantity)),
unit_cost=Decimal(str(material.cost_price or 0)),
line_cost=line_cost,
)
)
return ProductBOMResponse(
product_id=product.id,
product_name=product.name,
total_material_cost=total_material_cost,
items=items,
)
return await product_service.get_product_bom(db_session, product_id)
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
@@ -282,46 +90,4 @@ async def replace_product_bom(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product_result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
material_ids = [item.material_id for item in payload.items]
if len(material_ids) != len(set(material_ids)):
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
if material_ids:
material_result = await db_session.execute(
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
)
materials = material_result.scalars().all()
material_map = {m.id: m for m in materials}
if len(material_map) != len(material_ids):
raise HTTPException(status_code=400, detail="存在无效物料")
invalid_materials = [m.name for m in materials if m.item_type != "material"]
if invalid_materials:
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
else:
material_map = {}
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
for item in payload.items:
if item.quantity <= 0:
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
db_session.add(
ProductMaterial(
finished_product_id=product_id,
material_product_id=item.material_id,
quantity=item.quantity,
loss_rate=item.loss_rate,
)
)
await db_session.flush()
return await get_product_bom(product_id, db_session, current_user)
return await product_service.replace_product_bom(db_session, product_id, payload, current_user)
+6 -38
View File
@@ -9,17 +9,15 @@
路由前缀: /api/suppliers
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from inventory.models import Supplier
from ..schemas import SupplierCreate, SupplierResponse
from ..services.master_data_service import master_data_service
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
@@ -32,12 +30,7 @@ async def list_suppliers(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Supplier).where(Supplier.is_active == True)
if search:
query = query.where(Supplier.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
result = await db_session.execute(query)
return [SupplierResponse.from_orm(s) for s in result.scalars().all()]
return await master_data_service.list_suppliers(db_session, skip, limit, search)
@router.post("", response_model=SupplierResponse, status_code=201)
@@ -46,15 +39,7 @@ async def create_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
data = supplier_data.dict()
if not data.get("code"):
data["code"] = f"S{datetime.now().strftime('%Y%m%d%H%M%S')}"
supplier = Supplier(**data)
db_session.add(supplier)
await db_session.flush()
await db_session.refresh(supplier)
return SupplierResponse.from_orm(supplier)
return await master_data_service.create_supplier(db_session, supplier_data, current_user)
@router.put("/{supplier_id}", response_model=SupplierResponse)
@@ -64,17 +49,7 @@ async def update_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
for key, value in supplier_data.dict().items():
setattr(supplier, key, value)
await db_session.flush()
await db_session.refresh(supplier)
return SupplierResponse.from_orm(supplier)
return await master_data_service.update_supplier(db_session, supplier_id, supplier_data, current_user)
@router.delete("/{supplier_id}")
@@ -83,11 +58,4 @@ async def delete_supplier(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
supplier.is_active = False
await db_session.flush()
return {"message": "供应商已删除"}
return await master_data_service.delete_supplier(db_session, supplier_id, current_user)
+3 -16
View File
@@ -9,15 +9,13 @@
"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from datetime import datetime
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Warehouse
from ..schemas import WarehouseCreate, WarehouseResponse
from ..services.master_data_service import master_data_service
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
@@ -27,10 +25,7 @@ async def list_warehouses(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
)
return [WarehouseResponse.from_orm(w) for w in result.scalars().all()]
return await master_data_service.list_warehouses(db_session)
@router.post("", response_model=WarehouseResponse, status_code=201)
@@ -39,12 +34,4 @@ async def create_warehouse(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
data = warehouse_data.dict()
if not data.get("code"):
data["code"] = f"W{datetime.now().strftime('%Y%m%d%H%M%S')}"
warehouse = Warehouse(**data)
db_session.add(warehouse)
await db_session.flush()
await db_session.refresh(warehouse)
return WarehouseResponse.from_orm(warehouse)
return await master_data_service.create_warehouse(db_session, warehouse_data, current_user)
+3 -2
View File
@@ -54,7 +54,8 @@ from .material_schemas import (
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
MaterialPriceTrendResponse
MaterialPriceTrendResponse,
PriceHistoryItem
)
from .purchase_demand_schemas import (
PurchaseDemandCalculateRequest,
@@ -89,6 +90,6 @@ __all__ = [
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse", "PriceHistoryItem",
"PurchaseDemandCalculateRequest", "PurchaseDemandItemResponse", "PurchaseDemandResponse",
]
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -20,5 +20,4 @@ class CustomerResponse(BaseModel):
email: Optional[str]
is_active: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+3 -5
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List, Literal
from datetime import datetime
from decimal import Decimal
@@ -39,8 +39,7 @@ class FinanceAllocationResponse(BaseModel):
order_id: int
allocated_amount: Decimal
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class FinanceTransactionResponse(BaseModel):
@@ -59,8 +58,7 @@ class FinanceTransactionResponse(BaseModel):
created_at: datetime
allocations: List[FinanceAllocationResponse] = []
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class FinanceSummaryResponse(BaseModel):
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
from decimal import Decimal
@@ -14,8 +14,7 @@ class InventoryResponse(BaseModel):
locked_quantity: Decimal
available_quantity: Decimal
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class InventoryCreate(BaseModel):
+5 -7
View File
@@ -3,7 +3,7 @@
定义物料价格历史和物料供应商关联的数据结构
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Optional, List, Dict
@@ -27,9 +27,8 @@ class MaterialPriceHistoryResponse(BaseModel):
supplier_name: Optional[str]
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class MaterialSupplierCreate(BaseModel):
@@ -57,9 +56,8 @@ class MaterialSupplierResponse(BaseModel):
min_order_quantity: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PriceHistoryItem(BaseModel):
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
@@ -33,8 +33,7 @@ class ProductResponse(BaseModel):
is_active: bool
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class ProductMaterialItemUpdate(BaseModel):
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import datetime, date
from decimal import Decimal
@@ -34,8 +34,7 @@ class PurchaseOrderResponse(BaseModel):
received_date: Optional[datetime]
paid_date: Optional[datetime]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PurchaseOrderItemResponse(BaseModel):
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import datetime, date
from decimal import Decimal
@@ -43,8 +43,7 @@ class SalesOrderResponse(BaseModel):
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class SalesOrderItemResponse(BaseModel):
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from decimal import Decimal
@@ -27,5 +27,4 @@ class StockMovementResponse(BaseModel):
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -20,5 +20,4 @@ class SupplierResponse(BaseModel):
email: Optional[str]
is_active: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+2 -3
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -19,5 +19,4 @@ class WarehouseResponse(BaseModel):
is_active: bool
is_default: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,71 @@
"""仪表盘聚合业务服务层
将 dashboard_routes 中的聚合查询与统计编排下沉到此,
路由层只做依赖注入与响应返回。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from inventory.models import Customer, Inventory, Product, PurchaseOrder, SalesOrder, Supplier, Warehouse
class DashboardService:
"""仪表盘统计服务"""
@staticmethod
async def get_dashboard(db_session: AsyncSession) -> dict:
material_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
) or 0
finished_product_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
) or 0
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) or 0
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) or 0
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) or 0
total_stock = await db_session.scalar(
select(func.sum(Inventory.quantity))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
total_value = await db_session.scalar(
select(func.sum(Inventory.quantity * Product.cost_price))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
pending_purchase = await db_session.scalar(
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
) or 0
pending_sales = await db_session.scalar(
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
) or 0
low_stock_products = await db_session.execute(
select(Product, Inventory)
.join(Inventory, Product.id == Inventory.product_id)
.where(Product.item_type == "material")
.where(Inventory.quantity <= Product.min_stock)
.limit(10)
)
low_stock = [
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
for p, i in low_stock_products.all()
]
return {
"finished_product_count": finished_product_count,
"material_count": material_count,
"supplier_count": supplier_count,
"customer_count": customer_count,
"warehouse_count": warehouse_count,
"total_stock": total_stock,
"total_value": round(total_value, 2),
"pending_purchase": pending_purchase,
"pending_sales": pending_sales,
"low_stock_products": low_stock,
}
dashboard_service = DashboardService()
@@ -0,0 +1,193 @@
"""进销存主数据业务服务层
将 customer / supplier / warehouse 这类主数据 CRUD 编排从路由层下沉到此,
路由层只做参数校验与响应组装。
"""
from datetime import datetime
from typing import List, Optional, Type
from fastapi import HTTPException
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from inventory.models import Customer, Supplier, Warehouse
from ..schemas import (
CustomerCreate,
CustomerResponse,
SupplierCreate,
SupplierResponse,
WarehouseCreate,
WarehouseResponse,
)
def _generate_code(prefix: str) -> str:
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
async def _get_entity_or_404(
db_session: AsyncSession,
model: Type[Customer] | Type[Supplier] | Type[Warehouse],
entity_id: int,
detail: str,
):
result = await db_session.execute(select(model).where(model.id == entity_id))
entity = result.scalar_one_or_none()
if not entity:
raise HTTPException(status_code=404, detail=detail)
return entity
def _build_customer_response(customer: Customer) -> CustomerResponse:
return CustomerResponse.model_validate(customer)
def _build_supplier_response(supplier: Supplier) -> SupplierResponse:
return SupplierResponse.model_validate(supplier)
def _build_warehouse_response(warehouse: Warehouse) -> WarehouseResponse:
return WarehouseResponse.model_validate(warehouse)
class MasterDataService:
"""客户 / 供应商 / 仓库主数据服务"""
@staticmethod
async def list_customers(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
) -> List[CustomerResponse]:
query: Select = select(Customer).where(Customer.is_active == True)
if search:
query = query.where(Customer.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
result = await db_session.execute(query)
return [_build_customer_response(customer) for customer in result.scalars().all()]
@staticmethod
async def create_customer(
db_session: AsyncSession,
customer_data: CustomerCreate,
current_user: User,
) -> CustomerResponse:
data = customer_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("C")
customer = Customer(**data)
db_session.add(customer)
await db_session.commit()
await db_session.refresh(customer)
return _build_customer_response(customer)
@staticmethod
async def update_customer(
db_session: AsyncSession,
customer_id: int,
customer_data: CustomerCreate,
current_user: User,
) -> CustomerResponse:
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
for key, value in customer_data.model_dump().items():
setattr(customer, key, value)
await db_session.commit()
await db_session.refresh(customer)
return _build_customer_response(customer)
@staticmethod
async def delete_customer(
db_session: AsyncSession,
customer_id: int,
current_user: User,
) -> dict:
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
customer.is_active = False
await db_session.commit()
return {"message": "客户已删除"}
@staticmethod
async def list_suppliers(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
) -> List[SupplierResponse]:
query: Select = select(Supplier).where(Supplier.is_active == True)
if search:
query = query.where(Supplier.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
result = await db_session.execute(query)
return [_build_supplier_response(supplier) for supplier in result.scalars().all()]
@staticmethod
async def create_supplier(
db_session: AsyncSession,
supplier_data: SupplierCreate,
current_user: User,
) -> SupplierResponse:
data = supplier_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("S")
supplier = Supplier(**data)
db_session.add(supplier)
await db_session.commit()
await db_session.refresh(supplier)
return _build_supplier_response(supplier)
@staticmethod
async def update_supplier(
db_session: AsyncSession,
supplier_id: int,
supplier_data: SupplierCreate,
current_user: User,
) -> SupplierResponse:
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
for key, value in supplier_data.model_dump().items():
setattr(supplier, key, value)
await db_session.commit()
await db_session.refresh(supplier)
return _build_supplier_response(supplier)
@staticmethod
async def delete_supplier(
db_session: AsyncSession,
supplier_id: int,
current_user: User,
) -> dict:
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
supplier.is_active = False
await db_session.commit()
return {"message": "供应商已删除"}
@staticmethod
async def list_warehouses(
db_session: AsyncSession,
) -> List[WarehouseResponse]:
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
)
return [_build_warehouse_response(warehouse) for warehouse in result.scalars().all()]
@staticmethod
async def create_warehouse(
db_session: AsyncSession,
warehouse_data: WarehouseCreate,
current_user: User,
) -> WarehouseResponse:
data = warehouse_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("W")
warehouse = Warehouse(**data)
db_session.add(warehouse)
await db_session.commit()
await db_session.refresh(warehouse)
return _build_warehouse_response(warehouse)
master_data_service = MasterDataService()
+284
View File
@@ -0,0 +1,284 @@
"""物料管理业务服务层
将 material_routes 中的价格历史、价格趋势、物料供应商关联等业务编排下沉到此,
路由层只做参数校验与响应组装。
"""
from typing import List
from fastapi import HTTPException
from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from inventory.models import MaterialPriceHistory, MaterialSupplier, Product, Supplier
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialPriceTrendResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
PriceHistoryItem,
)
async def _get_material_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
return product
async def _get_active_supplier(db_session: AsyncSession, supplier_id: int, detail: str = "供应商不存在") -> Supplier:
result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400 if detail == "供应商不存在" else 404, detail=detail)
return supplier
def _build_price_history_response(
product: Product,
price_history: MaterialPriceHistory,
supplier_name: str | None,
) -> MaterialPriceHistoryResponse:
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=supplier_name,
remark=price_history.remark,
created_at=price_history.created_at,
)
def _build_material_supplier_response(
product: Product,
material_supplier: MaterialSupplier,
supplier_name: str | None,
) -> MaterialSupplierResponse:
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier_name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at,
)
class MaterialService:
"""物料价格与供应商关联服务"""
@staticmethod
async def add_price_history(
db_session: AsyncSession,
product_id: int,
price_data: MaterialPriceHistoryCreate,
current_user: User,
) -> MaterialPriceHistoryResponse:
product = await _get_material_product(db_session, product_id)
supplier_name = None
if price_data.supplier_id:
supplier = await _get_active_supplier(db_session, price_data.supplier_id)
supplier_name = supplier.name
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark,
)
db_session.add(price_history)
product.cost_price = price_data.price
await db_session.commit()
await db_session.refresh(price_history)
return _build_price_history_response(product, price_history, supplier_name)
@staticmethod
async def get_price_history(
db_session: AsyncSession,
product_id: int,
limit: int,
) -> List[MaterialPriceHistoryResponse]:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory, Supplier)
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
return [
_build_price_history_response(product, ph, supplier.name if supplier else None)
for ph, supplier in result.all()
]
@staticmethod
async def get_price_trend(
db_session: AsyncSession,
product_id: int,
months: int,
) -> MaterialPriceTrendResponse:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory, Supplier)
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
rows = result.all()
price_history_list = [ph for ph, _supplier in rows]
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
PriceHistoryItem(
date=ph.effective_date,
price=ph.price,
supplier_name=supplier.name if supplier else None,
)
for ph, supplier in rows
],
)
@staticmethod
async def add_material_supplier(
db_session: AsyncSession,
product_id: int,
supplier_data: MaterialSupplierCreate,
current_user: User,
) -> MaterialSupplierResponse:
product = await _get_material_product(db_session, product_id)
supplier = await _get_active_supplier(db_session, supplier_data.supplier_id)
existing = await db_session.execute(
select(MaterialSupplier).where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
if supplier_data.is_primary:
await db_session.execute(
update(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity,
)
db_session.add(material_supplier)
await db_session.commit()
await db_session.refresh(material_supplier)
return _build_material_supplier_response(product, material_supplier, supplier.name)
@staticmethod
async def get_material_suppliers(
db_session: AsyncSession,
product_id: int,
) -> List[MaterialSupplierResponse]:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier, Supplier)
.outerjoin(Supplier, MaterialSupplier.supplier_id == Supplier.id)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
return [
_build_material_supplier_response(product, ms, supplier.name if supplier else None)
for ms, supplier in result.all()
]
@staticmethod
async def remove_material_supplier(
db_session: AsyncSession,
supplier_id: int,
current_user: User,
) -> dict:
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.commit()
return {"message": "物料供应商关联已删除"}
@staticmethod
async def get_supplier_materials(
db_session: AsyncSession,
supplier_id: int,
) -> List[MaterialSupplierResponse]:
supplier = await _get_active_supplier(db_session, supplier_id, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier, Product)
.outerjoin(Product, MaterialSupplier.product_id == Product.id)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku if product else None,
product_name=product.name if product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at,
)
for ms, product in result.all()
]
material_service = MaterialService()
+317
View File
@@ -0,0 +1,317 @@
"""产品管理业务服务层
将 product_routes 中不跨模块的产品 CRUD / BOM 编排下沉到此,
路由层只做参数校验与响应组装。
"""
from decimal import Decimal
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from moldinsight.models import ProcessingTask, STPFile
from inventory.models import Product, ProductMaterial
from ..schemas import (
ProductBOMResponse,
ProductBOMUpdate,
ProductCreate,
ProductMaterialItemResponse,
ProductResponse,
)
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, Decimal]:
if not product_ids:
return {}
result = await db_session.execute(
select(
ProductMaterial.finished_product_id,
func.coalesce(
func.sum(Product.cost_price * ProductMaterial.quantity),
0,
),
)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(product_ids))
.group_by(ProductMaterial.finished_product_id)
)
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
return ProductResponse(
id=product.id,
sku=product.sku,
name=product.name,
description=product.description,
category=product.category,
unit=product.unit,
item_type=product.item_type,
cost_price=Decimal(str(product.cost_price or 0)),
sale_price=Decimal(str(product.sale_price or 0)),
min_stock=product.min_stock,
max_stock=product.max_stock,
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
is_active=product.is_active,
created_at=product.created_at,
)
async def _get_product_or_404(db_session: AsyncSession, product_id: int, active_only: bool = False) -> Product:
query = select(Product).where(Product.id == product_id)
if active_only:
query = query.where(Product.is_active == True)
result = await db_session.execute(query)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
return product
def _validate_item_type(item_type: str) -> None:
if item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
async def _build_bom_response(db_session: AsyncSession, product: Product) -> ProductBOMResponse:
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id == product.id)
.order_by(ProductMaterial.id.asc())
)
items: List[ProductMaterialItemResponse] = []
total_material_cost = Decimal("0")
for bom, material in bom_result.all():
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
total_material_cost += line_cost
items.append(
ProductMaterialItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
quantity=Decimal(str(bom.quantity)),
unit_cost=Decimal(str(material.cost_price or 0)),
line_cost=line_cost,
)
)
return ProductBOMResponse(
product_id=product.id,
product_name=product.name,
total_material_cost=total_material_cost,
items=items,
)
class ProductService:
"""产品 CRUD 与 BOM 服务"""
@staticmethod
async def list_products(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
category: Optional[str],
item_type: Optional[str],
) -> List[ProductResponse]:
query = select(Product).where(Product.is_active == True)
if search:
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
if category:
query = query.where(Product.category == category)
if item_type:
query = query.where(Product.item_type == item_type)
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db_session.execute(query)
products = result.scalars().all()
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
@staticmethod
async def create_product(
db_session: AsyncSession,
product_data: ProductCreate,
current_user: User,
) -> ProductResponse:
_validate_item_type(product_data.item_type)
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.model_dump()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
product = Product(**product_dict)
db_session.add(product)
await db_session.commit()
await db_session.refresh(product)
return _build_product_response(product, 0)
@staticmethod
async def create_product_from_task(
db_session: AsyncSession,
task_id: str,
current_user: User,
) -> ProductResponse:
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="分析任务不存在")
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
stp_file = stp_result.scalar_one_or_none()
if not stp_file:
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
if stp_file.product_id:
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
product = existed.scalar_one_or_none()
if product:
return _build_product_response(product, 0)
base_sku = f"MI{stp_file.id}"
sku = base_sku
n = 1
while True:
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
if not conflict.scalar_one_or_none():
break
n += 1
sku = f"{base_sku}-{n}"
name = Path(stp_file.original_filename or f"mold_{stp_file.id}").stem or f"模具分析-{stp_file.id}"
desc_parts = []
if stp_file.volume:
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
if stp_file.product_weight:
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
if stp_file.surface_area:
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
product = Product(
sku=sku,
name=name,
description=description,
category="模具成品",
unit="件",
item_type="finished",
cost_price=0,
sale_price=0,
min_stock=0,
max_stock=0,
)
db_session.add(product)
await db_session.flush()
stp_file.product_id = product.id
await db_session.commit()
await db_session.refresh(product)
return _build_product_response(product, 0)
@staticmethod
async def update_product(
db_session: AsyncSession,
product_id: int,
product_data: ProductCreate,
current_user: User,
) -> ProductResponse:
product = await _get_product_or_404(db_session, product_id)
_validate_item_type(product_data.item_type)
conflict = await db_session.execute(
select(Product).where(Product.sku == product_data.sku, Product.id != product_id)
)
if conflict.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.model_dump()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
for key, value in product_dict.items():
setattr(product, key, value)
await db_session.commit()
await db_session.refresh(product)
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
return _build_product_response(product, material_cost_map.get(product.id, 0))
@staticmethod
async def delete_product(
db_session: AsyncSession,
product_id: int,
current_user: User,
) -> dict:
product = await _get_product_or_404(db_session, product_id)
product.is_active = False
await db_session.commit()
return {"message": "产品已删除"}
@staticmethod
async def get_product_bom(
db_session: AsyncSession,
product_id: int,
) -> ProductBOMResponse:
product = await _get_product_or_404(db_session, product_id, active_only=True)
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
return await _build_bom_response(db_session, product)
@staticmethod
async def replace_product_bom(
db_session: AsyncSession,
product_id: int,
payload: ProductBOMUpdate,
current_user: User,
) -> ProductBOMResponse:
product = await _get_product_or_404(db_session, product_id, active_only=True)
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
material_ids = [item.material_id for item in payload.items]
if len(material_ids) != len(set(material_ids)):
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
if material_ids:
material_result = await db_session.execute(
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
)
materials = material_result.scalars().all()
material_map = {m.id: m for m in materials}
if len(material_map) != len(material_ids):
raise HTTPException(status_code=400, detail="存在无效物料")
invalid_materials = [m.name for m in materials if m.item_type != "material"]
if invalid_materials:
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
for item in payload.items:
if item.quantity <= 0:
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
for item in payload.items:
db_session.add(
ProductMaterial(
finished_product_id=product_id,
material_product_id=item.material_id,
quantity=item.quantity,
loss_rate=item.loss_rate,
)
)
await db_session.commit()
return await _build_bom_response(db_session, product)
product_service = ProductService()
+14
View File
@@ -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],
)
+10 -1
View File
@@ -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"]
+4
View File
@@ -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"],
+48 -3
View File
@@ -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],
+2
View File
@@ -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()
+106 -42
View File
@@ -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)
+9
View File
@@ -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:
+3 -19
View File
@@ -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
+32 -14
View File
@@ -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
View File
@@ -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:
+4 -7
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
from datetime import timedelta
from sqlalchemy import select
@@ -31,8 +31,7 @@ class UserResponse(BaseModel):
is_superuser: bool = False
roles: List[str]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
@@ -60,8 +59,7 @@ class RoleResponse(BaseModel):
is_system: bool
permissions: List[str]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PermissionCreate(BaseModel):
@@ -78,8 +76,7 @@ class PermissionResponse(BaseModel):
module: Optional[str]
description: Optional[str]
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class UserCreate(BaseModel):
+8 -4
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, ExpiredSignatureError, jwt
import bcrypt
@@ -48,9 +48,9 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, _require_secret_key(), algorithm=settings.ALGORITHM)
return encoded_jwt
@@ -142,7 +142,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
View File
@@ -20,9 +20,10 @@ from inventory.api import inventory_router
from shared.models.base import Base
from shared.models.identity import User
from inventory.models import Customer, Warehouse, Supplier, Product, ProductMaterial, Inventory, MaterialSupplier, SalesOrder, SalesOrderItem
from moldinsight.models import STPFile, ProcessingTask
import moldinsight.models # noqa: F401 # 全量注册:create_all 需含 moldinsight 表(stp_files 等)
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.services.auth_service import get_current_active_user, get_current_admin_user
@pytest.fixture(scope="session")
@@ -133,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:
+39
View File
@@ -0,0 +1,39 @@
import pytest
@pytest.mark.anyio
async def test_dashboard_returns_seeded_summary(client):
resp = await client.get("/api/dashboard")
assert resp.status_code == 200
body = resp.json()
assert body["material_count"] == 1
assert body["finished_product_count"] == 2
assert body["supplier_count"] == 1
assert body["customer_count"] == 1
assert body["warehouse_count"] == 1
assert body["total_stock"] == 1000
assert float(body["total_value"]) == 10000.0
assert body["pending_purchase"] == 0
assert body["pending_sales"] == 0
assert body["low_stock_products"] == []
@pytest.mark.anyio
async def test_dashboard_low_stock_products_are_listed(client):
inventory = await client.get("/api/inventory")
assert inventory.status_code == 200
inv_id = next(row["id"] for row in inventory.json()["items"] if row["product_sku"] == "MAT-001")
updated = await client.put(
f"/api/inventory/{inv_id}",
json={"quantity": 0, "locked_quantity": 0},
)
assert updated.status_code == 200
resp = await client.get("/api/dashboard")
assert resp.status_code == 200
low_stock = resp.json()["low_stock_products"]
assert len(low_stock) == 1
assert low_stock[0]["sku"] == "MAT-001"
assert low_stock[0]["quantity"] == 0
assert low_stock[0]["min_stock"] == 0
+180
View File
@@ -0,0 +1,180 @@
import pytest
@pytest.mark.anyio
async def test_customer_list_search_filters_active_rows(client):
create_resp = await client.post(
"/api/customers",
json={"name": "目标客户Alpha", "contact_person": "张三"},
)
assert create_resp.status_code == 201
customer_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/customers/{customer_id}")
assert delete_resp.status_code == 200
kept_resp = await client.post(
"/api/customers",
json={"name": "目标客户Beta", "contact_person": "李四"},
)
assert kept_resp.status_code == 201
resp = await client.get("/api/customers", params={"search": "目标客户"})
assert resp.status_code == 200
rows = resp.json()
names = [row["name"] for row in rows]
assert "目标客户Beta" in names
assert "目标客户Alpha" not in names
@pytest.mark.anyio
async def test_customer_create_auto_generates_code(client):
resp = await client.post(
"/api/customers",
json={"name": "自动编码客户", "phone": "123456"},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"].startswith("C")
assert body["name"] == "自动编码客户"
@pytest.mark.anyio
async def test_customer_update_returns_latest_fields(client):
create_resp = await client.post(
"/api/customers",
json={"name": "旧客户名", "email": "old@example.com"},
)
assert create_resp.status_code == 201
customer_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/customers/{customer_id}",
json={"code": "C-CUSTOM", "name": "新客户名", "email": "new@example.com"},
)
assert update_resp.status_code == 200
body = update_resp.json()
assert body["code"] == "C-CUSTOM"
assert body["name"] == "新客户名"
assert body["email"] == "new@example.com"
@pytest.mark.anyio
async def test_customer_delete_soft_deletes_row(client):
create_resp = await client.post(
"/api/customers",
json={"name": "待删除客户"},
)
assert create_resp.status_code == 201
customer_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/customers/{customer_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["message"] == "客户已删除"
list_resp = await client.get("/api/customers", params={"search": "待删除客户"})
assert list_resp.status_code == 200
assert all(row["id"] != customer_id for row in list_resp.json())
@pytest.mark.anyio
async def test_supplier_list_search_filters_active_rows(client):
create_resp = await client.post(
"/api/suppliers",
json={"name": "目标供应商Alpha", "contact_person": "王五"},
)
assert create_resp.status_code == 201
supplier_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/suppliers/{supplier_id}")
assert delete_resp.status_code == 200
kept_resp = await client.post(
"/api/suppliers",
json={"name": "目标供应商Beta", "contact_person": "赵六"},
)
assert kept_resp.status_code == 201
resp = await client.get("/api/suppliers", params={"search": "目标供应商"})
assert resp.status_code == 200
rows = resp.json()
names = [row["name"] for row in rows]
assert "目标供应商Beta" in names
assert "目标供应商Alpha" not in names
@pytest.mark.anyio
async def test_supplier_create_auto_generates_code(client):
resp = await client.post(
"/api/suppliers",
json={"name": "自动编码供应商", "phone": "123456"},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"].startswith("S")
assert body["name"] == "自动编码供应商"
@pytest.mark.anyio
async def test_supplier_update_returns_latest_fields(client):
create_resp = await client.post(
"/api/suppliers",
json={"name": "旧供应商名", "email": "old-s@example.com"},
)
assert create_resp.status_code == 201
supplier_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/suppliers/{supplier_id}",
json={"code": "S-CUSTOM", "name": "新供应商名", "email": "new-s@example.com"},
)
assert update_resp.status_code == 200
body = update_resp.json()
assert body["code"] == "S-CUSTOM"
assert body["name"] == "新供应商名"
assert body["email"] == "new-s@example.com"
@pytest.mark.anyio
async def test_supplier_delete_soft_deletes_row(client):
create_resp = await client.post(
"/api/suppliers",
json={"name": "待删除供应商"},
)
assert create_resp.status_code == 201
supplier_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/suppliers/{supplier_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["message"] == "供应商已删除"
list_resp = await client.get("/api/suppliers", params={"search": "待删除供应商"})
assert list_resp.status_code == 200
assert all(row["id"] != supplier_id for row in list_resp.json())
@pytest.mark.anyio
async def test_warehouse_list_orders_default_first(client):
create_resp = await client.post(
"/api/warehouses",
json={"code": "W002", "name": "普通仓库"},
)
assert create_resp.status_code == 201
resp = await client.get("/api/warehouses")
assert resp.status_code == 200
rows = resp.json()
assert rows[0]["is_default"] is True
assert rows[0]["name"] == "默认仓库"
@pytest.mark.anyio
async def test_warehouse_create_auto_generates_code(client):
resp = await client.post(
"/api/warehouses",
json={"name": "自动编码仓库", "manager": "管理员A"},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"].startswith("W")
assert body["name"] == "自动编码仓库"
+100
View File
@@ -0,0 +1,100 @@
import pytest
@pytest.mark.anyio
async def test_material_price_history_create_updates_latest_cost(client):
resp = await client.post(
"/api/materials/1/price-history",
json={"price": 12.5, "supplier_id": 1, "remark": "latest"},
)
assert resp.status_code == 201
body = resp.json()
assert body["product_id"] == 1
assert body["price"] == 12.5
assert body["supplier_name"] == "供应商A"
history = await client.get("/api/materials/1/price-history")
assert history.status_code == 200
assert history.json()[0]["price"] == 12.5
@pytest.mark.anyio
async def test_material_price_trend_returns_change_summary(client):
resp1 = await client.post(
"/api/materials/1/price-history",
json={"price": 10.0, "supplier_id": 1},
)
assert resp1.status_code == 201
resp2 = await client.post(
"/api/materials/1/price-history",
json={"price": 15.0, "supplier_id": 1},
)
assert resp2.status_code == 201
trend = await client.get("/api/materials/1/price-trend", params={"months": 6})
assert trend.status_code == 200
body = trend.json()
prices = [item["price"] for item in body["price_history"]]
assert sorted(prices) == [10.0, 15.0]
assert body["current_price"] in {10.0, 15.0}
assert body["price_change"] in {5.0, -5.0}
assert body["price_change_percent"] in {50.0, -33.33}
assert len(body["price_history"]) == 2
@pytest.mark.anyio
async def test_material_price_trend_requires_history(client):
resp = await client.get("/api/materials/1/price-trend")
assert resp.status_code == 404
@pytest.mark.anyio
async def test_add_material_supplier_and_list_by_material(client):
resp = await client.post(
"/api/materials/1/suppliers",
json={"supplier_id": 1, "is_primary": True, "lead_time": 7, "min_order_quantity": 10},
)
assert resp.status_code == 400 # seeded_db 已存在主关联
@pytest.mark.anyio
async def test_get_material_suppliers_and_supplier_materials(client):
by_material = await client.get("/api/materials/1/suppliers")
assert by_material.status_code == 200
material_rows = by_material.json()
assert len(material_rows) == 1
assert material_rows[0]["supplier_id"] == 1
assert material_rows[0]["supplier_name"] == "供应商A"
by_supplier = await client.get("/api/materials/suppliers/1/materials")
assert by_supplier.status_code == 200
supplier_rows = by_supplier.json()
assert len(supplier_rows) == 1
assert supplier_rows[0]["product_id"] == 1
assert supplier_rows[0]["product_sku"] == "MAT-001"
@pytest.mark.anyio
async def test_remove_material_supplier_deletes_association(client):
resp = await client.delete("/api/materials/suppliers/1")
assert resp.status_code == 200
assert resp.json()["message"] == "物料供应商关联已删除"
by_material = await client.get("/api/materials/1/suppliers")
assert by_material.status_code == 200
assert by_material.json() == []
@pytest.mark.anyio
@pytest.mark.parametrize(
"url,payload,expected",
[
("/api/materials/2/price-history", {"price": 10.0}, 400),
("/api/materials/99999/price-history", {"price": 10.0}, 404),
("/api/materials/1/price-history", {"price": 10.0, "supplier_id": 99999}, 400),
("/api/materials/1/suppliers", {"supplier_id": 99999}, 400),
],
)
async def test_material_endpoints_validation(url, payload, expected, client):
resp = await client.post(url, json=payload)
assert resp.status_code == expected
+176
View File
@@ -0,0 +1,176 @@
import pytest
@pytest.mark.anyio
async def test_list_products_returns_material_cost_for_finished_goods(client):
resp = await client.get("/api/products")
assert resp.status_code == 200
rows = resp.json()
finished = next(row for row in rows if row["sku"] == "MOLD-STD")
assert float(finished["material_cost"]) == 20.0
@pytest.mark.anyio
async def test_create_product_finished_resets_stock_bounds(client):
resp = await client.post(
"/api/products",
json={
"sku": "FG-NEW",
"name": "新成品",
"item_type": "finished",
"unit": "件",
"min_stock": 10,
"max_stock": 99,
"cost_price": 12,
"sale_price": 20,
},
)
assert resp.status_code == 201
body = resp.json()
assert body["sku"] == "FG-NEW"
assert body["min_stock"] == 0
assert body["max_stock"] == 0
@pytest.mark.anyio
async def test_update_product_rejects_duplicate_sku(client):
created = await client.post(
"/api/products",
json={
"sku": "FG-2",
"name": "成品2",
"item_type": "finished",
"unit": "件",
"cost_price": 0,
"sale_price": 1,
},
)
assert created.status_code == 201
product_id = created.json()["id"]
resp = await client.put(
f"/api/products/{product_id}",
json={
"sku": "MOLD-STD",
"name": "改名",
"item_type": "finished",
"unit": "件",
"cost_price": 0,
"sale_price": 1,
},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "SKU已存在"
@pytest.mark.anyio
async def test_delete_product_soft_deletes_record(client):
created = await client.post(
"/api/products",
json={
"sku": "MAT-DEL",
"name": "待删物料",
"item_type": "material",
"unit": "kg",
"cost_price": 2,
"sale_price": 0,
},
)
assert created.status_code == 201
product_id = created.json()["id"]
deleted = await client.delete(f"/api/products/{product_id}")
assert deleted.status_code == 200
assert deleted.json()["message"] == "产品已删除"
listed = await client.get("/api/products")
assert listed.status_code == 200
assert all(row["id"] != product_id for row in listed.json())
@pytest.mark.anyio
async def test_get_product_bom_returns_line_costs(client):
resp = await client.get("/api/products/2/materials")
assert resp.status_code == 200
body = resp.json()
assert body["product_id"] == 2
assert float(body["total_material_cost"]) == 20.0
assert len(body["items"]) == 1
assert body["items"][0]["material_sku"] == "MAT-001"
@pytest.mark.anyio
async def test_replace_product_bom_rewrites_items(client):
created = await client.post(
"/api/products",
json={
"sku": "MAT-002",
"name": "铝材",
"item_type": "material",
"unit": "kg",
"cost_price": 5,
"sale_price": 0,
},
)
assert created.status_code == 201
material_id = created.json()["id"]
replaced = await client.put(
"/api/products/2/materials",
json={
"items": [
{"material_id": 1, "quantity": 1, "loss_rate": 0},
{"material_id": material_id, "quantity": 2, "loss_rate": 0.1},
]
},
)
assert replaced.status_code == 200
body = replaced.json()
assert len(body["items"]) == 2
assert float(body["total_material_cost"]) == 20.0
@pytest.mark.anyio
@pytest.mark.parametrize(
"url,payload,expected",
[
("/api/products", {"sku": "BAD", "name": "bad", "item_type": "unknown", "unit": "件", "cost_price": 0, "sale_price": 0}, 400),
("/api/products/2/materials", {"items": [{"material_id": 1, "quantity": 1}, {"material_id": 1, "quantity": 2}]}, 400),
("/api/products/2/materials", {"items": [{"material_id": 99999, "quantity": 1}]}, 400),
("/api/products/2/materials", {"items": [{"material_id": 2, "quantity": 1}]}, 400),
("/api/products/2/materials", {"items": [{"material_id": 1, "quantity": 0}]}, 400),
("/api/products/1/materials", {"items": []}, 400),
],
)
async def test_product_service_validation_paths(url, payload, expected, client):
method = client.post if url == "/api/products" else client.put
resp = await method(url, json=payload)
assert resp.status_code == expected
@pytest.mark.anyio
async def test_create_product_from_task_creates_and_binds_product(client):
resp = await client.post("/api/products/from-task/task-demo-1")
assert resp.status_code == 201
body = resp.json()
assert body["sku"] == "MI1"
assert body["name"] == "demo-mold"
assert body["category"] == "模具成品"
assert body["item_type"] == "finished"
assert body["min_stock"] == 0
assert body["max_stock"] == 0
assert "由模具分析创建" in body["description"]
again = await client.post("/api/products/from-task/task-demo-1")
assert again.status_code == 201
assert again.json()["id"] == body["id"]
@pytest.mark.anyio
@pytest.mark.parametrize(
"task_id,expected",
[("missing-task", 404)],
)
async def test_create_product_from_task_validation(task_id, expected, client):
resp = await client.post(f"/api/products/from-task/{task_id}")
assert resp.status_code == expected
+417
View File
@@ -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
+319
View File
@@ -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
+16
View File
@@ -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)
# ── 未命中与防护 ────────────────────────────────────────────────
+105
View File
@@ -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 以引导锁文件生成流程"
)
+5 -3
View File
@@ -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
+46
View File
@@ -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"