Compare commits

..

57 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
cjw e728dcd226 批次4后续专项完成:D11清偿 + OCC方案B实施 + 部署参数 + D2诚实标注 + CI门禁
① D11 HTML 报告 RustFS 单源化(TECH_DEBT P2 清偿):可视化产物写任务临时目录后
   裸传报告键 html/reports/{filename}(文件名寻址),/html StaticFiles 挂载删除,
   新增 html_report_router 根路径代理(报告键→遗留 JSON 包装→本地卷兜底→404,
   防穿越);URL 形状 /html/{filename} 不变,持久化引用零迁移;celery 摘除
   html_data 卷,镜像不再烤入陈旧报告;顺带删除 get_stp_file_with_data 死数据块
② OCC 方案 B(D10 清偿):run_occ(op_name, payload) 契约 + 常驻工作进程池
   (occ_process_pool + occ_worker 操作注册表),超时/崩溃 terminate 换新补位、
   任务级超时 recover 整体重建,残留线程泄漏根治;TopoDS 不跨进程(generate_cavity
   分模 + 方案 STEP 持久化全在子进程内,返回 export_manifest);删除内存形状缓存链、
   CADExporter.export_mold_results、shape_loader(→ stp_materializer)
③ OCC 方案 A 部署参数:CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 进
   Dockerfile.celery + compose + .env.example
④ D2 诚实标注:铝价响应带 source: "simulated",前端按来源渲染标注(原硬编码
   "上海期货交易所"属虚假声明),死代码 getAluminumPrice 删除
⑤ CI 门禁:.gitea/workflows/ci.yml 三 job(pytest / 前端构建含 vue-tsc /
   openapi 漂移检测)

接口变更三件套随批完成(openapi 76→77 paths + gen:api + 前端构建通过;方案 B
接口面零变化)。测试基线 143 passed, 0 skipped(新增 16 项)。文档六处同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:22:01 +08:00
cjw 0e6b3b1811 后端设计治理:批次 0-4 全部完成(安全/部署/一致性/结构/架构)
按 ROADMAP §3.1 治理批次推进的后端设计审查整改:

- 批次 0(安全):/api/status/{task_id} 补 JWT 鉴权与任务归属校验;
  pythonocc_available 真实探测;bcrypt 超 72 字节显式拒绝;
  SECRET_KEY/RUSTFS_* 惰性校验,代码侧弱默认移除
- 批次 1(部署正确性):主处理链路改走 RustFS(分派入参 stp_file_id 化,
  worker 按 object_key 下载);AUTO_MIGRATE 开关 + 迁移目录 alembic/→migrations/
  修复包遮蔽(自动迁移此前从未真正生效);OCC 镜像改 conda 原生执行 +
  基础镜像 tag 锁定;compose 关键项改 ${VAR:?} 强制显式配置
- 批次 2(任务一致性):删除 Redis 进程内存回退,PG 为任务状态单一事实源;
  批量元数据入库(processing_tasks.batch_id,迁移 a3f8c2d91e47);
  型腔失败任务标 failed 不再静默 completed;事务边界收口
  (数据本体写 flush-only、失败先回滚再置 failed、进度更新保留即时 commit)
- 批次 3(API 与代码结构):592 行 advanced_router 拆为 design/cost/machining/
  export 四子路由,请求体全量 Pydantic 化;ROUTE_MODULES + route_registry
  (/api/health 呈现 degraded,DEBUG fail fast);纯计算端点统一 to_thread;
  StorageIntegrationService 按职责三拆;MAX_FILE_SIZE 接线生效、
  celery 复用 Settings.redis_url;管理员重置密码改 JSON body(端到端断裂修复);
  openapi.json 重导出(76 paths)+ 前端 gen:api
- 批次 4(架构演进):共享 ORM 按模块拆分(shared/models/base.py + identity.py、
  moldinsight/models/、inventory/models/,删除三条无使用方的跨模块
  relationship,跨模块桥接收敛为裸 FK 硬规则,无兼容 facade);
  OCC executor 重建补 cancel_futures=True(消除旧队列被慢恢复线程
  并行消化的数据竞争);OCC 吞吐方案设计先行
  (docs/topics/performance/OCC_THROUGHPUT.md);顺手清偿 D15
  (vite.config.ts 未用参数致 npm run build 失败)

测试基线:125 passed, 2 skipped(pytest + sqlite+aiosqlite;归属边界、
路由契约、配置治理、鉴权回归等随批新增)
文档同步:STATUS / TECH_DEBT / ROADMAP / ARCHITECTURE / API_CONTRACT /
OPERATIONS / AGENTS

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:15:49 +08:00
cjw 4537faf2c4 优化 2026-09-16 17:55:04 +08:00
cjw 3f120417d1 文档 2026-09-15 18:07:33 +08:00
cjw 6464e89942 文档 2026-09-15 18:02:25 +08:00
cjw 4fbff0761a x 2026-09-02 18:18:42 +08:00
cjw 5e531ffe1e 文档目录结构简洁化 2026-09-01 18:05:18 +08:00
cjw bee439cf34 x 2026-08-31 18:01:34 +08:00
cjw 3ea59551db 模块拆分 init 2026-08-27 14:53:22 +08:00
cjw e6ddea33a2 fix(frontend): 移除 openapi-typescript 依赖,解决 TS6 peer 冲突导致服务器 npm install 失败
- openapi-typescript 仅用于本地 gen:api 生成类型,不参与构建,服务器不需要
- gen:api 改用 npx --yes openapi-typescript@7.13.0 按需拉取
- 服务器 npm install 不再需要 --legacy-peer-deps
2026-08-04 10:13:08 +08:00
cjw 3b5f70f1f3 chore(deploy): 移除仓库内 deploy.sh,避免与服务器钩子脚本同名冲突 2026-08-03 17:08:45 +08:00
cjw eab4137c0a fix(frontend)+chore(deploy): 修复明细表格渲染 + static/ 改服务器构建
- SalesOrdersTab/PurchaseOrdersTab/MaterialsTab/ProductsTab: t-table 明细行不渲染(push/splice 改新数组引用)+ Decimal 算术类型错误(Number() 包裹)
- static/ 不再进 git(.gitignore),改为 deploy.sh 在服务器 npm run build
- 新增 deploy.sh:拉取 + 构建前端 + 重启后端
2026-08-03 16:12:34 +08:00
cjw 4ec6f98f10 x 2026-07-31 16:55:52 +08:00
cjw 2f811b986c fix(deploy): 恢复跟踪 .env,修复服务器 git pull 后无 .env 导致服务无法启动 2026-07-31 16:07:44 +08:00
cjw 5016458e44 x 2026-07-31 15:57:13 +08:00
cjw a330757946 xx 2026-07-30 11:01:00 +08:00
cjw 853c478657 xxx 2026-07-30 10:30:50 +08:00
cjw cf6d708566 xxx 2026-07-27 15:54:25 +08:00
cjw a8c0e1af3d docs: 更新演进路线图(P1-2 注册表 + P1-4 Alembic 进度)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 11:05:03 +08:00
cjw 00ca81287e feat(db): 引入 Alembic 取代裸 DDL 迁移
- alembic init + env.py 接 settings/models metadata(asyncpg->psycopg2 同步 URL)
- 补 3 个 CheckConstraint 到 models(原只在裸 DDL)
- 离线生成初始迁移(31 表+约束+95 索引,全 sa.* 通用类型)
- init_db 用 _run_alembic_migrations(自动基线+upgrade head)替换 create_tables+ensure_schema_updates(删 92 行裸 DDL)
- 删破坏性 migrate_db.py(drop_all)
- 既有 DB 首次启动自动 stamp 基线,无需手动

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 11:04:23 +08:00
cjw 66ba6e00f5 refactor(moldinsight): 引入可插拔注册表(MoldGenerator + FeatureDetector)
- 新增 MoldGeneratorRegistry:multi_scheme_planner 消除 if-else,按 mold_type 选生成器
- 新增 FeatureDetectorRegistry:geometry_analyzer._detect_features 消除 6 个检测器硬编码
- 新增模具类型/特征检测器只需 register 一行
- 移除 OCC ThreadPoolExecutor(注册表本身串行执行)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 11:03:16 +08:00
cjw f3d2951dc3 x 2026-07-20 10:16:27 +08:00
263 changed files with 42893 additions and 11368 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/
+76
View File
@@ -0,0 +1,76 @@
# 服务配置
HOST=0.0.0.0
# ================================
# 端口配置 - 唯一修改端口的地方
# ================================
# gemold(moldinsight)API 对外端口
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
# ================================
DEBUG=false
# 文件处理配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600
ALLOWED_EXTENSIONS=.stp,.step,.stp.gz
# 几何处理配置
POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high
PARALLEL_PROCESSING=true
# 数据库配置——以部署机实测可达 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://192.168.0.11:8010
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600
# JWT认证配置
SECRET_KEY=vGLxbDGj4I3LnWZYQqRrchlVBSWpM73IZ8fT7ldwpsXpDYB82ghkr7sRkO7D-BiR
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Redis配置(与 DB 同 IP)
REDIS_HOST=192.168.0.11
REDIS_PORT=6379
REDIS_PASSWORD=Qqs1996
REDIS_DB=0
# 管理员配置
ADMIN_USERNAME=cjw
ADMIN_PASSWORD=Qqs1996
ADMIN_EMAIL=792430652@qq.com
ADMIN_FULL_NAME=管理员
# LLM 增强分析配置(可选)
# 启用后自动生成模具设计评审报告和分模方向推荐
# 支持 OpenAI 兼容 API(OpenAI / DeepSeek / vLLM / Ollama 等)
LLM_ENABLED=false
LLM_API_URL=https://api.deepseek.com/v1
LLM_API_KEY=sk-509f968af3e2466bbef8b5949180782c
LLM_MODEL=deepseek-v4-flash
LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000
+37 -10
View File
@@ -1,17 +1,25 @@
# 服务配置
HOST=0.0.0.0
# ================================
# 端口配置 - 唯一修改端口的地方
# 端口配置 — DEPLOYMENT.md §1.2 选择 A 为默认(前端独占宿主端口,backend 不暴露)
# ================================
# 应用端口(容器内端口)
PORT=8000
# Docker映射到宿主机的端口(docker-compose使用)
HOST_PORT=10001
# 容器内部端口统一 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=
# ================================
DEBUG=false
# 日志配置
# LOG_FORMAT: json(生产默认,结构化输出)/ text(开发默认,人可读)
# LOG_LEVEL: DEBUG / INFO / WARNING / ERROR
LOG_FORMAT=json
LOG_LEVEL=INFO
# 文件处理配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600
@@ -22,25 +30,39 @@ POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high
PARALLEL_PROCESSING=true
# 数据库配置
# 数据库配置(服务器已部署,请填写真实地址)
DB_HOST=localhost
DB_PORT=5432
# 是否在应用启动时自动执行 alembic 迁移(默认 true,保持单机开发体验)。
# 多副本/容器编排部署建议设为 false:多个实例同时启动会并发迁移,
# 改由部署流程单点执行 alembic CLI 或 python -m shared.database.init_db
AUTO_MIGRATE=true
DB_NAME=moldinsight
DB_USER=moldinsight_user
DB_PASSWORD=moldinsight_password
# RustFS 对象存储配置 (S3v4 API)
# RustFS / MinIO 兼容对象存储配置(服务器已部署,请填写真实地址)
RUSTFS_ENDPOINT=http://localhost:9000
RUSTFS_ACCESS_KEY=your-access-key
RUSTFS_SECRET_KEY=your-secret-key
RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600
# Redis 配置(服务器已部署,请填写真实地址)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT认证配置
SECRET_KEY=your-secret-key-change-in-production-min-32-chars
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# CORS 白名单(逗号分隔,不设置则默认为 [*],生产环境务必设置)
# CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://localhost:8001,https://your-domain.com
# 管理员账户配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-to-a-secure-password
@@ -61,3 +83,8 @@ LLM_API_KEY=sk-your-api-key
LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000
# Celery/OCC 吞吐调优(可选,默认值在 compose 的 ${CELERY_CONCURRENCY:-2};
# concurrency 即 OCC 并行分析数,见 docs/topics/performance/OCC_THROUGHPUT.md)
# CELERY_CONCURRENCY=2
# CELERY_MAX_TASKS_PER_CHILD=50
+68
View File
@@ -0,0 +1,68 @@
# CI 门禁(Gitea Actions,语法与 GitHub Actions 兼容)
#
# 三个 job:
# 1. backend-tests —— pytest 全量(sqlite+aiosqlite,无外部服务依赖;
# OCC 契约测试在无 pythonocc 的 pip 环境自动 skip)
# 2. frontend-build —— npm ci + npm run build(含 vue-tsc -b 类型检查,D15 已修复)
# 3. openapi-drift —— 用 OCC 环境重导出 openapi.json 与仓库版本比对,
# 防止接口变更三件套(AGENTS §2)被遗漏导致前后端漂移
#
# 运行前提:Gitea 实例启用 Actions 且注册了 runner;
# `ubuntu-latest` label 需映射到带 node/git 的镜像(gitea runner 默认映射满足)。
# 已验证事实:pytest 与 openapi 导出均不依赖 .env(settings 惰性校验)。
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
# requirements.txt 自带阿里云 pip 镜像配置
run: pip install -r requirements.txt
- name: Run tests
run: python -m pytest tests/ -q
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Type check and build
working-directory: frontend
run: npm run build
openapi-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Miniforge and pythonocc
# pythonocc-core 仅经 conda-forge 提供(与 deploy/Dockerfile.moldinsight 同版本约束)
# 默认走 TUNA 镜像(Gitea 服务器多为国内网络);海外环境可换回官方源
run: |
wget -q https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/Miniforge3-Linux-x86_64.sh -O miniforge.sh
bash miniforge.sh -b -p "$HOME/miniforge"
"$HOME/miniforge/bin/conda" create -n ci -c https://mirrors.tuna.tsinghua.edu.cn/conda-forge -y python=3.12 pythonocc-core=7.9.0
- name: Install python dependencies
run: "$HOME/miniforge/envs/ci/bin/pip" install -r requirements.txt
- name: Export openapi.json and compare with committed version
run: |
"$HOME/miniforge/envs/ci/bin/python" -c "import sys; sys.path.insert(0, 'src'); from entrypoints.unified import app; import json; print(json.dumps(app.openapi(), ensure_ascii=False, indent=2))" > /tmp/openapi.json
git diff --exit-code --no-index /tmp/openapi.json openapi.json || {
echo "::error::openapi.json 与代码不一致——请按 docs/API_CONTRACT.md §4 重导出并执行 npm run gen:api"
exit 1
}
+3 -1
View File
@@ -15,7 +15,6 @@ __pycache__/
# 项目临时文件
.DS_Store
*.log
.env
1panel.env
*.env.local
logs/
@@ -24,6 +23,9 @@ logs/
uploads/
html_output/
# 前端构建产物(由 deploy.sh 在部署时 npm run build 生成,不进 git)
static/
# 不再需要在 src/ 下跟踪这些
src/uploads/
src/html_output/
+177
View File
@@ -0,0 +1,177 @@
# AGENTS.md - geMoldInsight 开发规范
> 本文件是给开发 agent(Claude / Codex / …)和协作开发者的入口文档。**开始任何实现前先读这个**,避免重复输入背景。
> 人类入口见 [README.md](README.md);**当前实现状态见 [docs/STATUS.md](docs/STATUS.md)**(本文件不复制状态内容)。
## 1. 项目是什么
**geMoldInsight**:面向模具制造场景的综合系统,围绕 STEP/STP 模型分析、模具方案生成、分析结果沉淀与导出、成品创建、BOM / 库存 / 采购 / 销售闭环展开。
形态:**单仓库 + 单数据库 + 多模块 + 可独立部署的 modular monolith**。
- `moldinsight`:模具分析、几何处理、批量分析、成本估算、CAM、结果导出(Celery 异步链路)
- `inventory`:成品 / 物料 / BOM / 库存 / 采购 / 销售 / 财务
- `frontend`:Vue 3 独立前端工程(与后端同仓不同目录)
- `shared`:配置、数据库、认证、日志、应用工厂等共享平台层
技术栈一句话:FastAPI + SQLAlchemy 2.0 + PostgreSQL + Alembic + Redis + Celery + PythonOCC/trimesh/pyvista + RustFS(MinIO 兼容);前端 Vue 3 + Vite + TypeScript + Pinia + TDesign;OpenAPI → TypeScript 类型生成。
架构与模块边界的详细说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
## 2. 硬约束速览(违反即返工)
- **文档先行**:非微小改动,先把实施方案写入对应文档,再按文档执行;方案变了先改文档再改代码。不能先改代码后补文档。
- **完成需求后必须同步文档**:按 §4.1 的映射表逐项检查,防实现与文档漂移。
- **每类信息只有一个归属文档**:状态只在 [docs/STATUS.md](docs/STATUS.md);架构边界只在 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);规划只在 [docs/ROADMAP.md](docs/ROADMAP.md);技术债只在 [docs/TECH_DEBT.md](docs/TECH_DEBT.md);配置与运行只在 [docs/OPERATIONS.md](docs/OPERATIONS.md);部署入口只在 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md);前后端契约只在 [docs/API_CONTRACT.md](docs/API_CONTRACT.md)。其他文档只链接,不复制。
- **README 只做导航与最短入门**,不维护状态 / 架构 / 规划 / 部署细节。
- **业务代码归属模块**:moldinsight 业务进 `src/moldinsight/`,inventory 业务进 `src/inventory/`;只有真正跨模块复用的基础能力才进 `src/shared/`。不继续把业务逻辑堆进 `shared`。
- **单数据库是刻意设计**: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/ # 独立部署入口(纯组装: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);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 上传
batch_router.py # /api/batch-upload 批量上传与分析
task_router.py # /api/status/{task_id} 任务状态查询
history_router.py # /api/history 分析历史与结果文件
cam_router.py # /api/cam/plan CAM 加工方案(Pydantic 请求模型 + to_thread)
design_router.py # 设计类接口:/optimize-layout /design-* /detect-undercuts(原 advanced_router,D1 拆分)
cost_router.py # /cost-estimate 成本估算(原 advanced_router)
machining_router.py # 加工类接口:/design-cam /check-collision /optimize-toolpath /design-electrodes /simulate-machining
export_router.py # 导出类接口:/export-mold /export-download /export-recommendations
core_modules.py # 核心计算模块惰性装载器(设计/加工路由共用,装载失败 503)
aluminum_price_routes.py # /api/aluminum-price/* 铝价(模拟数据,响应带 source: "simulated",见 TECH_DEBT D2)
html_report_router.py # GET /html/{filename} 报告代理(根路径挂载:RustFS 报告键 → 遗留 JSON 包装 → 本地卷兜底;include_into 由入口调用)
debug_router.py # /api/debug/tasks 全量任务 dump(仅 DEBUG 模式注册,仍需登录)
core/ # 几何与方案核心算法(OCC 重依赖区)
stp_parser.py # STEP/STP 解析
geometry_analyzer.py # 几何分析
mesh_generator.py # 网格生成
base_mold_generator.py # Generator 公共接口(契约测试覆盖)
mold_generator.py # 模具生成
mold_generator_registry.py # 生成器注册表
aluminum_foam_mold.py # 铝泡沫模具方案
feature_detector_registry.py # 特征识别注册表
parting_candidate_generator.py / parting_scheme_scorer.py # 分模候选与评分
multi_scheme_planner.py # 多方案规划
mold_system_designer.py # 模架/浇注等系统设计
side_action_designer.py # 侧向抽芯设计
cavity_layout_optimizer.py # 型腔布局优化
mold_machining.py / mold_cam.py # 加工与 CAM
mold_quality_inspector.py # 质量检查
cad_exporter.py # CAD 导出(export_mold_results 随方案 B 已删;export_step 等供 OCC 子进程持久化/转换)
occ_worker.py # OCC 常驻工作进程入口:操作注册表(parse_stp/generate_mesh/generate_cavity/analyze_mold_design/detect_undercuts/convert_component_step/ping/sleep/warmup)+ worker_main 消息循环
services/ # 业务服务层
task_dispatcher.py # 后台任务统一分派(勿绕过它 fire-and-forget)
task_query_service.py # 任务状态查询聚合
processing_service.py # 分析处理编排(run_occ 经常驻 OCC 进程池调度,见 occ_process_pool.py / OCC_THROUGHPUT.md)
occ_process_pool.py # OCC 常驻工作进程池(方案 B):超时/崩溃 terminate 换新补位;任务级超时 recover 整体重建
calculation_service.py # 计算服务
cost_estimate_service.py # 成本估算
cam_bundle_service.py # CAM 结果打包
verification_service.py # FreeCAD 验证(可选)
stp_materializer.py # 按 task_id 把 STP 原件落盘临时文件(OCC 解析在子进程内,形状不跨进程)
material_service.py # 物料价格服务
aluminum_price_service.py # 铝价服务(模拟数据)
llm_service.py # LLM 增强分析(可选,OpenAI 兼容)
task_storage_service.py # STP 文件与处理任务生命周期存储(D9:数据写 flush-only,状态更新即时 commit)
analysis_storage_service.py # 分析结果数据存储(几何/网格/型腔/HTML/特征)与任务数据视图组装
file_history_service.py # 按文件名聚合的上传历史查询视图
models/ # moldinsight 域 ORM(stp_analysis.py:stp_files 及各阶段产物 + processing_tasks,共 9 表)
storage/
rustfs_storage.py # RustFS/MinIO 客户端封装
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 / 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:纯平台引导(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 # 建表与管理员种子
models/base.py # 唯一 ORM Base + 模型归属约定(跨模块只许裸 FK,禁跨模块 relationship)
models/identity.py # 身份与权限 ORM:User/Role/Permission/UserRole/RolePermission/UserActivity/SystemLog
models/schemas.py # 共享 Pydantic 模型
services/auth_routes.py # /api/auth/* 认证用户角色权限路由
services/auth_service.py # JWT 签发校验 + get_current_active_user 依赖
services/redis_task_manager.py # Redis 任务状态(Hash 字段级原子更新,兼容旧 string)
utils/logger.py # 结构化日志(json/text)+ request_id
utils/file_handler.py # 上传文件处理
utils/html_generator.py # 可视化报告生成(HTML/摘要/数据 JSON;产物写任务临时目录,由 moldinsight 上传 RustFS 报告键)
celery_app.py # Celery app(Redis broker,task_acks_late)
celery_tasks.py # moldinsight 异步分析任务
frontend/ # Vue 3 独立工程:src/modules 按域组织(moldinsight/inventory/users/login/home);src/types/api.ts 为 openapi 生成物,勿手改
migrations/ # 数据库迁移
scripts/ # 一次性迁移与工具脚本(migrations/ 数据迁移、db/ 索引与审计 SQL、tools/ 检查工具),非运行时代码
tests/ # pytest:sqlite+aiosqlite 临时库;pythonocc 缺失时 OCC 契约测试自动 skip
deploy/ # Dockerfile.* / nginx / build 脚本 / generate_lockfiles.{sh,bat}(D13 锁文件生成入口)
docs/ # 权威文档(本文件 §5 导航)
```
## 4. 开发约定
### 4.1 完成需求后的文档映射(改什么 → 同步什么)
| 变化 | 必须同步 |
|---|---|
| 实现状态(完成了什么 / 测试基线变化) | [docs/STATUS.md](docs/STATUS.md)(顶部加日志条目) |
| 模块边界 / 目录结构 / 架构原则 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| 接口路径 / 请求响应模型 / 鉴权 | [docs/API_CONTRACT.md](docs/API_CONTRACT.md) + 重新导出 `openapi.json` + `npm run gen:api` |
| 配置项增删 / 启动方式 / 运维要求 | [docs/OPERATIONS.md](docs/OPERATIONS.md) + [.env.example](.env.example) |
| 部署方式 / Compose / Nginx | [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)(操作细节进 [docs/deployment/LINUX_SETUP.md](docs/deployment/LINUX_SETUP.md)) |
| 计划 / 优先级变化 | [docs/ROADMAP.md](docs/ROADMAP.md) |
| 技术债新增 / 清偿 | [docs/TECH_DEBT.md](docs/TECH_DEBT.md) |
| 阶段性结论 / 旧方案 | 迁入 [docs/archive/](docs/archive/README.md),不留在权威文档 |
### 4.2 测试
- 命令:`pytest tests/ -q`(pytest.ini 已定 `testpaths=tests`,asyncio auto 模式)。
- 测试用 sqlite+aiosqlite 临时库([tests/conftest.py](tests/conftest.py) 自建 fixture),不依赖真实 PostgreSQL/Redis。
- 依赖 pythonocc 的契约测试在无 OCC 环境自动 skip;OCC 全量验证用 conda 环境(参考项目实践:本地 pip 环境 + moldinsight conda/OCC 环境各跑一遍,基线数见 [docs/STATUS.md](docs/STATUS.md))。
- 新增接口/服务逻辑应配套测试;改 `mold_generator` 公共接口必须保持契约测试通过。
### 4.3 API 与契约
- 新增 API 优先考虑模块归属(moldinsight / inventory / shared auth),路由文件过大按职责拆分。
- 请求体用 Pydantic 模型定义,减少 `await request.json()` 手写解析(存量债务见 [docs/TECH_DEBT.md](docs/TECH_DEBT.md) D1)。
- 接口变更后重导出 `openapi.json` 并在前端重新生成类型,步骤见 [docs/API_CONTRACT.md](docs/API_CONTRACT.md) §4。
### 4.4 配置
- 配置只走 `.env`([.env.example](.env.example) 为全键说明);compose 从同目录 `.env` 注入 `${VAR}`。
- 关键项(`DB_*` / `SECRET_KEY` / `ADMIN_PASSWORD`)无代码兜底;新增硬依赖配置缺失要 fail-fast,不给 localhost 默认。
- 配置项语义与加载优先级详见 [docs/OPERATIONS.md](docs/OPERATIONS.md) §1。
## 5. 文档导航
| 文档 | 管什么 |
|---|---|
| AGENTS.md(本文件) | agent briefing + 硬约束 + 代码地图 + 开发约定 |
| [README.md](README.md) | 人类入口:是什么 + 快速启动 + 文档导航 |
| [docs/STATUS.md](docs/STATUS.md) | **当前实现状态(唯一归属,常改,日志体)** |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 架构 + 模块边界 + 结构原则 |
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | 配置 / 启动 / 环境 / 运维硬性要求 |
| [docs/API_CONTRACT.md](docs/API_CONTRACT.md) | 前后端契约权威:端点 / 约定 / OpenAPI 类型生成 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 部署入口与部署文档分工 |
| [docs/deployment/LINUX_SETUP.md](docs/deployment/LINUX_SETUP.md) | Linux 详细部署步骤 |
| [docs/ROADMAP.md](docs/ROADMAP.md) | 演进路线与阶段计划 |
| [docs/TECH_DEBT.md](docs/TECH_DEBT.md) | 活跃技术债与治理顺序 |
| [docs/topics/](docs/topics/) | 专题补充(存储等) |
| [docs/archive/README.md](docs/archive/README.md) | 历史文档归档入口 |
-29
View File
@@ -1,29 +0,0 @@
# Dockerfile for MoldInsight
FROM continuumio/miniconda3:latest
# 设置工作目录
WORKDIR /app
# 复制项目文件
COPY . .
# 更新conda并创建环境
RUN conda update -n base -c defaults conda -y && \
conda create -n moldinsight python=3.11 pythonocc-core=7.9.0 -c conda-forge -y
# 激活环境并安装Python依赖
RUN . /opt/conda/etc/profile.d/conda.sh && \
conda activate moldinsight && \
pip install -r requirements.txt
# 创建必要的目录
RUN mkdir -p uploads html_output logs
# 设置启动脚本
RUN chmod +x start.sh
# 暴露端口
EXPOSE 8000
# 启动命令(使用shell形式确保环境激活)
CMD ["/bin/bash", "-c", "source /opt/conda/etc/profile.d/conda.sh && conda activate moldinsight && python src/main.py"]
+168 -288
View File
@@ -1,4 +1,4 @@
# Gemold - 模具制造管理系统
# geMoldInsight
<div align="center">
@@ -7,319 +7,199 @@
![FastAPI](https://img.shields.io/badge/fastapi-0.100.0-green)
![Vue.js](https://img.shields.io/badge/vue.js-3-green)
![PostgreSQL](https://img.shields.io/badge/postgresql-15-blue)
![License](https://img.shields.io/badge/license-Mit-green)
![License](https://img.shields.io/badge/license-MIT-green)
</div>
## 项目简介
geMoldInsight 是一个面向模具制造场景的综合系统,围绕 **STEP/STP 模型分析、模具方案生成、分析结果沉淀、成品创建、BOM/库存/采购/销售闭环** 展开。
**Gemold** 是一个面向模具制造行业的综合性管理系统,集成了STP文件分析、模具设计建议、用户权限管理和进销存功能。 系统采用现代化的技术栈,提供高效、稳定、易扩展的解决方案。
> **当前实现状态**:见 [docs/STATUS.md](docs/STATUS.md)
> **当前架构与边界**:见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
> **本文是唯一文档导航入口**:请按角色或主题跳转到对应主文档
### 核心功能
---
| 功能模块 | 描述 |
|---------|------|
| 🔷 STP文件分析 | 使用PythonOCC解析STP文件,提取几何特征 |
| 📊 模具设计建议 | 自动生成型腔、型芯、工艺参数等设计方案 |
| 🎨 3D可视化 | 实时预览产品模型和模具结构 |
| 👥 用户权限管理 | 完整的角色权限控制系统 |
| 📦 进销存管理 | 库存、供应商、客户管理 |
| 💾 数据持久化 | PostgreSQL + RustFS存储 |
## 项目概览
当前项目已经从早期单体演进为:
- **moldinsight 模块**:模具分析、几何处理、批量分析、成本估算、结果导出
- **inventory 模块**:产品、BOM、库存、采购、销售、财务
- **frontend 模块**:Vue 3 前端工程
- **shared 平台层**:配置、数据库、认证、日志、应用工厂
项目当前采用:
> **单仓库 + 单数据库 + 多模块 + 可独立部署**
更详细的结构说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
---
## 快速开始(三步)
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
前端开发需要:
```bash
cd frontend
npm install
```
### 2. 配置环境变量
复制并编辑:
- [`.env.example`](.env.example)
- 部署场景可参考 [deploy/.env.example](deploy/.env.example)
### 3. 启动
推荐先查看部署入口:
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
本地常见方式(按模式对应不同 compose 文件):
```bash
# 默认: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
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
```
> 如需 OCC 几何分析能力,请准备 PythonOCC 运行环境。项目中通常通过 conda 提供,而不是仅靠 pip 安装。
---
## 目录概览
```text
geMoldInsight/
├── src/
│ ├── entrypoints/ # 独立部署入口
│ ├── shared/ # 当前共享平台层
│ ├── moldinsight/ # 模具分析模块
│ ├── inventory/ # 进销存模块
│ ├── celery_app.py # Celery app
│ └── celery_tasks.py # moldinsight 异步任务
├── frontend/ # 独立前端工程
├── migrations/ # 数据库迁移
├── deploy/ # 镜像、Nginx、部署辅助文件
├── docs/
├── tests/
├── requirements.txt
└── .env.example
```
---
## 文档导航(唯一入口)
### 按主题阅读
| 文档 | 解决什么问题 |
|---|---|
| [AGENTS.md](AGENTS.md) | 项目开发规范、硬约束、代码地图、文档同步要求 |
| [docs/STATUS.md](docs/STATUS.md) | 当前实现状态(日志体,唯一归属) |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 当前架构、模块边界、结构原则 |
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | 配置 / 启动 / 环境 / 运维硬性要求 |
| [docs/API_CONTRACT.md](docs/API_CONTRACT.md) | 前后端契约:端点总览、约定、OpenAPI 类型生成 |
| [docs/ROADMAP.md](docs/ROADMAP.md) | 后续演进路线与阶段计划 |
| [docs/TECH_DEBT.md](docs/TECH_DEBT.md) | 当前活跃技术债与治理计划 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 部署主题入口与部署文档分工 |
| [docs/deployment/LINUX_SETUP.md](docs/deployment/LINUX_SETUP.md) | Linux 环境下的详细部署步骤 |
| [docs/archive/BACKEND_MODULARIZATION_BLUEPRINT.md](docs/archive/BACKEND_MODULARIZATION_BLUEPRINT.md) | 模块化蓝图档案与补充设计讨论 |
### 按角色阅读
| 你是 | 建议阅读顺序 |
|---|---|
| 第一次了解项目 | 本文 → [docs/STATUS.md](docs/STATUS.md) → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| 开发者 / 改代码 | [AGENTS.md](AGENTS.md) → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) → [docs/API_CONTRACT.md](docs/API_CONTRACT.md) → [docs/TECH_DEBT.md](docs/TECH_DEBT.md) |
| 运维 / 部署 | 本文 → [docs/OPERATIONS.md](docs/OPERATIONS.md) → [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) → [docs/deployment/LINUX_SETUP.md](docs/deployment/LINUX_SETUP.md) |
| 规划 / 重构 | 本文 → [docs/ROADMAP.md](docs/ROADMAP.md) → [docs/TECH_DEBT.md](docs/TECH_DEBT.md) |
---
## 核心能力
| 模块 | 能力 |
|---|---|
| moldinsight | STEP/STP 上传、几何分析、特征识别、模具方案、批量分析、成本估算、结果导出 |
| inventory | 成品/物料、BOM、库存、库存流水、采购订单、销售订单、财务、采购建议 |
| integration | 分析结果一键创建成品,打通“模具分析 → 成品 → BOM → 销售/采购/库存” |
| platform | 用户、角色、权限、JWT 鉴权、数据库连接、日志、健康检查 |
---
## 技术栈
### 后端
- **FastAPI** - 笷性能异步Web框架
- **PythonOCC** - 专业CAD几何处理
- **SQLAlchemy** - ORM框架
- **PostgreSQL** - 关系型数据库
- **RustFS** - 高性能文件存储
- FastAPI
- SQLAlchemy 2.0
- PostgreSQL
- Alembic
- Redis
- Celery
- PythonOCC / trimesh / pyvista
- RustFS / MinIO 兼容对象存储
### 前端
- **Vue.js 3** - 渐进式JavaScript框架
- **Three.js** - 3D可视化库
- **原生CSS** - 白色简约现代风格
- Vue 3
- Vite
- TypeScript
- Pinia
- Vue Router
- TDesign Vue Next
### 基础设施
- **Docker** - 容器化部署
- **Systemd** - 服务管理
- **Conda** - 环境管理
- Docker / Docker Compose
- 结构化日志 / request_id
- OpenAPI → TypeScript 类型生成
---
## 项目结构
## 当前代码入口
```
geMoldInsight/
├── src/ # 源代码目录
│ ├── main.py # 主程序入口
│ ├── api/ # API路由层
│ │ ├── routes.py # 模具分析API路由聚合
│ │ ├── auth_routes.py # 认证API
│ │ ├── v1/ # MoldInsight API (v1)
│ │ │ ├── upload_router.py # 文件上传
│ │ │ ├── task_router.py # 任务状态查询
│ │ │ └── history_router.py # 分析历史
│ │ └── inventory/ # 进销存API
│ │ ├── product_routes.py # 产品管理
│ │ ├── sales_order_routes.py # 销售订单
│ │ ├── purchase_order_routes.py # 采购订单
│ │ ├── finance_routes.py # 财务管理
│ │ ├── material_routes.py # 物料管理
│ │ └── schemas/ # 请求/响应模型
│ ├── core/ # 核心业务逻辑
│ │ ├── stp_parser.py # STP文件解析
│ │ ├── geometry_analyzer.py # 几何分析
│ │ ├── mesh_generator.py # 网格生成
│ │ ├── mold_generator.py # 普通塑料模具生成
│ │ ├── aluminum_foam_mold.py # 铝泡沫模具生成
│ │ ├── mold_quality_inspector.py # 模具质量检测
│ │ └── ai_mold_assistant.py # AI模具助手
│ ├── models/ # 数据模型
│ │ ├── database.py # SQLAlchemy ORM模型
│ │ └── schemas.py # Pydantic模式
│ ├── services/ # 服务层
│ │ ├── auth_service.py # 认证服务
│ │ ├── processing_service.py # STP处理流程编排
│ │ ├── calculation_service.py # 工程参数计算
│ │ ├── material_service.py # 材料属性管理
│ │ ├── task_query_service.py # 任务状态查询
│ │ ├── storage_integration_rustfs.py # RustFS存储集成
│ │ ├── redis_task_manager.py # Redis任务管理
│ │ └── verification_service.py # FreeCAD几何验证
│ ├── database/ # 数据库管理
│ │ ├── database.py # 数据库连接池
│ │ ├── init_db.py # 数据库初始化
│ │ └── migrate_db.py # 数据库迁移
│ ├── storage/ # 对象存储
│ │ ├── rustfs_storage.py # RustFS S3客户端
│ │ └── object_storage.py # 通用对象存储
│ └── utils/ # 工具类
│ ├── logger.py # 日志工具
│ ├── file_handler.py # 文件处理
│ └── html_generator.py # 3D可视化HTML生成
├── static/ # 前端静态资源
│ ├── vue-app.js # Vue3 SPA应用
│ ├── style.css # 全局样式
│ └── index.html # 入口页面
├── templates/ # Jinja2模板
├── config/ # 配置模块
│ └── settings.py # 环境变量配置
├── scripts/ # 脚本工具
│ ├── db/ # SQL脚本
│ ├── migrations/ # 数据迁移脚本
│ ├── tools/ # 临时检查/清理工具
│ └── verify_stp.py # STP验证脚本
├── docs/ # 项目文档
│ └── deployment/ # 部署相关文档
├── tests/ # 测试用例
├── uploads/ # 上传文件目录 (gitignore)
├── html_output/ # 3D可视化输出 (gitignore)
├── requirements.txt # Python依赖
├── docker-compose.yml # Docker编排配置
├── Dockerfile # Docker构建文件
├── start.sh # Linux启动脚本
├── .env.example # 环境变量模板
└── .env # 环境变量 (gitignore)
```
- 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 入口(一键命令对应文件名):
- unified: [docker-compose.yml](docker-compose.yml) → `docker compose up -d`
- moldinsight-only: [docker-compose.moldinsight.yml](docker-compose.moldinsight.yml) → `docker compose -f docker-compose.moldinsight.yml up -d`
- inventory-only: [docker-compose.inventory.yml](docker-compose.inventory.yml) → `docker compose -f docker-compose.inventory.yml up -d`
---
## 快速开始
## 开发建议
### 1. 环境要求
- Python 3.10+
- PostgreSQL 13+
- Conda (推荐)
### 2. 安装依赖
```bash
# 创建Conda环境
conda create -n py_3.12 python=3.12
# 激活环境
conda activate py_3.12
# 安装依赖
pip install -r requirements.txt
```
### 3. 配置环境变量
创建 `.env` 文件:
```env
# 数据库配置
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/gemold
# 服务配置
SECRET_KEY=your-secret-key-here
PORT=8000
# 管理员配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
```
### 4. 初始化数据库
```bash
python src/database/init_db.py
```
### 5. 启动服务
**Linux:**
```bash
chmod +x start.sh
./start.sh
```
**Windows:**
```cmd
start.bat
```
**Docker:**
```bash
docker-compose up -d
```
### 6. 访问服务
- 服务地址: http://localhost:8000
- 默认管理员: admin / admin123
---
## 核心功能详解
### STP文件分析
系统使用PythonOCC解析STP文件,提取以下几何特征:
| 特征类型 | 描述 |
|---------|------|
| 边界框 | 产品整体尺寸 |
| 体积 | 产品体积计算 |
| 表面积 | 产品表面积计算 |
| 壁厚 | 壁厚分布分析 |
| 加强筋 | 加强筋位置和密度 |
| 孔洞 | 孔洞和凹槽位置 |
| 倒扣 | 倒扣区域检测 |
| 对称性 | 对称性分析 |
| 重心 | 重心位置计算 |
### 模具设计建议
系统自动生成以下设计建议。
| 建议类型 | 描述 |
|---------|------|
| 型腔数量 | 单腔或多腔建议 |
| 模架尺寸 | 基于产品尺寸推荐 |
| 顶出系统 | 顶针顶出布局 |
| 冷却水路 | 冷却需求分析 |
| 材料选择 | 基于产量推荐材料 |
### 3D可视化
- **点云模型** - 从STP提取的真实几何形状
- **模具型腔** - 型腔和型芯可视化
- **分型面** - 分型面位置显示
- **交互控制** - 旋转、缩放、平移
- **视图切换** - 显示/隐藏各组件
---
## API文档
### 认证API
```
POST /api/auth/login # 用户登录
POST /api/auth/logout # 用户登出
GET /api/auth/me # 获取当前用户信息
```
### 文件分析API
```
POST /api/upload # 上传STP文件
POST /api/status/{task_id} # 获取分析状态
GET /api/history # 获取分析历史
```
### 进销存API
```
GET /api/inventory/dashboard # 仪表盘数据
GET /api/inventory/products # 产品列表
POST /api/inventory/products # 创建产品
```
---
## 部署指南
### Systemd服务 (Linux)
```bash
# 复制服务文件
sudo cp gemoldinsight.service /etc/systemd/system/
# 启用服务
sudo systemctl enable gemoldinsight
# 启动服务
sudo systemctl start gemoldinsight
```
### Docker部署
```bash
# 构建镜像
docker build -t gemold:latest .
# 启动容器
docker-compose up -d
```
---
## 开发指南
### 代码风格
- 遵循PEP 8规范
- 使用类型注解
- 保持函数简洁
### 提交规范
- feat: 新功能
- fix: 修复bug
- docs: 文档更新
- refactor: 代码重构
- test: 测试相关
- 新增业务逻辑优先放入对应业务模块,不要继续堆进 `shared`
- 新增 API 时优先考虑模块归属,而不是“能放就放”
- 文档状态统一维护在 [docs/STATUS.md](docs/STATUS.md)
- 部署方式变化统一更新 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
---
## 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
---
## 贡献者
感谢所有为这个项目做出贡献的开发者。
---
## 联系方式
- 项目地址: [GitHub](https://github.com/your-org/gemold)
- 问题反馈: [Issues](https://github.com/your-org/gemold/issues)
本项目采用 MIT 许可证,详见 [LICENSE](LICENSE)。
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+35 -36
View File
@@ -1,23 +1,43 @@
# ============================================
# 环境变量模板 — 独立部署版
# 环境变量模板 — 复用服务器已有基础设施
# ============================================
# 复制为 .env 并修改配置
# 复制为 .env 并按服务器实际服务地址修改
# 数据库
DB_HOST=postgres
# API / 前端对外端口
FRONTEND_PORT=80
BACKEND_PORT=8000
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
# 数据库(服务器已部署)
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=moldinsight
DB_USER=moldinsight_user
DB_PASSWORD=moldinsight_password
DB_USER=moldinsight
DB_PASSWORD=change-me
# Redis
REDIS_HOST=redis
# Redis(服务器已部署)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# MinIO (RustFS 兼容存储)
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# RustFS / MinIO 兼容对象存储(服务器已部署)
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=change-me
RUSTFS_SECRET_KEY=change-me
RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600
# 服务配置
HOST=0.0.0.0
DEBUG=false
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600
ALLOWED_EXTENSIONS=.stp,.step,.stp.gz
POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high
PARALLEL_PROCESSING=true
# 认证
SECRET_KEY=your-secret-key-change-in-production-min-32-chars
@@ -30,35 +50,14 @@ ADMIN_PASSWORD=change-this-to-a-secure-password
ADMIN_EMAIL=admin@gemold.com
ADMIN_FULL_NAME=系统管理员
# 端口
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
# FreeCAD 验证(可选)
ENABLE_FREECAD_VERIFICATION=false
FREECAD_VERIFICATION_TIMEOUT=120
# 文件上传
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600
ALLOWED_EXTENSIONS=.stp,.step,.stp.gz
# 几何处理
POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high
PARALLEL_PROCESSING=true
# RustFS 对象存储
RUSTFS_ENDPOINT=http://minio:9000
RUSTFS_ACCESS_KEY=minioadmin
RUSTFS_SECRET_KEY=minioadmin
RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600
# LLM (可选)
# LLM(可选)
LLM_ENABLED=false
LLM_API_URL=https://api.openai.com/v1
LLM_API_KEY=sk-your-api-key
LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000
# FreeCAD 验证 (可选)
ENABLE_FREECAD_VERIFICATION=false
FREECAD_VERIFICATION_TIMEOUT=120
+5 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM python:3.12-slim-bookworm
WORKDIR /app
@@ -13,5 +13,9 @@ RUN pip install --no-cache-dir -r requirements-base.txt && \
COPY src/shared/ /app/src/shared/
# init_db 启动期自动迁移(AUTO_MIGRATE)需要迁移脚本随镜像分发
COPY migrations/ /app/migrations/
COPY alembic.ini /app/alembic.ini
ENV PYTHONPATH=/app/src
ENV PYTHONUNBUFFERED=1
-3
View File
@@ -1,3 +0,0 @@
FROM gemold-moldinsight:latest
CMD ["celery", "-A", "celery_app", "worker", "--workdir=/app/src", "--concurrency=2", "--loglevel=info"]
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
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 8000
-2
View File
@@ -2,8 +2,6 @@ FROM gemold-base:latest
COPY src/inventory/ /app/src/inventory/
COPY src/entrypoints/ /app/src/entrypoints/
COPY static/ /app/static/
RUN mkdir -p /app/logs
EXPOSE 8001
+32 -11
View File
@@ -1,24 +1,45 @@
FROM continuumio/miniconda3:latest AS pythonocc
# PythonOCC 仅经 conda-forge 提供,且其动态库与 conda Python 的 ABI 绑定。
# 旧方式(conda 环境装好后把 site-packages 拷入 python:slim 系统 python)依赖
# 两侧 Python ABI 恰好兼容,属脆弱做法(TECH_DEBT D13);现改为直接以同一
# conda 运行时作为最终镜像的执行环境,自带全部动态库。
# Base 用 Miniforge:conda-forge 默认且唯一渠道(无 defaults 渠道,无 Anaconda
# ToS 顾虑),与 CI 的 Miniforge 安装(.gitea/workflows/ci.yml)同源;tag 锁定。
FROM condaforge/miniforge3:24.7.1-2
RUN conda update -n base -c defaults conda -y && \
conda create -n moldinsight python=3.12 pythonocc-core=7.9.0 -c conda-forge -y
# 锁定几何栈核心版本;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 \
&& conda clean -afy
FROM gemold-base:latest
ENV PATH=/opt/conda/envs/moldinsight/bin:$PATH \
PYTHONUNBUFFERED=1
COPY --from=pythonocc /opt/conda/envs/moldinsight/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
WORKDIR /app
COPY deploy/requirements-moldinsight.txt .
RUN pip install --no-cache-dir -r requirements-moldinsight.txt && \
rm requirements-moldinsight.txt
# 自包含构建:不再基于 gemold-base,基础依赖与模块依赖一并安装
COPY deploy/requirements-base.txt deploy/requirements-moldinsight.txt ./
RUN pip install --no-cache-dir \
-r requirements-base.txt \
-r requirements-moldinsight.txt \
&& rm requirements-base.txt requirements-moldinsight.txt
COPY src/shared/ /app/src/shared/
COPY src/moldinsight/ /app/src/moldinsight/
COPY src/inventory/ /app/src/inventory/
COPY src/entrypoints/ /app/src/entrypoints/
COPY src/celery_app.py src/celery_tasks.py /app/src/
# init_db 启动期自动迁移(AUTO_MIGRATE)需要迁移脚本随镜像分发
COPY migrations/ /app/migrations/
COPY alembic.ini /app/alembic.ini
COPY uploads/ /app/uploads/
COPY static/ /app/static/
COPY html_output/ /app/html_output/
ENV PYTHONPATH=/app/src
RUN mkdir -p /app/logs
+9 -15
View File
@@ -8,25 +8,19 @@ echo === 构建基础镜像 ===
docker build -t gemold-base:latest -f deploy\Dockerfile.base .
echo.
echo === 构建 MoldInsight 镜像 (含 PythonOCC) ===
docker build -t gemold-moldinsight:latest -f deploy\Dockerfile.moldinsight .
echo === 构建统一后端镜像 ===
docker build -t gemold-backend:latest -f deploy\Dockerfile.moldinsight .
echo.
echo === 构建 Inventory 镜像 ===
docker build -t gemold-inventory:latest -f deploy\Dockerfile.inventory .
echo.
echo === 构建 Celery Worker 镜像 ===
docker build -t gemold-celery:latest -f deploy\Dockerfile.celery .
echo === 构建前端镜像 (Nginx 静态站点) ===
docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
echo.
echo === 全部构建完成 ===
echo.
echo 启动完整系统:
echo cd deploy ^&^& docker compose --profile full up -d
echo 启动 unified 默认栈(前端 + backend + celery):
echo docker compose up -d
echo.
echo 仅启动进销存:
echo cd deploy ^&^& docker compose --profile inventory up -d
echo.
echo 仅启动模具分析:
echo cd deploy ^&^& 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
+9 -14
View File
@@ -10,25 +10,20 @@ echo "=== 构建基础镜像 ==="
docker build -t gemold-base:latest -f deploy/Dockerfile.base .
echo ""
echo "=== 构建 MoldInsight 镜像 (含 PythonOCC) ==="
docker build -t gemold-moldinsight:latest -f deploy/Dockerfile.moldinsight .
echo "=== 构建统一后端镜像 ==="
docker build -t gemold-backend:latest -f deploy/Dockerfile.moldinsight .
echo ""
echo "=== 构建 Inventory 镜像 ==="
docker build -t gemold-inventory:latest -f deploy/Dockerfile.inventory .
echo ""
echo "=== 构建 Celery Worker 镜像 ==="
docker build -t gemold-celery:latest -f deploy/Dockerfile.celery .
echo "=== 构建前端镜像 (Nginx 静态站点) ==="
docker build -t gemold-frontend:latest -f deploy/Dockerfile.frontend .
echo ""
echo "=== 全部构建完成 ==="
echo ""
echo "启动完整系统:"
echo " cd deploy && docker compose --profile full up -d"
echo "启动 unified 默认栈(前端 + backend + celery):"
echo " docker compose up -d"
echo ""
echo "仅启动进销存:"
echo " cd deploy && docker compose --profile inventory up -d"
echo ""
echo "仅启动模具分析:"
echo " cd deploy && 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"
-165
View File
@@ -1,165 +0,0 @@
services:
postgres:
image: postgres:15
container_name: gemold_postgres
environment:
POSTGRES_DB: ${DB_NAME:-moldinsight}
POSTGRES_USER: ${DB_USER:-moldinsight_user}
POSTGRES_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-moldinsight_user} -d ${DB_NAME:-moldinsight}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- gemold_network
redis:
image: redis:7-alpine
container_name: gemold_redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- gemold_network
minio:
image: minio/minio:latest
container_name: gemold_minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
restart: unless-stopped
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: postgres
DB_PORT: "5432"
DB_NAME: ${DB_NAME:-moldinsight}
DB_USER: ${DB_USER:-moldinsight_user}
DB_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
RUSTFS_ENDPOINT: http://minio:9000
RUSTFS_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
RUSTFS_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
profiles:
- full
- moldinsight
networks:
- gemold_network
moldinsight-celery:
build:
context: ..
dockerfile: deploy/Dockerfile.celery
container_name: gemold_celery
environment:
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${DB_NAME:-moldinsight}
DB_USER: ${DB_USER:-moldinsight_user}
DB_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
RUSTFS_ENDPOINT: http://minio:9000
RUSTFS_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
RUSTFS_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
depends_on:
- moldinsight
restart: unless-stopped
profiles:
- full
- 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: postgres
DB_PORT: "5432"
DB_NAME: ${DB_NAME:-moldinsight}
DB_USER: ${DB_USER:-moldinsight_user}
DB_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
profiles:
- full
- inventory
networks:
- gemold_network
volumes:
postgres_data:
driver: local
minio_data:
driver: local
networks:
gemold_network:
driver: bridge
+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 中的实际版本(或保留 >=,按团队策略)"
+53
View File
@@ -0,0 +1,53 @@
upstream gemold_backend_upstream {
server backend:8000;
}
server {
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;
location /assets/ {
try_files $uri =404;
access_log off;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location /api/ {
proxy_pass http://gemold_backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://gemold_backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /html/ {
proxy_pass http://gemold_backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
-1
View File
@@ -10,7 +10,6 @@ minio>=7.1.0
aiohttp>=3.13.4
celery[redis]>=5.3.0
kafka-python>=2.0.2
pytest>=7.0.0
pytest-asyncio>=0.21.0
+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
+131 -106
View File
@@ -1,119 +1,144 @@
# docker-compose.yml - 完整版(包含PostgreSQL和MinIO)
# 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:
postgres:
image: postgres:15
container_name: moldinsight_postgres
environment:
POSTGRES_DB: ${DB_NAME:-moldinsight}
POSTGRES_USER: ${DB_USER:-moldinsight_user}
POSTGRES_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
frontend:
build:
context: .
dockerfile: deploy/Dockerfile.frontend
image: gemold-frontend:latest
container_name: gemold_frontend
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-moldinsight_user} -d ${DB_NAME:-moldinsight}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- moldinsight_network
minio:
image: minio/minio:latest
container_name: moldinsight_minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "9000:9000" # API端口
- "9001:9001" # 控制台端口
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
restart: unless-stopped
networks:
- moldinsight_network
moldinsight:
build: .
container_name: moldinsight_app
ports:
- "${HOST_PORT:-10001}:${CONTAINER_PORT:-8000}" # 宿主机端口:容器端口
volumes:
- ./uploads:/app/uploads
- ./html_output:/app/html_output
- ./logs:/app/logs
- ./.env:/app/.env:ro
environment:
# 应用端口配置(从settings.py读取)
- HOST=${HOST:-0.0.0.0}
- PORT=${CONTAINER_PORT:-8000}
# 数据库配置
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=${DB_NAME:-moldinsight}
- DB_USER=${DB_USER:-moldinsight_user}
- DB_PASSWORD=${DB_PASSWORD:-moldinsight_password}
# RustFS配置
- RUSTFS_ENDPOINT=http://minio:9000
- RUSTFS_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- RUSTFS_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
# 浏览器入口端口(无默认值:必须由 .env 中 FRONTEND_PORT 显式配置)
- "${FRONTEND_PORT}:8000"
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- moldinsight_network
- gemold_network
celery-worker:
build: .
container_name: moldinsight_celery
command: >
/bin/bash -c "source /opt/conda/etc/profile.d/conda.sh && conda activate moldinsight && cd /app/src && celery -A celery_app worker --concurrency=2 --loglevel=info"
volumes:
- ./uploads:/app/uploads
- ./html_output:/app/html_output
- ./logs:/app/logs
- ./.env:/app/.env:ro
backend:
build:
context: .
dockerfile: deploy/Dockerfile.moldinsight
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"
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=${DB_NAME:-moldinsight}
- DB_USER=${DB_USER:-moldinsight_user}
- DB_PASSWORD=${DB_PASSWORD:-moldinsight_password}
- RUSTFS_ENDPOINT=http://minio:9000
- RUSTFS_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- RUSTFS_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
<<: *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:-系统管理员}
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
# 共享卷过渡兜底(D6/D11):主链路已改走 RustFS。uploads 供 RustFS 异常时
# 本地路径回退;html_output 仅作 /html 报告代理的存量兜底读(新产物不落本地)
volumes:
- uploads_data:/app/uploads
- html_data:/app/html_output
restart: unless-stopped
networks:
- moldinsight_network
- gemold_network
volumes:
postgres_data:
driver: local
minio_data:
driver: local
moldinsight-celery:
# 与 backend 共用同一镜像(同一 build 声明 + 同一 tag,compose 只构建一次),
# 仅以 command 覆盖启动 worker——消除旧 Dockerfile.celery(已删)FROM
# gemold-backend 在并行构建下的"镜像尚不存在"陷阱,干净机器裸 up 一把过
build:
context: .
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:
<<: *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:
- uploads_data:/app/uploads
depends_on:
- backend
restart: unless-stopped
networks:
- gemold_network
networks:
moldinsight_network:
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
+113
View File
@@ -0,0 +1,113 @@
# geMoldInsight 前后端契约(API_CONTRACT)
> 文档定位:**前后端契约的唯一归属**——端点总览、统一约定、OpenAPI 类型生成流程。
> 端点定义、路径、请求/响应 schema 的**最终真相源是根目录 `openapi.json`**(由 FastAPI 自动生成);本文维护人可读的总览与变更规则。当前状态见 [STATUS.md](STATUS.md),架构见 [ARCHITECTURE.md](ARCHITECTURE.md),接口类技术债见 [TECH_DEBT.md](TECH_DEBT.md) D1。
---
## 1. 总览
三种部署形态暴露的 API 面(入口见 [src/entrypoints/](../src/entrypoints/)):
| 形态 | API 面 |
|---|---|
| unified(推荐) | `/api/*`(moldinsight + inventory)+ `/api/auth/*` + 顶层 `/health` |
| moldinsight-only | `/api/*`(moldinsight)+ `/api/auth/*` + `/health` |
| inventory-only | `/api/*`(inventory)+ `/api/auth/*` + `/health` |
- moldinsight 路由在 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 按 `ROUTE_MODULES` 清单经 `_safe_include` 聚合(装载失败登记 [route_registry.py](../src/moldinsight/api/route_registry.py),`/api/health` 呈现 `degraded` 并列出失败模块;`DEBUG=true` 下失败直接抛错;`debug_router` 仅 `DEBUG=true` 注册)。
- inventory 路由在 [inventory/api/\_\_init\_\_.py](../src/inventory/api/__init__.py) 按域静态聚合。
- 认证路由来自 [shared/services/auth_routes.py](../src/shared/services/auth_routes.py),由 [app_factory](../src/shared/app_factory.py) 挂载,三种形态共用。
## 2. 统一约定
- **鉴权**:JWT Bearer(`Authorization: Bearer <token>`)。登录:`POST /api/auth/login`(表单)/ `POST /api/auth/login/json`(JSON);受保护路由通过 FastAPI 依赖 `get_current_active_user` 注入当前用户([shared/services/auth_service.py](../src/shared/services/auth_service.py))。`SECRET_KEY` 跨进程必须一致。
- **响应形态**:现状**无统一信封包装**——各端点直接返回业务 JSON;schema 以 `openapi.json` 的 components 为准。新增接口不建议另起信封风格,保持与所在模块一致。
- **错误**:FastAPI 标准 `HTTPException` 状态码语义;业务校验优先 Pydantic 请求模型自动 422。
- **业务路由前缀**:全部业务端点在 `/api` 下;顶层仅 `/health`(探活)、`/html/{filename}`(可视化报告代理)、`/login`、`/users`(历史遗留入口,前端主链路用 `/api/auth/*`)。
## 3. 端点总览(按域分组)
> 下表为导航用速览;路径参数、请求/响应字段以 `openapi.json` 为准。
### 3.1 认证与用户(shared)
| 域 | 端点 | 文件 |
|---|---|---|
| 登录 | `/api/auth/login`、`/api/auth/login/json`、`/api/auth/logout` | auth_routes.py |
| 当前用户 | `/api/auth/me` | auth_routes.py |
| 用户管理 | `/api/auth/users`、`/api/auth/users/{user_id}`、`/api/auth/users/{user_id}/reset-password`(管理员;JSON body `{ new_password }`,最短 6 位) | auth_routes.py |
| 角色权限 | `/api/auth/roles`、`/api/auth/roles/{role_id}`、`/api/auth/roles/{role_id}/permissions`、`/api/auth/permissions`、`/api/auth/permissions/{permission_id}` | auth_routes.py |
### 3.2 moldinsight(模具分析)
| 域 | 端点 | 文件 |
|---|---|---|
| 上传 | `/api/upload` | upload_router.py |
| 批量分析 | `/api/batch-upload`、`/api/batch/{batch_id}`(聚合状态以 PG 为准;响应含 `current_step`;他人批次 403、不存在 404) | batch_router.py |
| 任务状态 | `/api/status/{task_id}`(需登录;仅任务所有者可访问,他人/无主任务 403,不存在 404) | task_router.py |
| 历史结果 | `/api/history`、`/api/history/{filename}` | history_router.py |
| HTML 报告 | `/html/{filename}`(根路径,非 `/api` 前缀;D11 代理:RustFS 报告键 `html/reports/{filename}` 直取 → 遗留 JSON 包装对象 → 本地 `html_output/` 存量兜底 → 404。已知约束:不做认证——iframe 无法携带 Authorization 头,沿用 StaticFiles 时代既定姿态) | html_report_router.py |
| CAM | `/api/cam/plan`(Pydantic 请求模型;未提供的偏好回落任务持久化偏好再回落默认) | cam_router.py |
| 设计 | `/api/optimize-layout`、`/api/design-cooling`、`/api/design-gating`、`/api/design-mold-system`、`/api/detect-undercuts` | design_router.py |
| 成本估算 | `/api/cost-estimate` | cost_router.py |
| 加工 | `/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 |
### 3.3 inventory(进销存)
| 域 | 端点 | 文件 |
|---|---|---|
| 成品 | `/api/products`、`/api/products/{product_id}`、`/api/products/{product_id}/materials`、`/api/products/from-task/{task_id}` | product_routes.py |
| 物料价格/供应商 | `/api/materials/*` | material_routes.py |
| 供应商 | `/api/suppliers`、`/api/suppliers/{supplier_id}` | supplier_routes.py |
| 客户 | `/api/customers`、`/api/customers/{customer_id}` | customer_routes.py |
| 仓库 | `/api/warehouses` | warehouse_routes.py |
| 库存 | `/api/inventory`、`/api/inventory/{inventory_id}` | inventory_routes.py |
| 库存流水 | `/api/stock-movements` | stock_movement_routes.py |
| 采购订单 | `/api/purchase-orders`、`/api/purchase-orders/{order_id}`、`.../receive`、`.../status` | purchase_order_routes.py |
| 采购建议 | `/api/purchase-demands/calculate` | purchase_demand_routes.py |
| 销售订单 | `/api/sales-orders`、`/api/sales-orders/{order_id}`、`.../status`、`.../issue-materials`、`.../consume-materials`、`.../production-plan` | sales_order_routes.py |
| 财务 | `/api/finance/summary`、`/api/finance/receivables|payables|receipts|payments|transactions`、`/api/finance/partner-statement/{partner_type}` 等 | finance_routes.py |
| 看板 | `/api/dashboard` | dashboard_routes.py |
**moldinsight → inventory 桥接**:`/api/products/from-task/{task_id}` 由分析任务一键创建成品(对应 `STPFile.product_id -> Product.id` 单库桥接,见 [ARCHITECTURE.md](ARCHITECTURE.md) §5.1)。
## 4. OpenAPI 与前端类型生成(接口变更三件套)
接口变更后**必须依次完成**:
1. **改代码**:路由 + Pydantic 请求/响应模型(优先模型,少写 `request.json()` 解析)。
2. **重导出 `openapi.json`**(在可 import 项目的环境执行,如 conda `gemold`):
```bash
python -c "import sys; sys.path.insert(0, 'src'); from entrypoints.unified import app; import json; print(json.dumps(app.openapi(), ensure_ascii=False, indent=2))" > openapi.json
```
(moldinsight-only 契约视角可把 `entrypoints.unified` 换成 `entrypoints.moldinsight`;对外主契约以 unified 为准。)
3. **重新生成前端类型**:
```bash
cd frontend && npm run gen:api # openapi-typescript:../openapi.json -> src/types/api.ts
```
- `frontend/src/types/api.ts` 是**生成物,禁止手改**;前端代码类型引用它。
- 三步缺一即前后端契约漂移(硬约束,见 [AGENTS.md](../AGENTS.md) §2)。
- 当前 `openapi.json` 于 2026-09-17 随批次 3 重导出(76 paths),前端 `src/types/api.ts` 同步再生。
## 5. 契约变更规则
- 新增接口先定模块归属(moldinsight / inventory / shared auth),再写路由;返回结构、路径、鉴权发生变化时,同步更新本文相应表格。
- 路由文件过大按职责拆分(参照批次 3 的 design / cost / machining / export 拆分先例;新增路由须登记 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 的 `ROUTE_MODULES`)。
- 破坏性变更(删字段 / 改语义)需在 [STATUS.md](STATUS.md) 日志条目中记录,并确认前端同仓同步修改。
## 6. 前端消费约定
- 前端为独立工程 [frontend/](../frontend/)(Vue 3 + Vite + TS + Pinia + TDesign),按域组织在 `src/modules/`(moldinsight / inventory / users / login / home)。
- API 类型唯一来源 `src/types/api.ts`(生成物);组件不手写与后端重复的响应类型。
- 跨域:开发期走 Vite;部署期为同域反代(见 [DEPLOYMENT.md](DEPLOYMENT.md)),`CORS_ORIGINS` 仅服务于非同域场景。
+312
View File
@@ -0,0 +1,312 @@
# geMoldInsight 架构与边界(ARCHITECTURE)
> 文档定位:**当前架构、模块边界与结构原则的权威文档**。
> 本文描述“现在的系统结构是什么、边界如何划分、目标形态是什么”;不负责维护当前实现进度,当前状态见 [STATUS.md](STATUS.md),部署见 [DEPLOYMENT.md](DEPLOYMENT.md),演进路线见 [ROADMAP.md](ROADMAP.md)。
> 如需追溯模块化设计蓝图与扩展讨论,见 [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](archive/BACKEND_MODULARIZATION_BLUEPRINT.md)。
---
## 1. 架构目标
geMoldInsight 的目标架构不是微服务,也不是继续维持历史单体,而是:
> **单仓库 + 单数据库 + 多模块 + 可独立部署的 modular monolith**
这意味着:
- 保持同一 Git 仓库
- 保持同一 PostgreSQL 数据库
- 按模块组织业务代码与部署入口
- 在不拆库、不拆仓的前提下,明确业务边界与部署边界
---
## 2. 当前模块划分
### 2.1 moldinsight
目录:
- [src/moldinsight/](../src/moldinsight/)
职责:
- STEP/STP 上传与任务管理
- 几何分析与特征识别
- 模具方案生成
- 批量分析
- 成本估算
- 导出与结果查询
- 结合 Celery 执行异步分析链路
### 2.2 inventory
目录:
- [src/inventory/](../src/inventory/)
职责:
- 成品/物料管理
- BOM 管理
- 库存与库存流水
- 采购订单 / 销售订单
- 财务与对账
- 采购建议推导
### 2.3 frontend
目录:
- [frontend/](../frontend/)
职责:
- 模具分析页面
- 进销存页面
- 登录/用户管理
- 统一路由与状态管理
- 基于 OpenAPI 类型生成的前端调用
### 2.4 shared(当前平台层)
目录:
- [src/shared/](../src/shared/)
职责:
- 配置
- 数据库连接与 session
- 认证与权限
- 日志与 request_id
- 应用工厂与共用中间件
说明:
- `shared` 的定位已明确为**平台层(跨模块基础能力)**;模块专属接线(RustFS 启动 / HTML 报告挂载 / 路由聚合)已收敛回模块层(见 §6.2),当前仓库结构仍以 `shared` 为事实名称
- 剩余语义收敛(identity 平台表 vs 模块表的命名与注释口径)随实际重构继续推进
---
## 3. 当前代码结构
当前核心结构如下:
```text
geMoldInsight/
├── src/
│ ├── entrypoints/
│ │ ├── moldinsight.py
│ │ └── inventory.py
│ ├── shared/
│ ├── moldinsight/
│ ├── inventory/
│ ├── celery_app.py
│ └── celery_tasks.py
├── frontend/
├── migrations/
├── deploy/
├── docs/
└── tests/
```
这反映的是**当前实际代码组织**,不是历史单体结构。
---
## 4. 部署边界
当前设计上支持三种部署模式:
### 4.1 unified
一个统一后端同时承载 moldinsight + inventory,并作为前端默认反代目标。
适合:
- 本地开发
- 集成环境
- 小团队统一部署
### 4.2 moldinsight-only
只部署模具分析后端。
适合:
- 单独开放分析能力
- 异步任务与文件处理独立扩容
入口:
- [src/entrypoints/moldinsight.py](../src/entrypoints/moldinsight.py)
### 4.3 inventory-only
只部署进销存后端。
适合:
- 独立使用 ERP / 库存能力
- 与 moldinsight 分开部署节奏
入口:
- [src/entrypoints/inventory.py](../src/entrypoints/inventory.py)
部署操作与当前推荐方案见 [DEPLOYMENT.md](DEPLOYMENT.md)。
---
## 5. 关键架构原则
### 5.1 单数据库是刻意设计
项目不是把 moldinsight 与 inventory 强拆成两个数据库,而是保留共享数据库,以支撑完整业务闭环:
- 分析结果
- 创建成品
- 成品 BOM
- 销售 / 采购 / 库存
典型桥接关系示例:
- `STPFile.product_id -> Product.id`
桥接只允许**裸 FK 列**(字符串表名),**不允许跨模块 ORM relationship**——单模块部署下另一模块的模型类可能未注册,跨模块 relationship 会让 mapper 配置直接失败(2026-09-17 批次 4 起为硬规则,原三条跨模块 relationship 均无使用方,已删除;对象化查询由使用方显式 select)。
### 5.2 模块边界优先于“临时方便”
新增逻辑时,应优先放入对应业务模块,而不是继续堆进 `shared`。
原则上:
- moldinsight 业务进入 `src/moldinsight/`
- inventory 业务进入 `src/inventory/`
- 只有真正跨模块复用的基础能力才进入 `src/shared/`
### 5.3 前端是独立工程,不是后端静态附属
前端已是独立 Vite/Vue 工程,部署上可与后端组合,但在代码组织上应视为独立模块,而不是后端 `static/` 的扩展。
---
## 6. 当前主要耦合点
虽然模块化已经成型,但仍有几个关键耦合点需要持续关注:
### 6.1 共享 ORM 模型 —— 已按模块拆分(2026-09-17,批次 4)
历史上的 `shared/models/database.py`(31 个模型类三类同居)已拆除,现为按归属分置:
- [src/shared/models/base.py](../src/shared/models/base.py):唯一 `Base` + 归属约定与全量注册点说明
- [src/shared/models/identity.py](../src/shared/models/identity.py):用户/角色/权限/审计(平台层,所有部署形态共用)
- [src/moldinsight/models/](../src/moldinsight/models/):STEP 分析域 9 表(stp_files 及各阶段产物、processing_tasks)
- [src/inventory/models/](../src/inventory/models/):进销存 15 表(catalog / warehouse / trading / finance 四域文件)
跨模块只允许裸 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 组合职责 —— 已收敛(2026-09-18,D3 剩余)
平台层与模块专属接线的边界已明确(`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. 专题文档与主骨架的关系
以下文档仍可作为专题补充参考,但不再承担默认入口职责:
- 存储方向:
- [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md)
- [topics/storage/STORAGE_SETUP.md](topics/storage/STORAGE_SETUP.md)
- 性能方向:
- [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(OCC 吞吐与隔离方案设计,TECH_DEBT D10 归属)
AI、铝泡沫等更偏历史设计/规划性质的专题材料已迁入 [archive/README.md](archive/README.md)。
阶段性任务清单、迁移计划、历史总结等文档会逐步迁入 [archive/README.md](archive/README.md)。
---
## 8. 与相关文档的边界
- 想看“当前做到哪一步”:看 [STATUS.md](STATUS.md)
- 想看“后面还要往哪演进”:看 [ROADMAP.md](ROADMAP.md)
- 想看“当前有哪些活跃技术债”:看 [TECH_DEBT.md](TECH_DEBT.md)
- 想看“配置怎么给、服务怎么起”:看 [OPERATIONS.md](OPERATIONS.md)
- 想看“前后端接口契约”:看 [API_CONTRACT.md](API_CONTRACT.md)
- 想看“怎么部署”:看 [DEPLOYMENT.md](DEPLOYMENT.md)
- 想看“模块化蓝图与更完整设计讨论”:看 [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
-390
View File
@@ -1,390 +0,0 @@
# 铝制家电包装泡沫模具分模功能开发检查清单
## 文档信息
| 项目 | 内容 |
|------|------|
| **文档名称** | 铝制家电包装泡沫模具分模功能开发检查清单 |
| **版本** | 1.0 |
| **日期** | 2026-03-13 |
| **项目** | geMoldInsight 模具分模功能增强 |
---
## 检查清单说明
本文档包含铝制家电包装泡沫模具分模功能开发的完整检查清单,用于跟踪开发进度和验收标准。
---
## 第一阶段:基础框架搭建
### 任务 1.1:创建铝泡沫模具参数类
- [ ] 创建 `AluminumFoamMoldParams` 数据类
- [ ] 添加发泡倍率参数 (`expansion_ratio`)
- [ ] 添加目标密度参数 (`target_density`)
- [ ] 添加成型温度参数 (`molding_temp`)
- [ ] 添加收缩率参数 (`shrinkage_rate`)
- [ ] 实现参数验证方法
- [ ] 设置合理的默认值
### 任务 1.2:创建铝泡沫材料数据库
- [ ] 创建 `FoamMaterialDatabase` 类
- [ ] 添加 AlSi10Mg 材料数据
- [ ] 添加 AlSi12 材料数据
- [ ] 添加纯铝泡沫材料数据
- [ ] 实现材料查询方法
- [ ] 实现材料参数获取方法
### 任务 1.3:扩展现有模具生成器
- [ ] 修改 `MoldCavityGenerator` 构造函数
- [ ] 添加铝泡沫参数支持
- [ ] 添加 `set_foam_material()` 方法
- [ ] 更新 `generate_mold_cavities()` 流程
- [ ] 适配收缩补偿计算
- [ ] 适配拔模角计算
### 任务 1.4:创建参数配置 API 接口
- [ ] 添加获取默认参数 API
- [ ] 添加设置参数 API
- [ ] 添加参数验证 API
- [ ] 添加模板保存 API
- [ ] 添加模板加载 API
- [ ] 实现请求参数解析
### 任务 1.5:前端参数面板开发
- [ ] 创建参数配置组件
- [ ] 添加分模参数输入控件
- [ ] 添加铝泡沫参数输入控件
- [ ] 添加拔模参数输入控件
- [ ] 添加高级参数折叠面板
- [ ] 实现参数提交功能
- [ ] 实现参数加载功能
### 任务 1.6:参数模板功能
- [ ] 创建预设模板(快速模式)
- [ ] 创建预设模板(经济模式)
- [ ] 创建预设模板(高精度模式)
- [ ] 实现模板保存功能
- [ ] 实现模板加载功能
- [ ] 实现模板列表功能
### 任务 1.7:参数验证逻辑
- [ ] 实现分型精度范围验证
- [ ] 实现型腔匹配度验证
- [ ] 实现拔模角范围验证
- [ ] 实现收缩率范围验证
- [ ] 实现温度范围验证
- [ ] 返回详细错误信息
### 任务 1.8:阶段一集成测试
- [ ] 测试参数设置功能
- [ ] 测试参数获取功能
- [ ] 测试参数验证
- [ ] 测试模板保存/加载
- [ ] 测试前端参数面板
- [ ] 测试 API 接口响应
- [ ] 无阻塞性 bug
---
## 第二阶段:分模算法优化
### 任务 2.1:改进法向量分析算法
- [ ] 添加高斯权重计算
- [ ] 实现多点采样
- [ ] 优化主方向识别
- [ ] 处理法向量突变
- [ ] 测试复杂几何产品
### 任务 2.2:实现多分型面检测
- [ ] 设计多分型面数据结构
- [ ] 实现分型面优先级排序
- [ ] 实现分型面序列生成
- [ ] 处理分型面交叠
- [ ] 测试 2 分型面案例
- [ ] 测试 3+ 分型面案例
### 任务 2.3:倒扣区域检测
- [ ] 分析产品几何特征
- [ ] 实现倒扣识别算法
- [ ] 标记倒扣位置
- [ ] 生成倒扣报告
- [ ] 提供处理建议
### 任务 2.4:改进拔模角计算
- [ ] 集成 BRepOffsetAPI_DraftAngle
- [ ] 实现拔模方向检测
- [ ] 实现拔模干涉检测
- [ ] 处理拔模失败情况
- [ ] 验证拔模后尺寸
### 任务 2.5:铝泡沫收缩补偿
- [ ] 基于发泡倍率计算收缩
- [ ] 实现多向收缩
- [ ] 补偿后尺寸验证
- [ ] 处理不均匀收缩
### 任务 2.6:型腔分离优化
- [ ] 优化布尔运算参数
- [ ] 处理复杂几何
- [ ] 添加分离结果验证
- [ ] 处理分离失败回退
### 任务 2.7:模具块生成
- [ ] 计算模具尺寸
- [ ] 添加安全余量
- [ ] 生成 A/B 板结构
- [ ] 添加模架结构
- [ ] 验证模具强度
### 任务 2.8:分型线平滑处理
- [ ] 实现 B 样条拟合
- [ ] 处理尖角
- [ ] 保持几何精度
- [ ] 验证平滑效果
### 任务 2.9:算法性能优化
- [ ] 添加并行计算
- [ ] 优化缓存策略
- [ ] 性能测试 < 30 秒
- [ ] 内存测试 < 1GB
### 任务 2.10:阶段二集成测试
- [ ] 测试典型产品分模
- [ ] 测试复杂产品分模
- [ ] 测试多分型面产品
- [ ] 测试算法稳定性
- [ ] 性能达标
---
## 第三阶段:质量检测模块
### 任务 3.1:创建质量检测器类
- [ ] 创建 `MoldQualityInspector` 类
- [ ] 定义检测接口
- [ ] 设计结果数据结构
- [ ] 实现批量检测
### 任务 3.2:分模面平滑度检测
- [ ] 实现曲率分析
- [ ] 实现凹凸检测
- [ ] 计算平滑度评分
- [ ] 标记问题区域
### 任务 3.3:分模面连续性检测
- [ ] 实现边界检查
- [ ] 实现间隙检测
- [ ] 实现完整性验证
- [ ] 报告问题位置
### 任务 3.4:模具结构合理性检测
- [ ] 实现模具尺寸检查
- [ ] 实现壁厚检查
- [ ] 实现干涉检查
- [ ] 生成改进建议
### 任务 3.5:生产可行性评估
- [ ] 计算注塑压力
- [ ] 计算锁模力
- [ ] 估算成型周期
- [ ] 评估生产成本
### 任务 3.6:质量报告生成
- [ ] 汇总检测结果
- [ ] 生成文字说明
- [ ] 添加图表
- [ ] 支持 PDF 导出
- [ ] 报告格式规范
---
## 第四阶段:可视化和交互
### 任务 4.1:分型面可视化增强
- [ ] 设置分型面颜色
- [ ] 调整透明度
- [ ] 边缘高亮
- [ ] 与产品对比度
### 任务 4.2:分型线可视化增强
- [ ] 设置线条颜色
- [ ] 调整线条粗细
- [ ] 添加端点标记
- [ ] 动态绘制效果
### 任务 4.3:交互式分型面调整
- [ ] 实现鼠标拖拽
- [ ] 实时更新模型
- [ ] 实现撤销功能
- [ ] 实现重做功能
### 任务 4.4:交互式参数调整
- [ ] 实现滑块实时更新
- [ ] 参数变化动画
- [ ] 效果对比视图
### 任务 4.5:剖视图功能
- [ ] 实现剖切算法
- [ ] 显示内部结构
- [ ] 剖面切换动画
- [ ] 多方向剖视
### 任务 4.6:测量工具
- [ ] 实现距离测量
- [ ] 实现角度测量
- [ ] 测量结果标注
- [ ] 测量精度验证
### 任务 4.7:视角控制增强
- [ ] 添加预设视角
- [ ] 实现动画过渡
- [ ] 实现自动对准
### 任务 4.8:导出视图功能
- [ ] 实现 PNG 导出
- [ ] 支持高清截图
- [ ] 批量导出支持
---
## 第五阶段:数据接口和测试
### 任务 5.1:STEP 导出接口
- [ ] 实现 STEP 导出
- [ ] 验证导出文件
- [ ] 测试 CAD 兼容性
### 任务 5.2:JSON 数据导出
- [ ] 实现 JSON 导出
- [ ] 包含完整参数
- [ ] 包含几何数据
- [ ] 包含质量报告
### 任务 5.3:PDF 报告导出
- [ ] 实现 PDF 生成
- [ ] 添加质量报告内容
- [ ] 添加图表
- [ ] 格式规范美观
### 任务 5.4:IGES 格式支持
- [ ] 实现 IGES 导入
- [ ] 实现 IGES 导出
- [ ] 验证 CAM 兼容性
### 任务 5.5:集成测试
- [ ] 功能测试通过
- [ ] 性能测试通过
- [ ] 兼容性测试通过
- [ ] 压力测试通过
### 任务 5.6:用户验收测试
- [ ] 功能演示完成
- [ ] 用户反馈收集
- [ ] 问题修复完成
- [ ] 用户验收签字
---
## 功能验收检查表
### 核心功能
- [ ] 自动分模算法正常运行
- [ ] 分型面检测准确
- [ ] 分型线计算正确
- [ ] 拔模角处理正确
- [ ] 收缩补偿正确
### 参数系统
- [ ] 所有参数可配置
- [ ] 参数验证正确
- [ ] 参数模板可用
- [ ] 参数保存成功
### 可视化
- [ ] 3D 模型正确显示
- [ ] 分型面可视化
- [ ] 分型线可视化
- [ ] 型腔/型芯可视化
- [ ] 交互操作流畅
### 质量检测
- [ ] 平滑度检测正常
- [ ] 连续性检测正常
- [ ] 结构检测正常
- [ ] 可行性评估正常
- [ ] 报告生成正常
### 数据接口
- [ ] STEP 导出正常
- [ ] JSON 导出正常
- [ ] PDF 导出正常
- [ ] IGES 导出正常
---
## 性能验收检查表
- [ ] 分模时间 < 30 秒
- [ ] 渲染帧率 > 30 FPS
- [ ] 内存占用 < 2 GB
- [ ] 支持 5 用户并发
---
## 代码质量检查表
- [ ] 代码符合 PEP 8 规范
- [ ] 包含类型注解
- [ ] 包含文档字符串
- [ ] 单元测试覆盖
- [ ] 无安全漏洞
- [ ] 无硬编码密码
---
**文档结束**
-37
View File
@@ -1,37 +0,0 @@
# 交付物清单(本次产出)
## 1. 分析报告(Markdown)
- `docs/MOLD_ERP_ANALYSIS_REPORT.md`
- `docs/ZERO_FINISHED_INVENTORY_CERTIFICATE.md`
- `docs/PERFORMANCE_SCALABILITY_PLAN.md`
- `docs/PERFORMANCE_BENCHMARKS.md`
- `docs/UAT_CHECKLIST.md`
- `docs/INTERFACE_INTEGRATION_CATALOG_TEMPLATE.md`
- `docs/CONFLUENCE_ARCHIVE_STRUCTURE.md`
## 2. 数据库脚本(PostgreSQL)
- 冻结交付后模具订单(头+明细):`scripts/db/001_freeze_delivered_sales_orders.sql`
- 约束与索引补齐:`scripts/db/002_indexes_and_constraints.sql`
- 数据修复示例(状态归一):`scripts/db/003_data_fixups.sql`
- 分区模板(按月):`scripts/db/010_partitioning_template.sql`
- RLS 模板(按 org_id):`scripts/db/011_rls_template.sql`
- 审计追溯(old/new + 操作者/IP/UA):`scripts/db/020_audit_trail.sql`
- 审计留存清理(180 天):`scripts/db/021_audit_retention.sql`
- 慢 SQL 采样(pg_stat_statements):`scripts/db/030_pg_stat_statements.sql`
## 3. 自动化测试(pytest)
- 交付后冻结:`tests/test_sales_order_delivered_freeze.py`
- 订单/采购/库存主链路用例(参数化 ≥30):`tests/test_api_inventory_orders.py`
## 4. 代码加固点(已落地)
- 交付后冻结:应用层拒绝更新/删除/改状态/领料(`status=delivered`)。
- 状态一致性:避免写入未允许的 `pending` 状态。
- 性能优化:BOM 需求计算去 N+1;库存扣减使用原子更新降低并发超卖风险。
## 5. Word + PDF 导出建议
- 建议使用 pandoc 将 `docs/MOLD_ERP_ANALYSIS_REPORT.md` 导出为 docx/pdf,并将生成物作为 CI 产物归档。
+197
View File
@@ -0,0 +1,197 @@
# geMoldInsight 部署总览(DEPLOYMENT)
> 文档定位:**唯一的部署主题入口文档**。
> 本文负责说明当前推荐部署模式、部署文档分工与历史文档去向;不承担全部 Linux 操作细节。详细 Linux 部署步骤见 [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md),当前状态见 [STATUS.md](STATUS.md),架构边界见 [ARCHITECTURE.md](ARCHITECTURE.md)。
---
## 1. 当前推荐部署模式
当前推荐模式为:
- **unified**:frontend + unified backend + moldinsight Celery worker
原因:
- 适合本地开发与集成环境
- 前端同域反代可以面对单一 backend
- 比按路径把前端网关分流到两套后端更易维护
### 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)
---
## 2. 支持的部署模式
### 2.1 unified
一个统一后端同时挂载 moldinsight + inventory。
适合:
- 本地开发
- 测试/集成环境
- 小团队统一部署
**Compose 文件**:[docker-compose.yml](../docker-compose.yml)(**默认入口**)
**一键命令**:
```bash
docker compose up -d
```
包含服务:`frontend` + `backend`(unified 入口)+ `moldinsight-celery`。
### 2.2 moldinsight-only
只部署模具分析后端。
适合:
- 独立开放分析能力
- 异步任务与文件处理独立扩容
**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
只部署进销存后端。
适合:
- 独立部署 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)。
---
## 3. 部署文档分工
### 3.1 当前权威文档
- [DEPLOYMENT.md](DEPLOYMENT.md)
- 部署入口与文档导航
- [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md)
- Linux 环境下的详细部署操作说明
### 3.2 端口与配置说明
以下文档作为当前部署补充说明保留:
- [deployment/DEPLOY_PORT.md](deployment/DEPLOY_PORT.md)
- [deployment/PORT_CONFIG.md](deployment/PORT_CONFIG.md)
它们的职责分别是:
- `DEPLOY_PORT.md`:端口暴露、端口规划与 Nginx / 防火墙层面的说明
- `PORT_CONFIG.md`:环境变量、端口配置项与 direct run / compose 映射补充
它们是部署入口文档的补充参考,不替代本文或 [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md)。
### 3.3 历史/阶段性部署材料
以下材料属于迁移期或历史说明,不应再视为当前部署权威:
- [archive/PORT_REFACTOR_SUMMARY.md](archive/PORT_REFACTOR_SUMMARY.md)
- [archive/FRONTEND_UNIFIED_DEPLOYMENT_PLAN.md](archive/FRONTEND_UNIFIED_DEPLOYMENT_PLAN.md)
这些材料已迁入 `docs/archive/`,仅用于历史追溯,不替代当前的 [DEPLOYMENT.md](DEPLOYMENT.md) 、 [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md) 与 [deployment/DEPLOY_PORT.md](deployment/DEPLOY_PORT.md)。
---
## 4. 当前部署事实
当前部署上的几个关键事实:
- 项目保持单仓库、单数据库
- frontend 是独立前端工程
- 后端支持模块化入口
- moldinsight 的异步分析链路依赖 celery
- PostgreSQL / Redis / 对象存储通常复用服务器已有服务,而不是必须由项目 compose 自带
这些事实的当前版本以 [STATUS.md](STATUS.md) 和 [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md) 为准。
---
## 5. 相关专题文档
以下文档可作为部署/存储方向的补充参考,但不替代部署入口文档:
- [deployment/DEPLOY_PORT.md](deployment/DEPLOY_PORT.md)
- [deployment/PORT_CONFIG.md](deployment/PORT_CONFIG.md)
- [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md)
- [topics/storage/STORAGE_SETUP.md](topics/storage/STORAGE_SETUP.md)
---
## 6. 后续整理原则
部署文档后续将遵循以下规则:
- 部署入口信息只在本文维护
- 操作步骤只在 [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md) 维护
- 历史迁移说明与阶段计划迁入 `docs/archive/`
- README 只保留最短启动说明,不再承担部署手册职责
-162
View File
@@ -1,162 +0,0 @@
# geMoldInsight 演进路线图
> 本文档是代码与功能演进的执行清单,基于 2026-07-13 的全量代码体检。每项含「现象 / 证据 / 修法 / 验证」,按 P0→P3 推进,完成后勾选。
## 诊断
代码已演进到「双应用模块化」形态(`entrypoints/` + `moldinsight/` + `inventory/` + `shared/`),但有 **三处结构性缺失** 让快速迭代变贵,外加 **一批静默 bug** 正在让功能"看起来在跑其实没跑":
- **缺中间层**:业务逻辑堆在路由/编排函数里(进销存无 service 层、`process_file_core` 280 行线性函数)
- **缺契约**:前后端靠手写类型,字段已大面积漂移(财务页整页是 0)
- **缺连接**:模具分析与进销存是两个孤立产品(`STPFile` 没有 `product_id`)
---
## P0 止血:正在静默失效的功能(1–2 周)
这些不是技术债,是**现在就在坏**的东西,先修。
### P0-1 Celery worker 不连 Redis/RustFS,异步任务全坏
- **现象**:任务进度写进 Celery 私有内存,web 端永远读不到;首次上传 RustFS 直接抛 `RuntimeError("RustFS 未连接")`。
- **证据**:`redis_task_manager.connect()` / `rustfs_manager.connect()` 只在 FastAPI startup 调用(`entrypoints/moldinsight.py:42,48`),Celery 进程不跑 startup;`processing_service.py` 在 celery 内调 `update_task` 时 `is_connected=False` 走 `_fallback_set`;`rustfs_storage.py:125-126` 未连接直接抛错。`deploy/docker-compose.yml` 的 `moldinsight-celery` 服务块缺 `REDIS_PASSWORD`。
- **修法**:`celery_tasks.py` 加 `@worker_process_init` 信号,显式 `connect()` redis 与 rustfs;补齐 celery 服务的 `REDIS_PASSWORD`/`SECRET_KEY` 等环境变量,与主应用对齐。
- **验证**:上传一个 STP,Celery 路径下任务进度能从 web 端 `/api/status/{task_id}` 读到;上传后 RustFS 中能看到对象。
- **状态**:- [ ]
### P0-2 LLM 设计报告 NameError,静默失效
- **现象**:`LLM_ENABLED=true` 时设计报告功能直接没有。
- **证据**:`llm_service.py:339` 用未定义变量 `trimmed`(应为 `features`,`trimmed` 只在 `_build_side_action_prompt` 中定义),外层 `try/except` 吞掉 `NameError` 返回 `None`。
- **修法**:`trimmed` -> `features`。
- **验证**:启用 LLM 后设计报告字段非空。
- **状态**:- [ ]
### P0-3 前端财务页全字段错配
- **现象**:FinanceTab 整页 0/空;用户管理菜单永不显示(`is_superuser` 后端不返回);dashboard 成品数恒 0。
- **证据**:16 处字段名对不上,如 `total_receivable` vs `receivable_total`(`finance_schemas.py:66`)、`order_no` vs `txn_no`(`finance_schemas.py:48`)等;`App.vue:91` 读 `is_superuser` 但 `UserResponse` 无此字段。
- **修法**:短期按映射手改前端字段;长期靠 P1-3 OpenAPI 契约生成根治。
- **验证**:财务页卡片与表格显示真实数据;用户管理菜单对管理员可见。
- **状态**:- [ ]
### P0-4 OCC 线程安全自相矛盾
- **现象**:偶发崩溃,外层 `max_workers=1` 保护形同虚设。
- **证据**:`processing_service.py:50-51` 用单线程池序列化 OCC,但 `geometry_analyzer._detect_features`(`geometry_analyzer.py:81`)内部又开 `ThreadPoolExecutor(max_workers=4)` 并行操作 OCC `TopoDS_Shape`。
- **修法**:特征检测器改串行;或预处理阶段把面特征抽成纯数值,检测器只处理数值不碰 OCC。
- **验证**:压测大模型反复分析无崩溃。
- **状态**:- [ ]
### P0-5 `.env` 进了 git 历史,真实密钥泄露
- **现象**:DB/Redis/SECRET_KEY/LLM key 已进入仓库历史。
- **证据**:`git ls-files --error-unmatch .env` 命中;`git log -- .env` 有 10+ 次提交;`.env` 内含真实凭据。
- **修法**:`git rm --cached .env`(停止跟踪,保留本地,后续不再提交);`SECRET_KEY` 从默认占位符轮换为强随机值。
- **用户决策(2026-07-13)**:私有仓库,不轮换其他密钥、不重写 git 历史。
- **验证**:`git status` 显示 `.env` 不再被跟踪(`D .env`)。
- **状态**:- [x]
### P0-6 铝价路由模块化部署后丢失
- **现象**:模块化部署后 `/api/aluminum-price/*` 直接 404。
- **证据**:单体 `main.py:47,155` 挂了 `aluminum_price_router`,但 `moldinsight/api/__init__.py` 的 `_safe_include` 列表不含 `aluminum_price_routes`。
- **修法**:把 `aluminum_price_routes` 加入 `_safe_include`。
- **验证**:模块化部署下 `/api/aluminum-price/*` 可访问。
- **状态**:- [ ]
### P0-7 导出缓存无持久化回退
- **现象**:多 worker 或重启后导出返回 409。
- **证据**:`processing_service._export_shapes_cache` 是进程内 dict,`get_export_shapes` 只查内存;`_persist_step_exports` 已写磁盘 manifest 但无回读逻辑。
- **修法**:`get_export_shapes` 缓存未命中时从磁盘 manifest 回读。
- **验证**:重启后导出仍可用。
- **状态**:- [ ]
---
### P0 执行结果(2026-07-13)
- ✅ **P0-1 Celery 连接**:`celery_tasks.py` 在任务内显式 `redis_task_manager.reconnect()` + `rustfs_manager.connect()`(Redis 客户端绑定事件循环,每任务 reconnect;RustFS 同步客户端连一次复用);`deploy/docker-compose.yml` celery 服务补 `REDIS_PASSWORD`/`RUSTFS_TIMEOUT`
- ✅ **P0-2 LLM NameError**:`llm_service.py:339` `trimmed` -> `features`
- ✅ **P0-3 前端字段错配**:FinanceTab 全字段对齐 schema(summary/statement/product-statement/transaction 共 16 处);`UserResponse` 加 `is_superuser` + 统一 `_build_user_response` 构造(修用户管理菜单不显示);DashboardTab `product_count`->`finished_product_count`;PurchaseOrdersTab `received_at/paid_at`->`received_date/paid_date`;后端 `FinanceTransactionResponse` 补 `partner_name` 并批量查询客户/供应商名称
- ✅ **P0-4 OCC 线程安全**:`geometry_analyzer._detect_features` `max_workers` 4->1
- ✅ **P0-5 .env 泄露**:`git rm --cached .env` 已取消跟踪(后续不再提交);`SECRET_KEY` 从默认占位符轮换为强随机值(现有登录 token 失效)。用户决策:私有仓库,不轮换其他密钥、不重写 git 历史
- ✅ **P0-6 铝价路由**:`moldinsight/api/__init__.py` `_safe_include` 加入 `aluminum_price_routes`
- ℹ️ **P0-7 导出缓存**:经排查**非 bug**——`export_artifacts` 已写 PG+Redis(`processing_service.py:342,361`),导出端点先走 `_select_persisted_files` 从 task_data 读取(`advanced_router.py:436`),重启后正常工作;409 仅在持久化也失败时出现,"请重新分析"提示为正确行为。内存 re-export 缓存的可靠性优化归入 P1-2
**未做验证**:前端未跑 vue-tsc 构建(字段重命名属机械改动,低风险);后端未跑 pytest(需 DB/Redis 环境)。建议下次在完整环境验证。
---
## P1 结构性地基:让后续迭代不再昂贵(持续)
### P1-1 进销存抽 service 层
- **现状**:`finance_routes.py` 751 行、`sales_order_routes.py` 777 行,事务编排/库存原子更新/流水写入全耦合在 endpoint;`shared/services/` 仅 auth+redis。
- **目标**:新建 `inventory/services/`,`PurchaseOrderService.receive()`、`SalesOrderService.issue_materials()`、`FinanceService.settle()`,route 只做校验+组装。
- **状态**:- [ ]
### P1-2 moldinsight 可插拔注册表 + Stage 流水线
- **现状**:模具类型硬编码 if-else(`multi_scheme_planner.py:40`);特征检测器硬编码 6 个(`geometry_analyzer.py:76-99`);`process_file_core` 280 行。
- **目标**:`FeatureDetectorRegistry` + `MoldGeneratorRegistry`(`@register` 装饰器);`process_file_core` 拆成 Stage 链。
- **解锁**:新增模具类型、IGES/BREP、批量分析。
- **状态**:- [ ]
### P1-3 前端 OpenAPI 契约生成
- **现状**:前端 40+ 处 `any`,字段全手写已大面积错配。
- **目标**:`openapi-typescript` 从 `/openapi.json` 生成 TS 类型替换 `any`;`api.ts` 加 baseURL/拦截器/超时,按域封装 `inventoryApi`/`moldinsightApi`/`authApi`。
- **状态**:- [ ]
### P1-4 引入 Alembic,废除裸 DDL
- **现状**:无 `alembic.ini`;`init_db.py` 22 条 `ALTER TABLE ADD COLUMN IF NOT EXISTS`,无版本/无回滚;`migrate_db.py` 是 `drop_all` 破坏性脚本;两应用 startup 并发跑 DDL 争锁。
- **目标**:`alembic init`,固化版本化迁移,启动只 `upgrade head`;删 `migrate_db.py`。
- **状态**:- [ ]
### P1-5 统一材料属性源
- **现状**:材料字典在 4 处重复定义且冲突(PE 收缩率 `material_service` 0.020 vs `aluminum_foam_mold.py:92` 0.025)。
- **目标**:`MaterialService` 作为唯一源,其他模块查询。
- **状态**:- [ ]
---
### P1 执行结果(进行中)
- 🚧 **P1-1 进销存抽 service 层**(进行中):
- ✅ 建立 `inventory/services/` 层,抽出 `FinanceService`(`finance_routes` 767→123 行,业务逻辑全下沉;路由仅做参数校验+响应组装)
- ✅ 将 `schemas/` 与 `utils.py` 从 `inventory/api/` 移至 `inventory/` 顶层,打破 service↔api 循环导入(schemas 是共享 DTO、utils 是纯函数,本不应嵌在 api 层;这是正确分层)
- ✅ import 测试通过:9 finance 路由 + 55 inventory 路由全部正常加载
- ⏳ 待办:抽 `sales_order_service` / `purchase_order_service` / `inventory`+`stock_movement` service;清理死代码 `api/utils.py`(待 `git rm`)
---
## P2 功能演进:把两个产品变成一个
### P2-1 打通模具分析 -> 进销存(最高产品价值)
- **现状**:`STPFile` 无 `product_id`,moldinsight 与 inventory 零数据关联。
- **目标**:`STPFile` 加 `product_id` 外键(可空),分析完成后一键创建 `Product(finished)` 并回写。
- **状态**:- [ ]
### P2-2 真 AI 落地,砍掉假 AI
- **现状**:`ai_mold_assistant.py` 209 行纯 stub 从未被调用;`ai_parting_detector.py` GNN 框架完整但无权重;`llm_service` 是唯一真接 AI(且有 P0-2 bug)。
- **目标**:聚焦一个能跑通的 AI 能力(LLM 扩到成本估算/工艺对话);GNN 要么真训权重,要么移除 stub。
- **状态**:- [ ]
### P2-3 模具成本估算 + 批量分析
- 依赖 P1-2 完成后才有性价比。
- **状态**:- [ ]
---
## P3 工程治理(穿插顺手做)
- [ ] `create_app()` 工厂消除两入口重复引导,废弃单体 `main.py`
- [ ] 删死依赖/死代码:Kafka(零引用)、`templates/` legacy、`ai_mold_assistant` stub、`ProcessingService.__init__` 3 个死实例
- [ ] `get_db_session` 统一事务边界(commit/rollback),废除路由手动 commit
- [ ] 连接池治理(3 进程峰值 150 > PG 默认 100),考虑 PgBouncer
- [ ] 进销存 state 从模块级单例迁回 Pinia,tab 改子路由
- [ ] 统一 `/health` 响应 schema;SPA fallback 排除 `/api` 前缀避免吞 404
- [ ] CORS 收敛(`allow_origins=["*"]` + `allow_credentials=True` 不安全)
---
## 执行进度
| 阶段 | 项数 | 已完成 | 进行中 |
|------|------|--------|--------|
| P0 | 7 | 6 修复 + 1 排查 | - |
| P1 | 5 | 0 | P1-1 进行中(finance 已抽) |
| P2 | 3 | 0 | - |
| P3 | 7 | 0 | - |
+116
View File
@@ -0,0 +1,116 @@
# 配置与运行(OPERATIONS)
> 文档定位:**配置 / 启动 / 环境 / 运维硬性要求的唯一归属**。
> 部署入口与部署文档分工见 [DEPLOYMENT.md](DEPLOYMENT.md),Linux 详细步骤见 [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md);当前状态见 [STATUS.md](STATUS.md),架构见 [ARCHITECTURE.md](ARCHITECTURE.md)。
---
## 1. 配置来源与优先级
- 配置统一走**环境变量**,代码侧由 [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` 注入容器环境变量;按模式对应不同文件名(见 [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/`)。
- `SECRET_KEY`:JWT 签名密钥,**无默认**;生产必须 ≥32 字符强随机。
- `ADMIN_PASSWORD`:初始管理员密码,**无默认**;首次建库前必须设置。
- `RUSTFS_*`:对象存储(兼容 `MINIO_*` 别名写法);本地开发缺省值仅为占位,连不上会在用到存储的链路报错。
- `REDIS_*`:默认 `localhost:6379` 无密码(本地开发语义),生产必须显式覆盖;连接串唯一拼装点为 `Settings.redis_url`(Celery broker/backend 复用)。
- `MAX_FILE_SIZE`:上传文件大小上限(字节),默认 `104857600`(100MB);此前为死配置(处理器硬编码 50MB),2026-09-17 起真实生效,收紧上限需同步调整该值。
- `CORS_ORIGINS`:逗号分隔白名单;不设默认放行 `*`,**生产必须显式设置**。
- `LOG_FORMAT`:`json`(生产默认,结构化)/ `text`(开发人可读);`LOG_LEVEL`:DEBUG/INFO/WARNING/ERROR。
- `DEBUG`:`true` 时额外注册 `/api/debug/*` 调试路由(仍需登录),**生产必须为 false**。
- 新增配置项的规则:只加 `settings.py` + `.env.example`,关键依赖项不给 localhost/弱口令兜底(见 [AGENTS.md](../AGENTS.md) §2)。
## 2. 安装与环境
- 后端依赖:`pip install -r requirements.txt`。
- **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 修正,可从仓库根直接跑):
```bash
# unified(moldinsight + inventory,推荐):8000
uvicorn src.entrypoints.unified:app --reload --host 0.0.0.0 --port 8000
# moldinsight-only:8000
uvicorn src.entrypoints.moldinsight:app --reload --host 0.0.0.0 --port 8000
# inventory-only:8001
uvicorn src.entrypoints.inventory:app --reload --host 0.0.0.0 --port 8001
```
Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与 compose 中 `moldinsight-celery` 的 `command:` 覆盖同参——worker 与后端共用 `gemold-backend` 镜像,无独立 Dockerfile):
```bash
cd src && celery -A celery_app worker --concurrency=2 --loglevel=info
```
- `--concurrency=N` 即 OCC 并行分析数:每个 prefork 子进程持一个常驻 OCC 工作进程(方案 B,见 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md))——每个 OCC 工作进程是独立的 Python + OCC 运行时,**N 增大时按「worker 子进程 + OCC 子进程」双份预算内存**,并预留 PG 连接数(按 celery 角色池随子进程倍增)。
- `--max-tasks-per-child=M`(如 50):worker 子进程定期重启,连带回收其 OCC 子进程(进程级兜底,方案 A)。
前端:
```bash
cd frontend
npm run dev # Vite 开发服务器
npm run gen:api # 从根目录 openapi.json 重新生成 src/types/api.ts(见 API_CONTRACT §4)
```
探活:unified/moldinsight `GET /health` 与 `GET /api/health`;inventory `GET /health`。
## 4. Docker Compose
按"模式 ↔ 文件名"一一对应:
```bash
# 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
```
> 旧 `--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. 运行时硬性要求
- **生产环境必须显式设置**:`SECRET_KEY`、`ADMIN_PASSWORD`、`DB_*`、`CORS_ORIGINS`、`RUSTFS_*`、`REDIS_PASSWORD`、`DEBUG=false`、`LOG_FORMAT=json`。
- **单数据库**:moldinsight 与 inventory 共享同一 PostgreSQL(刻意设计,不拆库)。
- **后台任务一律走 `task_dispatcher`** 与 Celery,不要在路由里 fire-and-forget。
- **uploads/ 与 html_output/ 为运行时产物目录**,不提交、不作为配置源头。`html_output/` 自 D11 起仅作 `/html` 报告代理的**存量兜底读**(新产物直传 RustFS 报告键 `html/reports/`,worker 不再写本地卷)。
- `scripts/` 下的一次性脚本执行前先确认目标环境(多为不可逆数据迁移)。
## 6. 排障指针
| 症状 | 先看 |
|---|---|
| 起服务连不上数据库 | `.env` 的 `DB_*` 是否与服务器一致(惰性校验:import 成功 ≠ 连接正常) |
| 上传/导出报对象存储错误 | `RUSTFS_*` 四项 + [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md) |
| 分析任务一直 pending | Celery worker 是否在跑;Redis 连通性;`task_dispatcher` 日志 |
| 前端类型与接口对不上 | `openapi.json` 是否重新导出、`npm run gen:api` 是否执行([API_CONTRACT.md](API_CONTRACT.md) §4) |
| 登录 401 | `SECRET_KEY` 是否跨进程一致(JWT 校验依赖同一密钥) |
| 部署端口/反代问题 | [deployment/DEPLOY_PORT.md](deployment/DEPLOY_PORT.md)、[deployment/PORT_CONFIG.md](deployment/PORT_CONFIG.md) |
+178
View File
@@ -0,0 +1,178 @@
# geMoldInsight 演进路线图(ROADMAP)
> 文档定位:**未来演进路线与阶段计划的权威文档**。
> 本文回答“下一步准备往哪里演进、按什么阶段推进”;不负责维护当前实现状态,当前状态见 [STATUS.md](STATUS.md)。架构边界见 [ARCHITECTURE.md](ARCHITECTURE.md),当前活跃技术债见 [TECH_DEBT.md](TECH_DEBT.md)。
> 本文基于历史归档 [archive/EVOLUTION_ROADMAP.md](archive/EVOLUTION_ROADMAP.md) 收敛整理而来。
---
## 1. 演进背景
geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台层 + 独立前端”的结构,但要让后续迭代成本继续下降,仍需要在以下方向持续推进:
- 继续收敛模块边界
- 继续减少 shared 的历史耦合
- 让部署、文档、契约与代码结构保持一致
- 让 moldinsight 与 inventory 的协作关系更稳定、可维护
当前事实与最近完成项见 [STATUS.md](STATUS.md)。
---
## 2. 当前演进主线
### 2.1 主线一:模块化架构收口
目标:
- 继续巩固 `moldinsight / inventory / frontend / shared` 的边界
- 减少历史单体遗留语义
- 让 README、架构文档、部署文档与代码结构一致
重点方向:
- 继续收敛 `shared` 的职责
- 逐步明确 identity / platform 的边界语义
- 收敛历史文档与旧部署叙事
### 2.2 主线二:moldinsight 工程化增强
目标:
- 让 STEP/STP 分析链路更稳定
- 让导出、批量分析、成本估算、任务状态等链路更可靠
- 继续提高 OCC 相关处理的可维护性与可测试性
重点方向:
- ~~`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 依赖场景下的契约测试/集成测试继续补齐
### 2.3 主线三:inventory 业务层继续沉淀
目标:
- 让 inventory 从“可用”继续走向“可扩展”
- 继续将路由中的业务逻辑下沉为 service 层
- 保持与 moldinsight 的桥接模型清晰
重点方向:
- 业务 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 主线四:部署与运维一致性
目标:
- 让推荐部署模式、Compose 入口、运维文档、Nginx/端口说明不再冲突
- 让前端、后端、异步任务链路在部署说明上形成单一叙事
重点方向:
- 继续收口部署文档
- 把历史部署迁移方案移入归档
- 保持同域前端 + unified backend 的默认认知清晰
---
## 3. 下一阶段优先项
### P0:文档与边界对齐
- 建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架
- 将 README 收敛为唯一导航入口
- 收口部署重复文档并建立 archive
### P1:moldinsight API 结构整理
- ~~拆分 `advanced_router`~~(2026-09-17 批次 3 完成)
- ~~为高频接口引入 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
- 继续减少 `request.json()` 风格手动解析(存量端点已清零,新增接口守此约定)
### P2:shared/platform 边界继续收敛
- ~~梳理共享 ORM 与业务模型的归属~~(2026-09-17 批次 4 完成:ORM 已按模块拆分,跨模块只许裸 FK)
- 继续减少 shared 直接承担业务组合逻辑
- 为后续平台层命名与目录调整准备条件
### P3:专项能力继续规范化
- ~~铝价模拟数据增加显式 `source: "simulated"`~~(2026-09-18 完成:后端响应带 `source` 字段,前端按来源渲染标注,不再硬编码交易所名)
- 补专题文档的定位/边界说明
- 清理历史 checklist / tasks / report 文档的展示层级
---
### 3.1 后端设计治理批次(2026-09 设计审查产出)
> 2026-09-15 完成 moldinsight 后端设计审查,产出的具体治理批次是当前下一阶段最具体的执行计划。
> 债务明细与逐项现状见 [TECH_DEBT.md](TECH_DEBT.md) §3(D5–D14);本小节只描述批次、顺序与每批归属。
| 批次 | 主题 | 内容 | 对应债务 |
| ------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| 批次 0 | 安全与诚实(0.5–1 天) | `/api/status/{task_id}` 补鉴权 + 任务归属校验;`pythonocc_available` 真实检测;bcrypt 超长密码拒绝;SECRET_KEY / RUSTFS_* 惰性校验补齐 | D5 |
| 批次 1 | 部署正确性(1–2 天) | 主链路改走 RustFS(分派入参`file_path` → `stp_file_id`,worker 按 object_key 下载解析);compose 共享卷兜底(过渡);alembic 移出 startup(`AUTO_MIGRATE` 开关);OCC 镜像引入方式修正 + 依赖锁文件 | D6、D12、D13 |
| 批次 2 | 任务一致性模型(2–4 天) | PG 为单一事实源、Redis 仅热缓存;去掉多进程内存回退;批量元数据入库;型腔失败标 failed;持久化事务边界收口 | D7、D8、D9、D11 |
| 批次 3 | API 与代码结构(3–5 天) | `_safe_include` 失败显式化(/health 暴露缺失路由);advanced_router 拆分 + Pydantic 请求模型;async 重计算统一 executor;StorageIntegrationService 拆分;配置治理 | D1、D14 |
| 批次 4 | 架构演进(5 天+) | ~~共享 ORM 按模块拆分;OCC 吞吐方案设计先行;文档 / 契约同步~~(2026-09-17 完成) | D3、D10 |
**执行顺序建议**:批次 0 与批次 1 的 D6(RustFS 主链路)先行——前者是确认的安全漏洞,后者是部署根本性缺陷,两者互不依赖、改动可控。其余按批次顺序推进,每批完成同步 STATUS / TECH_DEBT / API_CONTRACT。
> 进度:批次 0 / 1 / 2 已于 2026-09-16 完成、批次 3 / 4 已于 2026-09-17 完成,§3.1 批次计划**全部执行完毕**;批次 4 后续专项于 2026-09-18 完成——D11(HTML 报告 RustFS 单源 + `/html` 代理路由)与 **OCC 方案 B(`run_occ` 契约进程化 + 常驻进程池 kill-on-timeout)已清偿**(部署参数方案 A 一并落地,见 [TECH_DEBT.md](TECH_DEBT.md) D10 与 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md))。遗留:D13 的 pip 全量锁文件随下次镜像构建补齐。完成明细见 [STATUS.md](STATUS.md) 与 [TECH_DEBT.md](TECH_DEBT.md) §2.5–2.8。后续优先项回到 §3 P2 / P3 与主线方向。
---
## 4. 中长期方向
### 4.1 平台层语义收敛
长期仍建议将 `shared` 逐步收敛为更清晰的平台层语义,但这应建立在:
- 当前模块边界稳定
- 共享职责分层足够清晰
- 文档与部署已经同步收口
### 4.2 文档体系持续治理
后续文档治理原则:
- README 只做入口
- 当前状态只在 [STATUS.md](STATUS.md)
- 历史材料统一入 `docs/archive/`
- 每个主题只有一篇默认权威文档
### 4.3 测试能力继续增强
重点继续放在:
- OCC 相关集成验证
- 跨模块关键链路回归测试
- 关键契约的自动化保护
---
### 4.4 专题文档持续分级
后续还会继续把专题文档区分为三类:
- 当前仍有参考价值的专题文档(保留并补定位)
- 纯阶段性任务/检查单/迁移计划(迁入 archive)
- 可被主骨架吸收的重复说明(逐步收口)
---
## 5. 与相关文档的边界
- 当前项目处于什么状态:看 [STATUS.md](STATUS.md)
- 当前架构与边界是什么:看 [ARCHITECTURE.md](ARCHITECTURE.md)
- 当前有哪些技术债:看 [TECH_DEBT.md](TECH_DEBT.md)
- 当前部署方式怎么做:看 [DEPLOYMENT.md](DEPLOYMENT.md)
- 更完整的模块化蓝图讨论:看 [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
+55
View File
@@ -0,0 +1,55 @@
# geMoldInsight 项目状态(STATUS)
> 文档定位:**唯一的「现在到哪了」**。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。)
> 2026-09-17(**批次 4(架构演进)完成,§3.1 治理批次全部执行完毕**:① D3 主体清偿——891 行的旧 `shared/models/database.py`(31 模型类三类同居,已删除)按归属拆为 [shared/models/base.py](../src/shared/models/base.py)(唯一 Base)+ [shared/models/identity.py](../src/shared/models/identity.py)(身份权限 7 表)+ [moldinsight/models/](../src/moldinsight/models/)(分析域 9 表)+ [inventory/models/](../src/inventory/models/)(进销存 15 表,catalog/warehouse/trading/finance 四文件);**三条跨模块 ORM relationship(`User.stp_files` / `STPFile.user` / `STPFile.product`)经全仓核实零使用,直接删除**——跨模块桥接收敛为裸 FK 硬规则([ARCHITECTURE.md](ARCHITECTURE.md) §5.1),单模块部署 mapper 可独立配置;约 45 处 import 全量改写(含 migrations/env.py 全量注册、scripts/ 两个一次性脚本),旧模块物理删除无兼容 facade;零调用方死方法 `db_manager.create_tables` 一并删除(拆分后会静默建残缺 schema);② D10 治理——`_reset_occ_executor` 补 `cancel_futures=True`(旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**,属数据竞争而非单纯泄漏);吞吐方案设计先行定稿 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(短期 A:celery `--concurrency` 伸缩 + `--max-tasks-per-child` 兜底,启动参数已记 [OPERATIONS.md](OPERATIONS.md) §3;中期 B:`run_occ` 契约进程化 + kill-on-timeout,待独立排期);③ 新增 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定归属边界(31 表全量注册 / 单模块独立 mapper 配置 / 旧模块无 facade);④ 顺手清偿 D15——[vite.config.ts](../frontend/vite.config.ts) 删除未用的 `mode` 参数,`vue-tsc -b` 恢复通过,前端生产构建链路解除阻断。**接口面零变化**(无路由与 schema 变更,openapi.json 不触发重导出)。**测试基线**:**125 passed, 2 skipped**(基线 122 + 新增归属测试 3 项)。**下一步**:治理批次收尾后回到主线方向;遗留项 D11(HTML RustFS 单源)、D13(pip 锁文件)、OCC 方案 B 独立批次。)
> 2026-09-17(**批次 3(API 与代码结构)完成**:① D1 清偿——592 行 advanced_router 拆为 [design_router](../src/moldinsight/api/design_router.py) / [cost_router](../src/moldinsight/api/cost_router.py) / [machining_router](../src/moldinsight/api/machining_router.py) / [export_router](../src/moldinsight/api/export_router.py) 四个子路由(端点路径不变),请求体全量 Pydantic 模型化(`request.json()` 手动解析退役,校验统一 422);② 路由装载失败显式化:`ROUTE_MODULES` 清单 + [route_registry](../src/moldinsight/api/route_registry.py),失败经 `/api/health` 呈现 `degraded` 并列出清单(`pythonocc` 改真实探测),DEBUG 下 fail fast——此前失败仅 WARNING 后静默跳过,进程带病启动不可感知;③ 纯 Python 重计算端点(设计/加工/CAM 打包)统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环(OCC 仍走单线程 executor,D10 留批次 4);④ `StorageIntegrationService`(867 行)按职责拆为 [task_storage](../src/moldinsight/services/task_storage_service.py) / [analysis_storage](../src/moldinsight/services/analysis_storage_service.py) / [file_history](../src/moldinsight/services/file_history_service.py) 三服务,无调用方死代码 `log_user_activity` 删除;⑤ D14 收尾清偿——`MAX_FILE_SIZE` 接线生效(默认上限 50MB→100MB,以 .env 为准)、celery_app 复用 `Settings.redis_url`(连接串唯一拼装点)。**连带修复**:管理员重置密码改 JSON body `{ new_password }`(原裸 str 参数被解析为 query param,前端两个调用点均发 body,功能端到端断裂)+ [UsersView.vue](../frontend/src/modules/users/UsersView.vue) 同步;Dockerfile.celery 的 FROM 对齐 `gemold-backend:latest`(此前引用不存在的 tag,干净环境 celery 镜像必构建失败)。**接口变更三件套随批完成**:openapi.json 重导出(76 paths)+ 前端 `gen:api` 再生。**连带发现**:`npm run build` 因 vite.config.ts 既有 TS6133 失败(与本项目改动无关,登记 D15)。**测试基线**:**122 passed, 2 skipped**(新增 4 个测试文件共 17 项:[test_advanced_split_contract](../tests/test_advanced_split_contract.py) / [test_route_load_status](../tests/test_route_load_status.py) / [test_config_governance](../tests/test_config_governance.py) / [test_auth_password_reset](../tests/test_auth_password_reset.py);skips 为 alembic / celery 缺失环境)。**下一步**:批次 4(架构演进:共享 ORM 拆分、OCC 吞吐方案,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
> 2026-09-16(**批次 2(任务一致性模型)完成**:① D7 清偿——Redis 进程内存回退**彻底删除**(写 no-op / 读 None,查询路径自然落 PG),PG 为任务状态单一事实源;批量元数据入库:`processing_tasks` 新增 `batch_id` 列(迁移 `a3f8c2d91e47`,**升级后首次启动自动执行**),`GET /api/batch/{batch_id}` 改为 PG 聚合查询 + `STPFile.user_id` 归属校验,删除 Redis batch key 与内存 dict 双通道;`TaskQueryService` PG 视图与 batch 聚合响应补 `progress` / `current_step`(Redis 不可用时前端仍能看到进度);② D8 清偿——型腔分模失败不再吞异常,任务标 failed 并带明确错误(已提交的几何/网格保留);③ D9 清偿——数据本体写方法只 flush,编排层分阶段原子收口(阶段 A 几何+网格、阶段 B 型腔+HTML+特征+指标+验证、完成时参数随状态一并提交),失败先 rollback 再置 failed;进度/状态更新保留即时 commit(长任务进度可见性);upload/batch/advanced 调用方补显式 commit,STPFile + ProcessingTask 原子落库消除孤儿文件记录。D11 未动(共享卷已兜正确性,留后续批次)。**测试基线**:**105 passed, 1 skipped**(新增 [tests/test_batch_status_pg.py](../tests/test_batch_status_pg.py) 4 项 + [tests/test_redis_no_fallback.py](../tests/test_redis_no_fallback.py) 3 项)。**下一步**:批次 3(API 与代码结构:`_safe_include` 失败显式化、advanced_router 拆分 + Pydantic 请求模型、配置治理,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
> 2026-09-16(**批次 1(部署正确性)完成**:① D6 清偿——分派入参 `file_path` → `stp_file_id`,处理方按 PG 元数据从 RustFS 下载源文件到任务专属临时目录(RustFS 异常时回退节点本地路径),compose 增 `uploads_data` / `html_data` 共享卷过渡兜底;② D12 清偿——新增 `AUTO_MIGRATE` 开关(默认 true 保持单机行为;多副本设 false 改部署流程单点迁移),迁移脚本与 alembic.ini 补进镜像。**连带发现并修复**:迁移目录 `alembic/` 与 alembic 包重名,应用内 `import alembic` 被遮蔽——启动期自动迁移自引入 alembic 起**从未真正生效**(异常被 init_database 吞掉只打日志),且镜像原本未打包迁移脚本;目录已改名 `migrations/`(alembic.ini + 4 处文档引用同步);③ D13 主体——Dockerfile.moldinsight 改为 conda 运行时原生执行(不再跨镜像拷贝 site-packages),基础镜像 tag 锁定;pip 全量锁文件遗留,随下次镜像构建 `pip freeze` 生成;④ compose 关键项去弱默认:`SECRET_KEY` / `ADMIN_PASSWORD` 改 `${VAR:?}` 强制显式配置(与 OPERATIONS「无默认」声明对齐),`create_admin_user` 对空口令显式报错。**测试基线**:**98 passed, 1 skipped**(新增 [tests/test_deployment_config.py](../tests/test_deployment_config.py);alembic 缺失环境 skip)。**遗留**:D13 pip 锁文件;既有问题待查——Dockerfile.celery `FROM gemold-moldinsight:latest`,而 build.sh 只构建 `gemold-backend` tag,干净机器上 build.sh 的 celery 步骤会失败。**下一步**:批次 2(任务一致性模型,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
> 2026-09-16(**批次 0(安全与诚实)完成**:① `/api/status/{task_id}` 补 JWT 鉴权 + 任务归属校验(无 token 401 / 他人或无主任务 403 / 不存在 404),归属校验收敛为 `TaskQueryService.ensure_task_access` 供 task_router 与 advanced_router 共用——技术债 [D5 清偿](TECH_DEBT.md);② 上传预检 `pythonocc_available` 从硬编码 true 改为惰性真实探测;③ bcrypt 口令治理:创建侧超 72 字节显式拒绝(此前静默截断改变有效密码),验证侧截断比较(兼容历史哈希 + 避免超长登录 500);④ `SECRET_KEY` 未配置 / `RUSTFS_*` 缺失时惰性校验抛明确错误,代码侧不再有占位弱默认。**顺带修复**:完成态任务未持久化 `analysis_metrics` 时 `/api/status` 组装视图 500(值为 None 时 `.get(key, {})` 默认值不生效)。**测试基线**:pip 无 OCC 环境 **96 passed**(新增 [tests/test_status_endpoint_auth.py](../tests/test_status_endpoint_auth.py) 8 项回归)。status 端点鉴权为接口行为变化,已同步 [API_CONTRACT.md](API_CONTRACT.md) §3.2;openapi.json 重导出仍按既有待办随下次接口变更一并执行。**下一步**:批次 1(D6 RustFS 主链路 + D12 alembic 移出 startup + D13 OCC 镜像,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
> 2026-09-15(**后端设计审查完成 → 治理计划入文档**:完成 moldinsight 后端设计审查(部署 / 任务一致性 / API / 代码结构),产出治理批次计划入 [ROADMAP.md](ROADMAP.md) §3.1(批次 0–4:安全→部署→一致性→结构→架构);新识别技术债 D5–D14 入 [TECH_DEBT.md](TECH_DEBT.md) §3——含确认安全缺口 `/api/status/{task_id}` 无鉴权、主处理链路依赖节点本地文件路径(API 与 Celery worker 容器无共享卷)等。**下一步**:按批次 0 + 批次 1 的 D6(RustFS 主链路)启动实施。)
> 最后更新:2026-09-15(**项目规范体系对齐 ipc-chat-cortex**——参考 `ipc-chat-cortex` 的 AGENTS.md + docs 规范重整本文档体系:① [AGENTS.md](../AGENTS.md) 重写——硬约束速览(新增:接口变更三件套 Pydantic→openapi.json→gen:api、配置只走 .env 且关键项不兜底、单数据库刻意设计)+ 代码地图逐文件化 + 开发约定映射表(改什么→同步什么文档);② 新增 [OPERATIONS.md](OPERATIONS.md)(配置来源与优先级 / 本地启动 / Compose / 运维硬性要求)与 [API_CONTRACT.md](API_CONTRACT.md)(端点总览 / 统一约定 / OpenAPI 类型生成流程);③ 本文件改为日志体,原静态内容分流到各归属文档(推荐部署模式→DEPLOYMENT §1,未完成项→ROADMAP/TECH_DEBT)。**验证**:openapi 导出命令实测可用(conda gemold 环境,unified app 76 paths);**待办**:checked-in `openapi.json`(2026-07-27,70 paths)已落后当前代码,下次接口变更时按 [API_CONTRACT.md](API_CONTRACT.md) §4 重导出并 `npm run gen:api`。)
> 上一条: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/](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(端口补充)三层。)
-255
View File
@@ -1,255 +0,0 @@
# 存储架构说明
## 架构概述
本项目采用 **RustFS (S3兼容)** 作为对象存储和 **PostgreSQL** 作为元数据存储的双层存储架构。
```
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (FastAPI) │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ │
┌───────▼────────┐ ┌─────────▼─────────┐
│ PostgreSQL │ │ RustFS/S3 │
│ (元数据) │ │ (对象存储) │
│ │ │ │
│ - users │ │ - stp-files │
│ - stp_files │ │ - geometry │
│ - geometry_data│ │ - mold-cavities │
│ - mold_cavity │ │ - html-files │
│ - features │ │ - user-files │
│ - logs │ │ │
└────────────────┘ └──────────────────┘
```
## PostgreSQL 数据表
### 用户管理
- `users` - 用户信息(用户名、邮箱、密码等)
### 文件管理
- `stp_files` - STP文件元数据(文件名、哈希、大小、状态等)
- `html_files` - HTML报告文件元数据
### 几何数据
- `geometry_data` - 几何分析数据(体积、表面积、边界框等)
- `mold_cavity_data` - 模具型腔数据(工艺参数、质量评估等)
- `feature_detections` - 特征检测结果(壁厚、拔模角等)
- `design_recommendations` - 设计建议(优先级、参数等)
### 任务和日志
- `processing_tasks` - 处理任务记录
- `user_activities` - 用户活动日志
- `system_logs` - 系统日志
## RustFS 存储桶
| 存储桶名称 | 用途 | 存储内容 |
|-------------|------|---------|
| `moldinsight-stp-files` | STP/STEP文件 | 用户上传的原始3D模型文件 |
| `moldinsight-geometry` | 几何数据 | 几何分析结果的JSON数据 |
| `moldinsight-mold-cavities` | 模具型腔数据 | 模具设计的详细JSON数据 |
| `moldinsight-html` | HTML文件 | 生成的HTML报告文件 |
| `moldinsight-user-files` | 用户文件 | 其他用户上传的文件 |
## 快速开始
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 启动 PostgreSQL
使用 Docker Compose(推荐):
```bash
docker-compose up -d postgres
```
或手动启动:
```bash
# 创建数据库
createdb moldinsight
# 运行数据库容器
docker run -d \
--name postgres \
-e POSTGRES_DB=moldinsight \
-e POSTGRES_USER=moldinsight_user \
-e POSTGRES_PASSWORD=your_password \
-p 5432:5432 \
postgres:15
```
### 3. 启动 MinIO
使用 Docker Compose(推荐):
```bash
docker-compose up -d minio
```
或手动启动:
```bash
docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data --console-address ":9001"
```
### 4. 配置环境变量
```bash
# 复制示例配置
cp .env.example .env
# 编辑 .env 文件,修改数据库和MinIO配置
nano .env
```
### 5. 初始化数据库和存储
```bash
# 初始化数据库
python src/database/init_db.py
# 初始化MinIO存储
python src/storage/init_storage.py
```
### 6. 启动服务
```bash
python src/main.py
```
## 使用示例
### 保存STP文件
```python
from services.storage_integration import storage_integration
from database.database import db_manager
async def upload_file(file_path: str):
async with db_manager.get_session() as session:
stp_file = await storage_integration.save_stp_file(
session=session,
file_path=Path(file_path),
original_filename="model.stp",
user_id=1
)
print(f"文件已保存,ID: {stp_file.id}")
```
### 保存几何数据
```python
async def save_geometry(stp_file_id: int, geometry_data: dict):
async with db_manager.get_session() as session:
geo_data = await storage_integration.save_geometry_data(
session=session,
stp_file_id=stp_file_id,
geometry_json=geometry_data,
analysis_method="pythonocc"
)
print(f"几何数据已保存,ID: {geo_data.id}")
```
### 获取文件数据
```python
async def get_file_data(stp_file_id: int):
async with db_manager.get_session() as session:
data = await storage_integration.get_stp_file_with_data(
session=session,
stp_file_id=stp_file_id
)
# 访问几何数据
geometry = data['geometry_data']
print(f"体积: {geometry['volume']}")
print(f"表面积: {geometry['surface_area']}")
# 访问模具型腔数据
cavity = data['mold_cavity_data']
print(f"模具材料: {cavity['mold_material']}")
# 访问特征和建议
for feature in data['features']:
print(f"特征: {feature['feature_type']}")
```
## 数据清理策略
### MinIO 对象存储
- 设置生命周期策略自动删除旧文件
- 示例:删除30天前的临时文件
### PostgreSQL
- 定期清理已删除用户的记录
- 归档超过6个月的日志数据
## 监控和维护
### 检查存储使用情况
```bash
# MinIO控制台
# http://localhost:9001
# 用户名: minioadmin
# 密码: minioadmin
```
### 数据库备份
```bash
# 备份数据库
pg_dump -h localhost -U moldinsight_user moldinsight > backup.sql
# 恢复数据库
psql -h localhost -U moldinsight_user moldinsight < backup.sql
```
## 性能优化
### PostgreSQL
- 创建适当的索引(已在模型中定义)
- 定期运行 VACUUM 和 ANALYZE
- 考虑使用连接池(已配置)
### MinIO
- 启用缓存层
- 配置CDN分发静态文件
- 使用多区域复制
## 安全建议
1. **更改默认密码**:生产环境必须更改所有默认密码
2. **启用TLS**:生产环境启用 HTTPS
3. **访问控制**:配置适当的用户权限
4. **数据加密**:敏感数据加密存储
5. **定期备份**:设置自动备份策略
## 故障排除
### 连接MinIO失败
```
错误: 对象存储连接失败
解决: 检查 MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY 配置
```
### 数据库连接失败
```
错误: 数据库连接失败
解决: 检查 DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD 配置
```
### 文件上传失败
```
错误: STP文件上传失败
解决: 检查磁盘空间、网络连接、MinIO权限
```
-827
View File
@@ -1,827 +0,0 @@
# 铝制家电包装泡沫模具分模功能开发任务清单
## 文档信息
| 项目 | 内容 |
|------|------|
| **文档名称** | 铝制家电包装泡沫模具分模功能开发任务清单 |
| **版本** | 1.0 |
| **日期** | 2026-03-13 |
| **项目** | geMoldInsight 模具分模功能增强 |
---
## 任务总览
| 阶段 | 任务数 | 预计工期 |
|------|--------|----------|
| 第一阶段:基础框架 | 8 | 2 周 |
| 第二阶段:分模算法 | 10 | 2 周 |
| 第三阶段:质量检测 | 6 | 2 周 |
| 第四阶段:可视化和交互 | 8 | 2 周 |
| 第五阶段:数据接口和测试 | 6 | 1 周 |
| **总计** | **38** | **9 周** |
---
## 第一阶段:基础框架搭建 (1-2 周)
### 任务 1.1:创建铝泡沫模具参数类
**任务描述**:创建专门针对铝泡沫材料的参数配置类
**详细说明**:
- 在 `src/core/mold_generator.py` 中添加 `AluminumFoamMoldParams` 类
- 定义铝泡沫专用参数(发泡倍率、目标密度、成型温度等)
- 实现参数验证和默认值设置
**验收标准**:
- 参数类包含所有铝泡沫专用参数
- 参数验证通过
**预计工时**:4 小时
**依赖任务**:无
---
### 任务 1.2:创建铝泡沫材料数据库
**任务描述**:建立铝泡沫材料数据库,包含常用材料参数
**详细说明**:
- 创建材料数据库类 `FoamMaterialDatabase`
- 添加 AlSi10Mg、AlSi12、纯铝泡沫等常用材料
- 支持材料查询和参数获取
**验收标准**:
- 数据库包含至少 5 种铝泡沫材料
- 支持按名称查询材料参数
**预计工时**:4 小时
**依赖任务**:1.1
---
### 任务 1.3:扩展现有模具生成器
**任务描述**:扩展 `MoldCavityGenerator` 类支持铝泡沫参数
**详细说明**:
- 添加铝泡沫参数到构造函数
- 添加材料设置方法 `set_foam_material()`
- 适配现有的分模流程
**验收标准**:
- 可以使用铝泡沫参数创建生成器
- 参数正确传递给分模流程
**预计工时**:4 小时
**依赖任务**:1.1, 1.2
---
### 任务 1.4:创建参数配置 API 接口
**任务描述**:添加前端参数配置 API
**详细说明**:
- 在 `src/api/routes.py` 中添加参数相关路由
- 实现参数获取、设置、验证接口
- 支持参数模板保存和加载
**验收标准**:
- API 可以获取和设置分模参数
- 参数验证正确返回错误信息
**预计工时**:4 小时
**依赖任务**:1.3
---
### 任务 1.5:前端参数面板开发
**任务描述**:在 Web 界面中添加分模参数配置面板
**详细说明**:
- 在 `static/vue-app.js` 中添加参数配置组件
- 实现滑块、输入框、选择框等控件
- 支持参数实时预览
**验收标准**:
- 界面显示所有可配置参数
- 参数修改正确提交到后端
**预计工时**:8 小时
**依赖任务**:1.4
---
### 任务 1.6:参数模板功能
**任务描述**:实现参数模板保存和加载功能
**详细说明**:
- 创建预设参数模板(快速、经济、高精度等)
- 支持用户保存自定义模板
- 模板存储到数据库
**验收标准**:
- 至少 3 个预设模板可用
- 用户可以保存和加载模板
**预计工时**:4 小时
**依赖任务**:1.4, 1.5
---
### 任务 1.7:参数验证逻辑
**任务描述**:实现参数合法性验证
**详细说明**:
- 验证数值范围(角度、容差等)
- 验证参数组合合法性
- 返回详细的验证错误信息
**验收标准**:
- 所有参数都有验证逻辑
- 错误信息清晰明了
**预计工时**:4 小时
**依赖任务**:1.1
---
### 任务 1.8:阶段一集成测试
**任务描述**:测试参数系统的完整性
**详细说明**:
- 测试参数设置和获取
- 测试参数验证
- 测试前端交互
**验收标准**:
- 所有功能正常运行
- 无明显 bug
**预计工时**:4 小时
**依赖任务**:1.1-1.7
---
## 第二阶段:分模算法优化 (3-4 周)
### 任务 2.1:改进法向量分析算法
**任务描述**:改进分型面检测的法向量分析算法
**详细说明**:
- 添加高斯权重计算
- 支持多点采样
- 识别主分型方向
**验收标准**:
- 算法能正确处理复杂几何产品
- 分型方向准确率 > 90%
**预计工时**:8 小时
**依赖任务**:无
---
### 任务 2.2:实现多分型面检测
**任务描述**:支持复杂产品的多个分型面
**详细说明**:
- 识别需要多次分模的区域
- 正确处理分型面优先级
- 生成有序的分型面列表
**验收标准**:
- 能正确检测 2 个以上分型面
- 分型面顺序正确
**预计工时**:12 小时
**依赖任务**:2.1
---
### 任务 2.3:倒扣区域检测
**任务描述**:自动识别产品倒扣区域
**详细说明**:
- 分析产品几何特征
- 标记倒扣区域位置
- 提供处理建议
**验收标准**:
- 能识别常见的倒扣类型
- 提供准确的倒扣位置
**预计工时**:8 小时
**依赖任务**:2.1
---
### 任务 2.4:改进拔模角计算
**任务描述**:实现完整的拔模角计算和应用
**详细说明**:
- 使用 OpenCASCADE 拔模功能
- 支持不同拔模方向
- 处理拔模干涉
**验收标准**:
- 拔模角正确应用到模型
- 无明显变形或错误
**预计工时**:12 小时
**依赖任务**:无
---
### 任务 2.5:铝泡沫收缩补偿
**任务描述**:针对铝泡沫实现特殊的收缩补偿
**详细说明**:
- 基于发泡倍率计算收缩
- 多向收缩补偿
- 补偿后尺寸验证
**验收标准**:
- 收缩补偿量准确
- 补偿后模型无异常
**预计工时**:8 小时
**依赖任务**:1.2
---
### 任务 2.6:型腔分离优化
**任务描述**:改进型腔和型芯的分离算法
**详细说明**:
- 精确的布尔运算
- 处理复杂几何
- 分离结果验证
**验收标准**:
- 型腔/型芯分离正确
- 分离过程无错误
**预计工时**:8 小时
**依赖任务**:无
---
### 任务 2.7:模具块生成
**任务描述**:生成完整的模具块结构
**详细说明**:
- 计算模具尺寸
- 添加余量
- 生成 A/B 板结构
**验收标准**:
- 模具块尺寸合理
- 包含必要的结构元素
**预计工时**:8 小时
**依赖任务**:2.6
---
### 任务 2.8:分型线平滑处理
**任务描述**:对分型线进行平滑处理
**详细说明**:
- B 样条曲线拟合
- 尖角处理
- 平滑度验证
**验收标准**:
- 分型线平滑无毛刺
- 保持原始几何精度
**预计工时**:6 小时
**依赖任务**:2.2
---
### 任务 2.9:算法性能优化
**任务描述**:优化分模算法性能
**详细说明**:
- 并行计算支持
- 缓存优化
- 增量计算
**验收标准**:
- 分模时间 < 30 秒
- 内存占用 < 1GB
**预计工时**:8 小时
**依赖任务**:2.1-2.8
---
### 任务 2.10:阶段二集成测试
**任务描述**:测试分模算法的完整流程
**详细说明**:
- 使用测试产品验证
- 对比不同参数结果
- 性能测试
**验收标准**:
- 算法稳定运行
- 结果准确合理
**预计工时**:8 小时
**依赖任务**:2.1-2.9
---
## 第三阶段:质量检测模块 (5-6 周)
### 任务 3.1:创建质量检测器类
**任务描述**:创建 `MoldQualityInspector` 质量检测类
**详细说明**:
- 设计检测器架构
- 定义检测接口
- 实现结果数据结构
**验收标准**:
- 类结构完整
- 接口定义清晰
**预计工时**:4 小时
**依赖任务**:无
---
### 任务 3.2:分模面平滑度检测
**任务描述**:检测分模面的平滑度
**详细说明**:
- 曲率分析
- 凹凸检测
- 评分计算
**验收标准**:
- 正确识别不平滑区域
- 给出评分 (0-100)
**预计工时**:8 小时
**依赖任务**:3.1
---
### 任务 3.3:分模面连续性检测
**任务描述**:检测分模面的连续性
**详细说明**:
- 边界检查
- 间隙检测
- 完整性验证
**验收标准**:
- 能识别间隙和断点
- 报告位置和大小
**预计工时**:6 小时
**依赖任务**:3.1
---
### 任务 3.4:模具结构合理性检测
**任务描述**:检测模具结构的合理性
**详细说明**:
- 模具尺寸检查
- 壁厚检查
- 干涉检查
**验收标准**:
- 识别所有结构问题
- 提供修改建议
**预计工时**:8 小时
**依赖任务**:3.1
---
### 任务 3.5:生产可行性评估
**任务描述**:评估模具的生产可行性
**详细说明**:
- 注塑压力计算
- 锁模力计算
- 成型周期估算
**验收标准**:
- 估算值在合理范围
- 提供改进建议
**预计工时**:8 小时
**依赖任务**:3.1
---
### 任务 3.6:质量报告生成
**任务描述**:生成完整的质量检测报告
**详细说明**:
- 汇总各项检测结果
- 生成 PDF 格式报告
- 支持导出
**验收标准**:
- 报告内容完整
- 格式规范
**预计工时**:6 小时
**依赖任务**:3.2-3.5
---
## 第四阶段:可视化和交互 (5-6 周)
### 任务 4.1:分型面可视化增强
**任务描述**:改进分型面的可视化效果
**详细说明**:
- 分型面颜色和透明度设置
- 边缘高亮
- 动态效果
**验收标准**:
- 分型面清晰可见
- 与产品形成对比
**预计工时**:4 小时
**依赖任务**:无
---
### 任务 4.2:分型线可视化增强
**任务描述**:改进分型线的可视化
**详细说明**:
- 线条颜色和粗细
- 端点标记
- 动态绘制效果
**验收标准**:
- 分型线清晰可见
- 便于观察细节
**预计工时**:4 小时
**依赖任务**:无
---
### 任务 4.3:交互式分型面调整
**任务描述**:支持用户拖拽调整分型面
**详细说明**:
- 鼠标拖拽事件
- 实时更新模型
- 撤销/重做支持
**验收标准**:
- 拖拽响应流畅
- 模型正确更新
**预计工时**:12 小时
**依赖任务**:4.1
---
### 任务 4.4:交互式参数调整
**任务描述**:支持实时调整参数并预览效果
**详细说明**:
- 滑块实时更新
- 参数变化动画
- 效果对比
**验收标准**:
- 调整流畅无延迟
- 效果正确显示
**预计工时**:8 小时
**依赖任务**:1.5
---
### 任务 4.5:剖视图功能
**任务描述**:添加剖视图功能
**详细说明**:
- 沿分型面剖切
- 内部结构显示
- 剖面编辑
**验收标准**:
- 剖视图正确显示
- 切换流畅
**预计工时**:8 小时
**依赖任务**:4.1
---
### 任务 4.6:测量工具
**任务描述**:添加测量工具
**详细说明**:
- 距离测量
- 角度测量
- 测量结果标注
**验收标准**:
- 测量结果准确
- 操作便捷
**预计工时**:8 小时
**依赖任务**:无
---
### 任务 4.7:视角控制增强
**任务描述**:改进视角控制
**详细说明**:
- 预设视角
- 动画过渡
- 自动对准
**验收标准**:
- 视角切换流畅
- 自动对准准确
**预计工时**:4 小时
**依赖任务**:无
---
### 任务 4.8:导出视图功能
**任务描述**:支持导出当前视图
**详细说明**:
- PNG 图片导出
- 高清截图
- 报告插图
**验收标准**:
- 导出图片清晰
- 格式正确
**预计工时**:4 小时
**依赖任务**:4.1-4.7
---
## 第五阶段:数据接口和测试 (7-8 周)
### 任务 5.1:STEP 导出接口
**任务描述**:实现 STEP 格式导出
**详细说明**:
- 使用 PythonOCC 导出 STEP
- 包含分模后模型
- 验证导出正确性
**验收标准**:
- 导出文件可被 CAD 打开
- 几何正确
**预计工时**:8 小时
**依赖任务**:无
---
### 任务 5.2:JSON 数据导出
**任务描述**:实现 JSON 格式导出
**详细说明**:
- 导出分模参数
- 导出几何数据
- 导出质量报告
**验收标准**:
- JSON 格式正确
- 数据完整
**预计工时**:4 小时
**依赖任务**:无
---
### 任务 5.3:PDF 报告导出
**任务描述**:实现 PDF 格式报告导出
**详细说明**:
- 质量检测报告
- 包含图表和说明
- 模板支持
**验收标准**:
- PDF 生成成功
- 内容完整
**预计工时**:8 小时
**依赖任务**:3.6
---
### 任务 5.4:IGES 格式支持
**任务描述**:添加 IGES 格式支持
**详细说明**:
- IGES 导入
- IGES 导出
- 格式验证
**验收标准**:
- 导出文件可被 CAM 软件使用
**预计工时**:6 小时
**依赖任务**:5.1
---
### 任务 5.5:集成测试
**任务描述**:完整的系统集成测试
**详细说明**:
- 功能测试
- 性能测试
- 兼容性测试
**验收标准**:
- 所有功能正常运行
- 达到性能指标
**预计工时**:8 小时
**依赖任务**:全部
---
### 任务 5.6:用户验收测试
**任务描述**:配合用户进行验收测试
**详细说明**:
- 演示功能
- 收集反馈
- 修复问题
**验收标准**:
- 用户满意
- 达到预期目标
**预计工时**:8 小时
**依赖任务**:5.5
---
## 任务依赖关系图
```
第一阶段: 基础框架
├── 1.1 创建参数类
├── 1.2 材料数据库 ──┐
├── 1.3 扩展生成器 ──┼── 1.4 API ──┬── 1.5 前端 ──┬── 1.6 模板 ──┬── 1.7 验证 ──→ 1.8 测试
│ │ │ │ │
└────────────────────┴──────────────┴──────────────┴──────────────┘
第二阶段: 分模算法
│
├── 2.1 法向量分析 ──→ 2.2 多分型面 ──→ 2.3 倒扣检测
│
├── 2.4 拔模角 ──────────────────────────────────────────────────────────┐
│ │
├── 2.5 收缩补偿 ◄──────────────────┐ │
│ │ │
├── 2.6 型腔分离 ──→ 2.7 模具块 ──→ 2.8 平滑处理 ──→ 2.9 优化 ──→ 2.10 测试
│ │
└────────────────────────────────────┴────────────────────────────────────┘
第三阶段: 质量检测
│
├── 3.1 检测器类 ──→ 3.2 平滑度 ──→ 3.3 连续性 ──→ 3.4 结构 ──→ 3.5 可行性 ──→ 3.6 报告
│
└────────────────────────────────────┬────────────────────────────────────┘
第四阶段: 可视化
│
├── 4.1 分型面 ──→ 4.2 分型线 ──→ 4.3 拖拽 ──→ 4.4 参数调整 ──→ 4.5 剖视
│ │ │
├── 4.6 测量 ──→ 4.7 视角 ──→ 4.8 导出 ◄─────────────┘
│
└────────────────────────────────────┬────────────────────────────────────┘
第五阶段: 接口和测试
│
├── 5.1 STEP ──→ 5.2 JSON ◄──┐
│ │
├── 5.3 PDF ◄─────────────────┼── 5.4 IGES ──→ 5.5 集成 ──→ 5.6 验收
│ │
└──────────────────────────────┘
```
---
## 资源分配
| 角色 | 任务 | 预计工时 |
|------|------|----------|
| 后端开发 | 1.1-1.4, 2.1-2.10, 3.1-3.6, 5.1-5.4 | 180 小时 |
| 前端开发 | 1.5-1.6, 4.1-4.8 | 60 小时 |
| 测试 | 1.8, 2.10, 5.5-5.6 | 32 小时 |
| **总计** | | **272 小时** |
---
## 风险评估
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 算法复杂度高 | 时间延误 | 预留缓冲时间,分阶段交付 |
| OpenCASCADE 兼容问题 | 功能受限 | 多种实现方案,准备备选 |
| 性能不达标 | 用户体验差 | 持续优化,必要时降级功能 |
| 需求变更 | 计划调整 | 敏捷开发,快速迭代 |
---
**文档结束**
+298
View File
@@ -0,0 +1,298 @@
# geMoldInsight 技术债与治理计划(TECH_DEBT)
> 文档定位:**当前活跃技术债与治理计划的权威文档**。
> 本文回答“现在还有哪些重要债务、优先级如何、下一步怎么处理”;不负责维护当前实现状态,当前状态见 [STATUS.md](STATUS.md)。架构边界见 [ARCHITECTURE.md](ARCHITECTURE.md),未来路线见 [ROADMAP.md](ROADMAP.md)。
> 本文由归档文档 [archive/MOLDINSIGHT_TECH_DEBT_PLAN.md](archive/MOLDINSIGHT_TECH_DEBT_PLAN.md) 收敛整理而来,保留活跃债务与治理结论,弱化详细实施流水账。
---
## 1. 当前技术债概览
当前最主要的技术债集中在三个区域:
- **moldinsight API 与处理链路的结构收口**
- **inventory 复杂业务域的 service 继续下沉**
- **文档 / 部署 / 历史语义与当前代码现状未完全一致**
已经完成的高优先级治理不再作为持续待办反复展开,当前重点聚焦在“还没完成、且值得继续推进”的部分。
---
## 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 → §3 D5)
- bcrypt 超 72 字节显式拒绝 + 截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失明确报错
### 2.2 静默失败与可用性
- `detect-undercuts` 改为基于真实 shape 分析
- OCC 超时后重建 executor(短期)→ D10 方案 B 进程化彻底替换
- 后台任务统一分派,补强引用与并发控制
### 2.3 状态存储与缓存
- Redis 任务状态改为 Hash 字段级更新,兼容旧格式
- 内存回退彻底删除,PG 为任务状态单一事实源(原 D7)
- 完成态任务视图缓存;导出缓存与持久化链路收口
### 2.4 架构与代码清理
- 删除旧单体入口与死代码(`db_manager.create_tables` / `log_user_activity` / `CADExporter.export_mold_results` / `getAluminumPrice` 等)
- 惰性配置校验,提升可测试性
- Generator 公共接口提取 + 契约测试
- 共享 ORM 按模块拆分,跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1)
---
## 3. 当前活跃技术债
### D1. `advanced_router` 过大,职责混杂 —— 已清偿(2026-09-17,批次 3)
修复内容:
- 592 行的 advanced_router 按职责拆为四个子路由,端点路径全部不变:[design_router.py](../src/moldinsight/api/design_router.py)(布局/冷浇/模架/倒扣)、[cost_router.py](../src/moldinsight/api/cost_router.py)、[machining_router.py](../src/moldinsight/api/machining_router.py)(CAM/碰撞/刀路/电极/仿真)、[export_router.py](../src/moldinsight/api/export_router.py)(导出/下载/建议)
- 全部请求体改 Pydantic 模型(`request.json()` 手动解析退役),校验失败统一 422;`_get_cached_import` 上提为 [core_modules.py](../src/moldinsight/api/core_modules.py) 共用
- 契约测试:[tests/test_advanced_split_contract.py](../tests/test_advanced_split_contract.py)(路径不丢、鉴权不丢、422 语义、纯计算端点冒烟)
- openapi.json 重导出 + 前端 `gen:api`(接口变更三件套随批完成)
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。
### 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` 删除(前端此前保留了一份本地硬编码函数,已无调用方)
~~原现状 / 影响~~:铝价接口返回走势数据但无来源声明,前端原硬编码"上海期货交易所"字样,与实际模拟数据不一致,属虚假来源声明。
### D3. shared/platform 边界仍需继续收敛 —— 主体已清偿(2026-09-17 批次 4 + 2026-09-18 后续)
已完成部分(修复文件清单见 [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)
仍保留的收敛方向(低优先级,随实际重构推进):
- identity / platform 的边界语义(ROADMAP §2.1):平台表与模块表的命名/注释口径随实际重构推进
优先级:**P3**(仅剩 identity/platform 语义注释口径)
### D4. 文档现状 / 规划 / 历史混放 —— 已清偿(2026-09-22)
修复内容(保留编号以维持引用稳定):
- 已建立 `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 等已清偿项补齐时间戳
~~原现状 / 影响~~:TECH_DEBT §2 与 §3 内容重复膨胀,文档目录结构清晰度受新成员评估影响。
### D5. `/api/status/{task_id}` 未鉴权(安全缺口)—— 已清偿(2026-09-16,批次 0)
修复内容(保留编号以维持 D6–D14 引用稳定):
- 端点补 `Depends(get_current_active_user)`;归属校验收敛为 `TaskQueryService.ensure_task_access`,task_router 与 advanced_router 共用(advanced_router 原私有 `_ensure_task_access` 改为委托)
- 语义:无 token 401、他人/无主任务 403(无主不等于公共)、任务不存在 404
- 回归测试:[tests/test_status_endpoint_auth.py](../tests/test_status_endpoint_auth.py)
- 接口行为变化已同步 [API_CONTRACT.md](API_CONTRACT.md) §3.2
~~原现状 / 影响~~:端点未挂鉴权,匿名可枚举任务号拉取完整分析视图。
### D6. 主处理链路依赖节点本地文件路径 —— 已清偿(2026-09-16,批次 1)
修复内容(保留编号以维持引用稳定):
- 分派入参收敛为 `stp_file_id`(`dispatch_processing` 与 Celery 任务签名同步变更):处理方按 PG 元数据从 RustFS 下载源文件到任务专属临时目录(保留原始文件名,下游产物命名不变),任务结束即清理([processing_service.py](../src/moldinsight/services/processing_service.py) `_materialize_source_file`)
- RustFS 不可用时回退 `STPFile.file_path` 节点本地路径;compose 为 backend / celery 增加共享卷 `uploads_data` / `html_data` 作过渡兜底(HTML 产物跨容器写读同源问题一并兜住,正式修复在 D11)
~~原现状 / 影响~~:worker 直读 API 节点本地路径,双容器部署必然 `FileNotFoundError`。
### D7. Redis 降级为进程内 dict,多副本状态不一致 —— 已清偿(2026-09-16,批次 2)
修复内容(比原建议更彻底:完全删除内存回退,而非仅限 DEBUG):
- [redis_task_manager.py](../src/shared/services/redis_task_manager.py) 删除全部 `_fallback_*` 进程内存存储:Redis 不可用时写 no-op、读返回 None(Redis 仅热缓存,任务状态事实源在 PG,缓存缺失不影响正确性)
- 批量元数据入库:`processing_tasks` 新增 `batch_id` 列(迁移 `a3f8c2d91e47`),`GET /api/batch/{batch_id}` 改为按列聚合查询 + `STPFile.user_id` 归属校验,删除 Redis batch key 与进程内 dict 双通道
- `TaskQueryService` 的 PG 组装视图补 `progress` / `current_step`(Redis 不可用时前端轮询仍能看到进度);batch 聚合响应同步补 `current_step`
~~原现状 / 影响~~:Redis 故障时状态静默降级各进程内存,多副本互不可见、同任务不同副本读到不同状态。
### D8. 型腔生成失败被静默标记为 completed —— 已清偿(2026-09-16,批次 2)
修复内容:
- [processing_service.py](../src/moldinsight/services/processing_service.py) `_step_generate_cavity` 不再吞异常:分模失败直接向编排层传播 → 任务 failed(error_message 说明型腔阶段失败);已提交的几何/网格数据保留,用户可凭失败原因重新分析
- 未采用 `completed_with_fallback`:多一个状态值会扩散到前端所有状态分支,failed + 明确错误更诚实且成本低
~~原现状 / 影响~~:型腔失败被吞掉继续主流程,最终 completed,"完成"状态不可信。
### D9. 持久化事务边界破碎 —— 已清偿(2026-09-16,批次 2)
修复内容(进度可见性与原子性折中设计):
- **数据本体写方法只 flush 不 commit**:`save_stp_file` / `save_geometry_data` / `save_mesh_data` / `save_mold_cavity_data` / `save_html_file` / `save_features_and_recommendations` / `update_task_parameters` / `update_stp_file_analysis_summary` / `_save_analysis_metrics` / `_save_verification_metrics`
- **编排层分阶段收口**([processing_service.py](../src/moldinsight/services/processing_service.py)):阶段 A = 几何+网格(解析后确定成果,原子提交);阶段 B = 型腔+HTML+特征+指标+摘要+验证(结果包原子提交);完成时先 flush 任务参数、完成状态提交时一并落库(completed 即完整)
- **失败路径先 rollback 再置 failed**:未提交半成品回滚,失败状态单独提交,不出现"completed 但数据残缺"
- **保留即时 commit**:`update_task_status` / `update_stp_file_status`(处理中进度需跨事务对外可见,分钟级长任务不能憋在一个大事务里)
- 调用方补显式 commit:upload_router / batch_router(分派前置事务,STPFile + ProcessingTask 原子,消除孤儿文件记录)、advanced_router 导出两处
~~原现状 / 影响~~:各存储方法内部自行 commit,型腔保存失败留半成品数据且任务仍 completed。
### D10. OCC 全局单线程串行 + 超时重建泄漏线程 —— 已清偿(2026-09-18,方案 B 实施)
**方案 B(常驻 OCC 进程池,kill-on-timeout 根治泄漏)已实施**(2026-09-18):
- `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)(新增;详见 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md) §5)
- 超时/崩溃 = terminate() 换新补位——**残留线程泄漏根治**(C++ 栈由进程边界回收);OCC segfault 不再波及 API/worker 主进程
- 调用点全部迁移(解析/网格/型腔/分析/倒扣/STEP 转换),TopoDS 形状不跨进程(`generate_cavity` 的方案形状 STEP 导出改在子进程内持久化,返回 export_manifest)
- 顺带删除:内存形状缓存链(`_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` 进 compose + `.env.example`(`--max-tasks-per-child` 仍保留为进程回收兜底;2026-09-24 起 worker 与后端共用 `gemold-backend` 镜像,Dockerfile.celery 已移除,参数经 compose `command:` 覆盖与环境变量传递)。
保留为已知约束(非待修缺陷):
- 单进程内 OCC 串行是正确性要求(OCC 非线程安全),吞吐扩展走多进程(方案 A/B)
- 每个操作从 STP 原件重新加载形状(STEP 重载成本秒级)——进程隔离的设计取舍,见 OCC_THROUGHPUT §1.2/§5
优先级:~~**P3**~~ **已清偿**
### D11. HTML 报告本地磁盘与 RustFS 双写双读 —— 已清偿(2026-09-18)
修复内容:
- **写侧**:可视化产物不再落节点本地 `html_output/`——HTMLGenerator 每任务写临时目录([processing_service.py](../src/moldinsight/services/processing_service.py)),`.html` / `_summary.json` / `_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`(文件名寻址,同源同秒重复分析即覆盖刷新);`HTMLFile` 表仅存元数据
- **读侧**:`/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 命中结果取决于负载均衡,跨副本文件不共享。
### D12. 应用启动时自动执行 alembic 迁移
### D12. 应用启动时自动执行 alembic 迁移 —— 已清偿(2026-09-16,批次 1)
修复内容:
- 新增 `AUTO_MIGRATE` 开关(settings / .env.example / compose 透传):默认 `true` 保持单机开发行为;多副本部署设 `false`,由部署流程单点执行 alembic CLI 或 `python -m shared.database.init_db`
- **连带发现并修复两个使自动迁移从未真正生效的缺陷**:
1. 迁移目录 `alembic/` 与 alembic 包重名——应用内 `import alembic` 命中本地目录(namespace package)遮蔽真实包,启动期迁移异常被 `init_database` 吞掉只打日志;已改名 `migrations/`(alembic.ini `script_location` 与 4 处文档引用同步)
2. 镜像未打包迁移脚本与 alembic.ini,容器内迁移必然失败——Dockerfile.base / Dockerfile.moldinsight 已补 `COPY migrations/` + `COPY alembic.ini`
### D12. Pydantic v2 弃用项清理 —— 已清偿(2026-09-22)
修复内容(保留编号以维持引用稳定):
- 全仓 14 处 `class Config:` + `from_attributes = True` 统一迁移为 `model_config = ConfigDict(from_attributes=True)`:
- [src/inventory/schemas/customer_schemas.py](../src/inventory/schemas/customer_schemas.py) / [supplier_schemas.py](../src/inventory/schemas/supplier_schemas.py) / [warehouse_schemas.py](../src/inventory/schemas/warehouse_schemas.py) / [inventory_schemas.py](../src/inventory/schemas/inventory_schemas.py) / [stock_movement_schemas.py](../src/inventory/schemas/stock_movement_schemas.py) / [product_schemas.py](../src/inventory/schemas/product_schemas.py) / [material_schemas.py](../src/inventory/schemas/material_schemas.py) / [purchase_order_schemas.py](../src/inventory/schemas/purchase_order_schemas.py) / [sales_order_schemas.py](../src/inventory/schemas/sales_order_schemas.py) / [finance_schemas.py](../src/inventory/schemas/finance_schemas.py)
- [src/shared/services/auth_routes.py](../src/shared/services/auth_routes.py) UserResponse / RoleResponse / PermissionResponse
- 同步收掉 [src/shared/services/auth_service.py](../src/shared/services/auth_service.py) 中 `datetime.utcnow()` 的遗留 deprecation:3 处 token / last_login 写入改用 `datetime.now(timezone.utc)`,与 Pydantic 无关但同属“现代化弃用清理”范畴
- 语义保持:仅切换 Pydantic v2 配置语法 + UTC 时区语义,字段 / OpenAPI / JWT 行为零变化
- 验证:`pytest tests/ -q` **126 passed, 4 skipped**,deprecation warning 全部清零
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(镜像引入已清偿;锁文件流程已固化,待首次构建落盘)
现状:
- ~~从 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**(流程已固化,剩"首次构建后落盘"一次性产物)
### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3)
修复内容:
- ~~RUSTFS_* 弱默认~~(批次 0 代码侧去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(批次 1 改 `${VAR:?}` 强制显式配置)
- ~~MAX_FILE_SIZE 死配置~~(批次 3):upload/batch 路由的 `FileHandler` 接 `settings.UPLOAD_DIR / settings.MAX_FILE_SIZE`(此前处理器硬编码 50MB;接线后默认上限变为 100MB,以 .env 为准)
- ~~celery_app 重复拼装~~(批次 3):删除自行 load_dotenv + 手拼 REDIS URL,broker/backend 复用 `Settings.redis_url`(新增 property,连接串唯一拼装点)
优先级:已清偿
### D15. 前端 `npm run build` 因既有 TS 错误失败(批次 3 连带发现)—— 已清偿(2026-09-17,批次 4)
修复内容:
- [vite.config.ts](../frontend/vite.config.ts) 删除未使用的回调参数 `mode`(TS6133 源头,一行修复);`vue-tsc -b` 实测通过,生产构建链路恢复
~~原现状 / 影响~~:`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. 当前推荐治理顺序
> 注:2026-09-15 后端设计审查后,治理**执行顺序**以 [ROADMAP.md](ROADMAP.md) §3.1 批次计划为准(批次 0–4);D5–D14 的批次归属见该表。本节保留原有优先项作为补充说明。
### 第一优先级
1. ~~`advanced_router` 拆分~~(2026-09-17 批次 3 完成,见 D1)
2. ~~高优先级接口补 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
3. 文档主骨架收口并减少重复说明
### 第二优先级
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)
---
## 5. 治理原则
### 5.1 先收口接口与边界,再做更大结构调整
当前最值得继续投入的,不是大规模目录重写,而是:
- 先把接口边界、文档边界、部署边界收清楚
- 再逐步推进 shared/platform 的后续调整
### 5.2 优先做“降低长期维护成本”的改动
优先处理:
- 重复逻辑
- 模糊边界
- 静态契约缺失
- 文档漂移风险
### 5.3 已解决问题不再长期占据主文档中心
已经完成且稳定的问题,只在本文保留摘要结论;详细实施流水账后续归档,不继续作为主文档主体。
---
## 6. 与相关文档的边界
- 当前项目状态:看 [STATUS.md](STATUS.md)
- 当前架构与模块边界:看 [ARCHITECTURE.md](ARCHITECTURE.md)
- 后续演进路线:看 [ROADMAP.md](ROADMAP.md)
- 部署主题入口:看 [DEPLOYMENT.md](DEPLOYMENT.md)
- 原始 moldinsight 细粒度债务记录:看 [archive/MOLDINSIGHT_TECH_DEBT_PLAN.md](archive/MOLDINSIGHT_TECH_DEBT_PLAN.md)
+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(端口补充)三层。)
@@ -0,0 +1,5 @@
# geMoldInsight 后端模块化重构蓝图(归档)
> 文档定位:**模块化设计蓝图档案 / 补充设计材料**。
> 当前架构与边界的默认入口见 [../ARCHITECTURE.md](../ARCHITECTURE.md),当前状态见 [../STATUS.md](../STATUS.md),未来演进路线见 [../ROADMAP.md](../ROADMAP.md)。
> 本文保留更完整的模块化设计背景、目标与分阶段思考,用于追溯设计决策,不再保留在 `docs/` 顶层作为默认入口。
@@ -0,0 +1,7 @@
# 铝制家电包装泡沫模具分模功能开发检查清单(归档)
> 文档定位:**阶段性检查清单 / 历史材料**,不再作为当前权威文档。
> 当前项目状态见 [../STATUS.md](../STATUS.md),当前架构边界见 [../ARCHITECTURE.md](../ARCHITECTURE.md),当前活跃技术债见 [../TECH_DEBT.md](../TECH_DEBT.md)。
> 若需了解当前模具分析方向,请优先参考主骨架文档,而不是本文的阶段 checklist。
本文保留的是 2026-03-13 铝泡沫模具分模功能增强时期的检查清单,用于记录当时的开发跟踪方式。
@@ -1,5 +1,8 @@
# Confluence 归档目录结构(建议)
> 文档定位:**外部文档归档结构建议稿**。
> 本文描述的是面向 Confluence/知识库归档时的目录建议,不作为当前仓库内文档体系的权威说明。当前仓库文档入口见 [../../README.md](../../README.md) 与 [README.md](README.md)。
## 业务流程
- 01 端到端流程(客户订单→采购→到货→生产→交付)
+6
View File
@@ -0,0 +1,6 @@
# 交付物清单(归档)
> 文档定位:**一次性交付产物索引 / 历史材料**。
> 本文记录某轮分析/整改时的交付物集合,不作为当前项目状态或当前文档导航入口。当前默认入口见 [../../README.md](../../README.md),当前状态见 [../STATUS.md](../STATUS.md)。
保留本文的目的主要是追溯当时的分析交付范围,而不是指导当前项目维护。
+5
View File
@@ -0,0 +1,5 @@
# geMoldInsight 演进路线图(原始执行记录,归档)
> 文档定位:**历史路线与执行记录原文 / 归档材料**。
> 当前默认路线文档见 [../ROADMAP.md](../ROADMAP.md),当前状态见 [../STATUS.md](../STATUS.md),当前技术债见 [../TECH_DEBT.md](../TECH_DEBT.md)。
> 本文保留较细粒度的历史诊断、执行清单与过程记录,仅用于追溯,不再作为顶层默认文档。
@@ -0,0 +1,19 @@
# 前端独立部署 + 统一后端入口实施计划(归档)
> 文档定位:**阶段性实施计划 / 历史材料**,不再作为当前部署权威文档。
> 当前部署入口见 [../DEPLOYMENT.md](../DEPLOYMENT.md),Linux 详细部署步骤见 [../deployment/LINUX_SETUP.md](../deployment/LINUX_SETUP.md),当前项目状态见 [../STATUS.md](../STATUS.md)。
本文保留的是一次针对“前端独立部署 + unified backend”方向的实施计划,用于记录当时的设计思路与迁移目标。
当前项目的默认阅读方式已经调整为:
- 部署主题入口: [../DEPLOYMENT.md](../DEPLOYMENT.md)
- 当前推荐方案与当前事实: [../STATUS.md](../STATUS.md)
- 架构边界: [../ARCHITECTURE.md](../ARCHITECTURE.md)
如果你正在查找**当前有效的部署方式**,请不要以本文作为默认依据,而应优先参考上述主文档。
---
# 原始内容
> 目标:在已经切换到“前端独立部署 + 同域反代”的基础上,进一步取消前端 Nginx 对 `/api` 的路径级分流,改为反代到一个真正的 **unified backend**,一次性解决长期维护成本。
@@ -0,0 +1,5 @@
# moldinsight 模块技术债务分析与重构计划(原始记录,归档)
> 文档定位:**moldinsight 技术债原始分析与实施记录 / 归档材料**。
> 当前默认技术债文档见 [../TECH_DEBT.md](../TECH_DEBT.md),当前状态见 [../STATUS.md](../STATUS.md)。
> 本文保留更细粒度的问题清单、实施记录与阶段性说明,仅用于追溯,不再作为顶层默认文档。
@@ -1,5 +1,8 @@
# 模具制造进销存核心模块分析报告(代码基线:geMoldInsight)
> 文档定位:**业务分析/审计型报告文档**。
> 本文保留一次特定分析基线下的观察结论与流程梳理,不作为当前项目状态或当前架构的权威说明。当前状态见 [../STATUS.md](../STATUS.md),当前架构见 [../ARCHITECTURE.md](../ARCHITECTURE.md),演进路线见 [../ROADMAP.md](../ROADMAP.md)。
## 0. 范围与术语映射
- 客户订单(Customer Order):本仓库实现为 SalesOrder(销售订单),其业务语义更贴近“模具订单/按单生产订单”。对应表:`sales_orders`、`sales_order_items`。
+22
View File
@@ -0,0 +1,22 @@
# 端口配置历史说明(归档)
> 文档定位:**历史迁移说明 / 不再作为当前部署权威文档**。
> 当前部署入口见 [../DEPLOYMENT.md](../DEPLOYMENT.md),Linux 详细部署步骤见 [../deployment/LINUX_SETUP.md](../deployment/LINUX_SETUP.md)。
本文件保留为历史说明。
它所描述的“单体应用单一端口配置”思路,已经不再能完整代表当前 geMoldInsight 的模块化架构。
当前项目已演进为:
- gemold 模块可独立部署
- inventory 模块可独立部署
- unified 作为组合模式存在
- gemold 与 inventory 应分别考虑端口与网关暴露方式
因此,端口配置的当前权威说明已转移到以下文档:
- [../DEPLOYMENT.md](../DEPLOYMENT.md)
- [../deployment/LINUX_SETUP.md](../deployment/LINUX_SETUP.md)
- [../deployment/DEPLOY_PORT.md](../deployment/DEPLOY_PORT.md)
- [../deployment/PORT_CONFIG.md](../deployment/PORT_CONFIG.md)
+37
View File
@@ -0,0 +1,37 @@
# 文档归档说明(archive)
> 文档定位:**历史文档与阶段性材料归档目录**。
> archive 仅保存历史迁移说明、阶段性计划与已不再作为默认入口的旧文档;当前权威内容请优先查看 `docs/` 主骨架:
> - [../STATUS.md](../STATUS.md)
> - [../ARCHITECTURE.md](../ARCHITECTURE.md)
> - [../ROADMAP.md](../ROADMAP.md)
> - [../TECH_DEBT.md](../TECH_DEBT.md)
> - [../DEPLOYMENT.md](../DEPLOYMENT.md)
本目录用于存放:
- 历史迁移说明
- 阶段性实施计划
- 已不再作为默认入口的旧文档
当前已归档:
- [PORT_REFACTOR_SUMMARY.md](PORT_REFACTOR_SUMMARY.md)
- [FRONTEND_UNIFIED_DEPLOYMENT_PLAN.md](FRONTEND_UNIFIED_DEPLOYMENT_PLAN.md)
- [TASKS_ALUMINUM_FOAM_MOLD.md](TASKS_ALUMINUM_FOAM_MOLD.md)
- [CHECKLIST_ALUMINUM_FOAM_MOLD.md](CHECKLIST_ALUMINUM_FOAM_MOLD.md)
- [DELIVERABLES.md](DELIVERABLES.md)
- [EVOLUTION_ROADMAP.md](EVOLUTION_ROADMAP.md)
- [MOLDINSIGHT_TECH_DEBT_PLAN.md](MOLDINSIGHT_TECH_DEBT_PLAN.md)
- [BACKEND_MODULARIZATION_BLUEPRINT.md](BACKEND_MODULARIZATION_BLUEPRINT.md)
- [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/):已迁移的铝泡沫专题历史材料
部署相关的归档文档仅用于历史追溯;当前对应入口请查看:
- [../DEPLOYMENT.md](../DEPLOYMENT.md)
- [../deployment/LINUX_SETUP.md](../deployment/LINUX_SETUP.md)
- [../deployment/DEPLOY_PORT.md](../deployment/DEPLOY_PORT.md)
+7
View File
@@ -0,0 +1,7 @@
# 铝制家电包装泡沫模具分模功能开发任务清单(归档)
> 文档定位:**阶段性任务清单 / 历史材料**,不再作为当前权威文档。
> 当前项目状态见 [../STATUS.md](../STATUS.md),当前架构边界见 [../ARCHITECTURE.md](../ARCHITECTURE.md),当前活跃技术债见 [../TECH_DEBT.md](../TECH_DEBT.md)。
> 若需了解当前模具分析方向,请优先参考主骨架文档,而不是本文的阶段任务分解。
本文保留的是 2026-03-13 铝泡沫模具分模功能增强时期的任务拆解,用于记录当时的实施计划与阶段安排。
@@ -1,5 +1,8 @@
# “零成品库存”证明报告(geMoldInsight)
> 文档定位:**特定业务口径下的专题证明/分析报告**。
> 本文解释“零成品库存”这一业务与财务口径,不作为当前项目整体状态的权威说明。当前状态见 [../STATUS.md](../STATUS.md),相关业务分析见 [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)。
## 1. 结论
系统不设置成品入库、成品出库、销售退货等成品库存模块;系统库存口径仅覆盖“物料(material)”,成品(finished/模具)仅作为订单交付对象,不进入库存核算链路。
@@ -1,5 +1,7 @@
# AI 智能引擎设计文档
> 文档定位:**AI 能力方向的设计性/专题性文档**。
> 本文描述的是 AI 引擎的设计设想与能力规划,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),当前架构边界见 [../../../ARCHITECTURE.md](../../../ARCHITECTURE.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 一、AI 引擎架构
### 1.1 整体架构
@@ -1,5 +1,7 @@
# AI + FreeCAD 集成方案
> 文档定位:**AI / FreeCAD 集成方向的专题设计文档**。
> 本文描述的是集成设想、能力规划与差距分析,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),当前架构边界见 [../../../ARCHITECTURE.md](../../../ARCHITECTURE.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 一、项目概述
本文档描述 geMoldInsight 项目集成 AI 智能引擎和 FreeCAD 的完整方案,实现真实的模具型腔生成和 G 代码输出功能。
@@ -1,5 +1,8 @@
# 铝制家电包装泡沫模具分模功能技术规格说明书
> 文档定位:**铝泡沫模具分模方向的专题规格文档**。
> 本文保留该方向的需求背景、规格设想与能力边界,不作为当前项目整体状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),总体架构见 [../../../ARCHITECTURE.md](../../../ARCHITECTURE.md),活跃技术债见 [../../../TECH_DEBT.md](../../../TECH_DEBT.md)。
## 文档信息
| 项目 | 内容 |
@@ -1,5 +1,7 @@
# 性能基准定义(建议)
> 文档定位:**性能基准与压测口径的专题参考文档**。
> 本文给出建议性性能指标与测试数据口径,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 1. 核心接口基准
| 场景 | 接口 | 指标 |
@@ -1,5 +1,7 @@
# 性能与扩展性评估补充(模具订单/采购主线)
> 文档定位:**性能与扩展性方向的专题规划文档**。
> 本文描述的是性能评估、慢 SQL 发现、扩展路线等补充规划,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 1. 高并发冲突面与加固点
### 1.1 新增/修改模具订单的锁冲突来源
+166 -125
View File
@@ -1,170 +1,211 @@
# 部署时端口配置说明
# 模块化部署端口说明
## 生产环境部署端口配置
> 文档定位:**当前部署下的端口规划补充说明**。
> 部署入口与当前推荐方案见 [../DEPLOYMENT.md](../DEPLOYMENT.md),Linux 部署步骤见 [LINUX_SETUP.md](LINUX_SETUP.md)。
> 本文档负责 **当前模块化部署模式** 下的端口暴露、端口规划与 Nginx / 防火墙层面的补充说明,不再以历史单体 `src.main:app` 作为默认前提。
本项目现在使用统一的端口配置文件 `.env`,部署时需要相应调整配置。
当前推荐部署对象:
### Gunicorn 启动配置
- frontend(Nginx,同域入口)
- unified backend
- moldinsight Celery worker(无 HTTP 端口)
**推荐方式(使用 .env 配置):**
以下基础设施默认由服务器现有服务提供,不在本项目 compose 中重复部署:
- PostgreSQL
- Redis
- MinIO / RustFS(moldinsight 需要)
---
## 1. 推荐端口规划
| 组件 | 默认端口 | 说明 |
|---|---:|---|
| frontend | 80 | 前端 Nginx,同域入口 |
| unified backend | 8000 | 当前推荐统一后端 |
| moldinsight API | 8000 | 模具分析独立部署时使用 |
| inventory API | 8001 | 进销存独立部署时使用 |
| PostgreSQL | 5432 | 共享数据库 |
| Redis | 6379 | 共享队列/缓存 |
| MinIO API | 9000 | 对象存储接口 |
| MinIO Console | 9001 | 对象存储控制台 |
> Celery worker 不直接暴露 HTTP 端口。
---
## 2. 三种部署模式下的端口
### 2.1 moldinsight-only
- 对外开放:`8000`
- 依赖:PostgreSQL、Redis、MinIO/RustFS
- 可选:前置 Nginx 暴露 80/443
### 2.2 inventory-only
- 对外开放:`8001`
- 依赖:PostgreSQL、Redis
- 不要求对象存储
### 2.3 unified
当前推荐由 unified backend 提供单一后端入口:
- 对外开放:`8000`(或由前置 Nginx / 网关统一暴露 80/443)
- 依赖:PostgreSQL、Redis、MinIO / RustFS
- 配套:moldinsight Celery worker 不直接暴露 HTTP 端口
在生产环境中,仍推荐通过同域 Nginx / 网关统一对外暴露 80/443,再反代到 unified backend。
---
## 3. Docker Compose 端口来源
当前主部署文件:
- [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 外部端口(浏览器入口,推荐直接访问)
- 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=10003
INVENTORY_PORT=10004
```
对应 compose 行为:
- moldinsight:`${MOLDINSIGHT_PORT:-10003}:8000`
- inventory:`${INVENTORY_PORT:-10004}:8001`
---
## 4. 直接运行时的端口约定
### moldinsight-only
```bash
# 创建启动脚本
cat > start_production.sh << 'EOF'
#!/bin/bash
cd /opt/moldinsight/moldinsight_project
source venv/bin/activate
# 从 .env 读取端口配置
if [ -f .env ]; then
PORT=$(grep '^PORT=' .env | cut -d'=' -f2)
else
PORT=8000
fi
# 启动服务
gunicorn src.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:${PORT}
EOF
chmod +x start_production.sh
./start_production.sh
uvicorn src.entrypoints.moldinsight:app --host 0.0.0.0 --port 8000
```
### systemd 服务配置
### inventory-only
创建 `/etc/systemd/system/moldinsight.service`:
```ini
[Unit]
Description=MoldInsight Geometry Analysis Service
After=network.target postgresql.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/moldinsight/moldinsight_project
Environment=PATH=/opt/moldinsight/moldinsight_project/venv/bin
Environment="PORT=8000"
ExecStart=/opt/moldinsight/moldinsight_project/venv/bin/gunicorn src.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:${PORT}
Restart=always
[Install]
WantedBy=multi-user.target
```bash
uvicorn src.entrypoints.inventory:app --host 0.0.0.0 --port 8001
```
**重要:** 在 `[Service]` 部分添加 `Environment="PORT=8000"`,或在 `.env` 文件中配置 `PORT=8000`。
如果改端口:
- moldinsight 改 `--port`
- inventory 改 `--port`
- 同步更新 Nginx / 防火墙 / 前端 base URL
### Nginx 反向代理配置
---
## 5. 前端联动
如果前端与后端分开部署,需要与前端环境变量保持一致。
建议前端支持:
### unified 模式
```env
VITE_API_BASE_URL=https://api.example.com
```
### split 模式
```env
VITE_AUTH_API_BASE_URL=https://auth.example.com
VITE_MOLDINSIGHT_API_BASE_URL=https://moldinsight.example.com
VITE_INVENTORY_API_BASE_URL=https://inventory.example.com
```
当前详细策略见:
- [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](../archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
---
## 6. Nginx 示例
### moldinsight-only
```nginx
upstream moldinsight_backend {
server 127.0.0.1:8000; # 对应 .env 中的 PORT
}
server {
listen 80;
server_name your-domain.com;
server_name moldinsight.example.com;
location / {
proxy_pass http://moldinsight_backend;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
location /static {
alias /opt/moldinsight/moldinsight_project/static;
}
### inventory-only
location /html_output {
alias /opt/moldinsight/moldinsight_project/html_output;
```nginx
server {
listen 80;
server_name inventory.example.com;
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Docker 部署
---
Docker 部署自动从 `.env` 读取配置,无需额外设置:
## 7. 防火墙建议
如果不通过 Nginx 统一入口而是直接暴露服务端口,则应显式开放:
```bash
# .env 文件配置
PORT=8000 # 容器内端口
HOST_PORT=8080 # 宿主机端口
# moldinsight
sudo ufw allow 8000/tcp
# 启动
docker-compose up -d
# inventory
sudo ufw allow 8001/tcp
```
### 修改生产环境端口
生产环境更推荐:
- 外部只开放 80/443
- 内部仅开放 8000/8001 给 Nginx 或内网访问
1. **编辑 .env 文件**
```bash
PORT=9000 # 修改应用端口
```
---
2. **重启服务**
```bash
# systemd
sudo systemctl restart moldinsight
# Docker
docker-compose down && docker-compose up -d
# 手动启动
./start_production.sh
```
3. **更新 Nginx 配置(如果使用)**
```nginx
upstream moldinsight_backend {
server 127.0.0.1:9000; # 更新为新端口
}
```
```bash
sudo nginx -t && sudo nginx -s reload
```
### 防火墙配置
如果修改了端口,需要更新防火墙规则:
## 8. 快速检查
```bash
# UFW (Ubuntu/Debian)
sudo ufw allow 9000/tcp
sudo ufw delete allow 8000/tcp # 删除旧端口
# firewall-cmd (CentOS/RHEL)
sudo firewall-cmd --permanent --add-port=9000/tcp
sudo firewall-cmd --permanent --remove-port=8000/tcp
sudo firewall-cmd --reload
curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8001/health
```
### 健康检查
如果只部署单模块,只检查对应服务即可。
修改端口后,更新健康检查命令:
---
```bash
# 检查服务状态
curl http://localhost:9000/health
curl http://your-domain.com/health
```
## 9. 结论
## 快速参考
在当前模块化架构下:
| 部署方式 | 端口配置位置 | 重启命令 |
|---------|------------|---------|
| 直接运行 | `.env` 中的 `PORT` | Ctrl+C 后重新运行 |
| Gunicorn | `.env` 中的 `PORT` | `systemctl restart moldinsight` |
| Docker | `.env` 中的 `PORT` 和 `HOST_PORT` | `docker-compose restart` |
| Nginx代理 | Nginx配置中的 `proxy_pass` | `nginx -s reload` |
## 注意事项
⚠️ **重要:**
1. 所有端口配置统一在 `.env` 文件中管理
2. 修改端口后需要同步更新相关配置(Nginx、防火墙等)
3. 确保新端口没有被其他服务占用
4. 生产环境建议使用 Nginx 反向代理,对外提供 80/443 端口
5. .env 文件不应提交到版本控制系统,使用 `.env.example` 作为模板
- moldinsight 与 inventory 应视为两个独立后端模块
- `unified` 是当前推荐部署模式,由 unified backend 提供单一后端入口
- 端口应按模块与部署模式清晰分配;生产环境通常通过同域 Nginx / 网关统一对外暴露 80/443
+339 -178
View File
@@ -1,177 +1,342 @@
# MoldInsight Linux 部署指南
# geMoldInsight Linux 部署指南
## 系统要求
- Linux 系统 (Ubuntu 20.04+ / CentOS 8+)
- Python 3.8+
- PostgreSQL 12+
> 文档定位:**Linux 环境下的详细部署操作文档**。
> 当前部署主题入口见 [../DEPLOYMENT.md](../DEPLOYMENT.md),当前项目状态见 [../STATUS.md](../STATUS.md),当前架构边界见 [../ARCHITECTURE.md](../ARCHITECTURE.md)。
> 本文档描述的是 **当前模块化架构** 下的 Linux 部署方式,而不是历史单体入口方案。
当前项目支持三种部署模式:
- **unified**:frontend + unified backend + moldinsight Celery worker,统一对外部署(当前推荐)
- **moldinsight-only**:仅部署模具分析后端
- **inventory-only**:仅部署进销存后端
项目保持:
- **同一 Git 仓库**
- **同一 PostgreSQL 数据库**
- **共享认证体系**
- **按模块独立部署**
- **复用服务器上已存在的 PostgreSQL / Redis / RustFS(或 MinIO 兼容存储)**
详细架构蓝图见:
- [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](../archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
---
## 1. 系统要求
### 基础要求
- Linux(Ubuntu 22.04+ / Debian 12+ / CentOS Stream 9+)
- Python 3.12
- Git
- 服务器上已可访问的 PostgreSQL 15+
- 服务器上已可访问的 Redis 7+
## 1. 环境准备
### 按模块附加要求
#### moldinsight / unified 需要
- 服务器上已可访问的 MinIO 或 RustFS 兼容对象存储
- PythonOCC 运行环境
- Celery worker(推荐与 moldinsight 一起部署)
#### inventory-only 需要
- PostgreSQL
- Redis
- 不依赖 MinIO / RustFS
- 不依赖 Celery worker
---
## 2. 代码获取与目录约定
### 安装系统依赖
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install python3 python3-pip python3-venv postgresql postgresql-contrib git
# CentOS/RHEL
sudo yum update
sudo yum install python3 python3-pip postgresql postgresql-server git
```
### 配置PostgreSQL
```bash
# 启动PostgreSQL服务
sudo systemctl start postgresql
sudo systemctl enable postgresql
# 创建数据库和用户
sudo -u postgres psql
```
在PostgreSQL中执行:
```sql
CREATE DATABASE moldinsight;
CREATE USER molduser WITH PASSWORD 'moldpassword';
GRANT ALL PRIVILEGES ON DATABASE moldinsight TO molduser;
\q
```
## 2. 项目部署
### 克隆或复制项目
```bash
# 如果使用Git
cd /opt
sudo git clone <your-repo-url> moldinsight
sudo chown -R $USER:$USER moldinsight
cd moldinsight
# 或者直接复制项目文件到Linux服务器
sudo git clone <your-repo-url> geMoldInsight
sudo chown -R $USER:$USER geMoldInsight
cd geMoldInsight
```
### 创建Python虚拟环境
后续示例默认项目位于:
```bash
cd moldinsight_project
python3 -m venv venv
source venv/bin/activate
/opt/geMoldInsight
```
### 安装依赖
---
## 3. Python 环境准备
```bash
python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
## 3. 环境配置
> 如果需要 moldinsight 分析能力,请额外准备 PythonOCC 运行环境。该依赖通常通过 conda 或预构建运行镜像提供,而不是直接由 pip 安装。
---
## 4. 环境变量配置
复制模板:
### 修改环境配置文件
编辑 `.env` 文件:
```bash
nano .env
cp .env.example .env
```
修改为Linux环境的配置:
```env
# 数据库配置(Linux环境)
DATABASE_URL=postgresql+asyncpg://molduser:moldpassword@localhost:5432/moldinsight
或部署场景参考:
# 服务配置
DEBUG=false
```bash
cp deploy/.env.example .env
```
至少需要配置(并指向服务器上已存在的基础设施):
```env
HOST=0.0.0.0
PORT=8000
# Redis配置(可选)
REDIS_HOST=localhost
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=moldinsight
DB_USER=moldinsight_user
DB_PASSWORD=change-me
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
# Kafka配置(可选)
KAFKA_BOOTSTRAP_SERVERS=localhost:9092
KAFKA_SECURITY_PROTOCOL=PLAINTEXT
SECRET_KEY=change-me-in-production
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=minioadmin
RUSTFS_SECRET_KEY=minioadmin
```
### 创建必要的目录
说明:
- `RUSTFS_*` 仅 **moldinsight / unified** 模式需要
- `inventory-only` 可不使用对象存储
- 当前配置读取实现见 [settings.py](../../src/shared/config/settings.py)
---
## 5. 数据库初始化
项目当前使用 Alembic 管理迁移,应用启动时也会执行初始化流程。
首次部署建议:
1. 确保服务器上的 PostgreSQL / Redis / 对象存储服务已可访问
2. 启动应用前确认 `.env` 中的 `DB_HOST` / `REDIS_HOST` / `RUSTFS_ENDPOINT` 指向真实服务地址
3. 让应用执行初始化/升级逻辑
相关实现参考:
- [init_db.py](../../src/shared/database/init_db.py)
> 当前项目是 **单数据库** 设计,因此 unified / moldinsight-only / inventory-only 都连接到同一个数据库与同一 migration head。
---
## 6. 启动方式
## 6.0 frontend(同域反代入口)
当前推荐把前端作为独立静态站点部署,并通过同域 Nginx 反代到 unified backend:
- `/` → 前端静态资源与 SPA 路由
- `/api` → unified backend
- `/health` → unified backend
- `/html` → unified backend(内部再提供 moldinsight 分析产物)
如果使用 [docker-compose.yml](../../docker-compose.yml)(unified 模式)的 `frontend` 服务,则该入口已经内置在前端 Nginx 镜像中。
---
## 6.1 inventory-only
```bash
mkdir -p uploads html_output logs
chmod 755 uploads html_output logs
source .venv/bin/activate
uvicorn src.entrypoints.inventory:app --host 0.0.0.0 --port 8001
```
## 4. 启动服务
适合:
- 只提供进销存能力
- 不需要模具分析与异步任务
---
## 6.2 moldinsight-only
### 开发模式启动
```bash
cd moldinsight_project
source venv/bin/activate
python src/main.py
source .venv/bin/activate
uvicorn src.entrypoints.moldinsight:app --host 0.0.0.0 --port 8000
```
### 生产环境启动(使用Gunicorn)
适合:
- 单独部署模具分析能力
- 文件上传 / 分析 / 导出 / 批量分析
### moldinsight Celery worker
建议同时启动 worker:
```bash
# 安装Gunicorn
pip install gunicorn uvloop httptools
# 启动服务
cd moldinsight_project
source venv/bin/activate
gunicorn src.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
source .venv/bin/activate
celery -A src.celery_app.celery_app worker --loglevel=info
```
## 5. 系统服务配置(可选)
> moldinsight 的异步处理链路依赖 Celery + Redis;若只启动 HTTP 服务而不启动 worker,上传分析任务可能无法完整处理。
---
## 6.3 unified
`unified` 是当前推荐的默认部署方式,适合 frontend 同域反代到单一 backend 的本地开发、集成环境与统一部署场景。
如需按模块独立部署,则使用 `moldinsight-only` 或 `inventory-only` 入口;它们仍共享同一个仓库、同一个数据库与同一套基础设施。
当前入口与部署编排见:
- [../../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)
---
## 7. systemd 部署示例
## 7.1 inventory-only 服务
创建:
### 创建systemd服务文件
```bash
sudo nano /etc/systemd/system/moldinsight.service
sudo nano /etc/systemd/system/moldinsight-inventory.service
```
添加以下内容:
```ini
[Unit]
Description=MoldInsight Geometry Analysis Service
After=network.target postgresql.service
Description=geMoldInsight Inventory API
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/moldinsight/moldinsight_project
Environment=PATH=/opt/moldinsight/moldinsight_project/venv/bin
ExecStart=/opt/moldinsight/moldinsight_project/venv/bin/gunicorn src.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
WorkingDirectory=/opt/geMoldInsight
Environment=PATH=/opt/geMoldInsight/.venv/bin
ExecStart=/opt/geMoldInsight/.venv/bin/uvicorn src.entrypoints.inventory:app --host 0.0.0.0 --port 8001
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### 启用并启动服务
启用:
```bash
sudo systemctl daemon-reload
sudo systemctl enable moldinsight
sudo systemctl start moldinsight
sudo systemctl status moldinsight
sudo systemctl enable moldinsight-inventory
sudo systemctl start moldinsight-inventory
```
## 6. Nginx反向代理配置(可选)
---
## 7.2 moldinsight-only API 服务
创建:
### 安装Nginx
```bash
# Ubuntu/Debian
sudo apt install nginx
# CentOS/RHEL
sudo yum install nginx
sudo nano /etc/systemd/system/moldinsight-moldinsight.service
```
### 创建Nginx配置文件
```ini
[Unit]
Description=geMoldInsight MoldInsight API
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/geMoldInsight
Environment=PATH=/opt/geMoldInsight/.venv/bin
ExecStart=/opt/geMoldInsight/.venv/bin/uvicorn src.entrypoints.moldinsight:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
---
## 7.3 moldinsight Celery worker 服务
创建:
```bash
sudo nano /etc/nginx/sites-available/moldinsight
sudo nano /etc/systemd/system/moldinsight-celery.service
```
添加以下内容:
```ini
[Unit]
Description=geMoldInsight MoldInsight Celery Worker
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/geMoldInsight
Environment=PATH=/opt/geMoldInsight/.venv/bin
ExecStart=/opt/geMoldInsight/.venv/bin/celery -A src.celery_app.celery_app worker --loglevel=info
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
---
## 8. Nginx / 前端同域反代示例
当前仓库已提供前端 Nginx 配置:
- [deploy/nginx/frontend.conf](../../deploy/nginx/frontend.conf)
如果不使用仓库内 `frontend` 容器,也应遵循同样原则:
- `/` 提供前端静态资源与 SPA fallback
- `/api/` 反代后端
- `/health` 反代后端
- `/html/` 反代 moldinsight
### 8.1 inventory-only
```nginx
server {
listen 80;
server_name your-domain.com;
server_name inventory.example.com;
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 8.2 moldinsight-only
```nginx
server {
listen 80;
server_name moldinsight.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
@@ -180,102 +345,98 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /opt/moldinsight/moldinsight_project/static;
expires 30d;
}
}
```
### 启用站点并重启Nginx
```bash
sudo ln -s /etc/nginx/sites-available/moldinsight /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
### 8.3 unified(示意)
## 7. 防火墙配置
统一对外时,可由网关做模块路由,也可直接暴露统一组合后的应用入口。组合层标准化完成前,更推荐通过部署编排或网关来实现统一对外访问。
---
## 9. 验证部署
### 9.1 健康检查
inventory-only:
```bash
# Ubuntu/Debian (ufw)
sudo ufw allow 80
sudo ufw allow 8000
sudo ufw allow ssh
sudo ufw enable
# CentOS/RHEL (firewalld)
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=8000/tcp
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reload
curl http://127.0.0.1:8001/health
```
## 8. 验证部署
moldinsight-only:
### 检查服务状态
```bash
# 检查应用服务
curl http://localhost:8000/health
# 检查数据库连接
sudo -u postgres psql -d moldinsight -c "SELECT version();"
curl http://127.0.0.1:8000/health
```
### 测试文件上传
访问 `http://your-server-ip:8000` 上传STP文件测试功能。
### 9.2 基础能力验证
## 9. 故障排除
#### inventory-only
- 登录接口可用
- `/api/products` 返回数据
- `/api/inventory` 返回数据
### 常见问题
#### moldinsight-only
- 登录接口可用
- `/api/upload` 可访问
- 上传后 worker 能正常消费任务
- `/api/status/{task_id}` 能看到进度
1. **数据库连接失败**
- 检查PostgreSQL服务状态:`sudo systemctl status postgresql`
- 验证数据库连接:`psql -h localhost -U molduser -d moldinsight`
---
2. **端口被占用**
- 检查端口使用:`netstat -tulpn | grep 8000`
- 修改端口或停止占用进程
## 10. 常见问题
3. **权限问题**
- 确保目录权限正确:`chmod 755 uploads html_output logs`
- 检查文件所有者:`ls -la`
### 1. 为什么不再推荐 `src.main:app`?
因为当前项目已演进为模块化结构,`src.main:app` 更适合作为过渡兼容入口,而不是长期部署标准。应优先围绕 [entrypoints/](../../src/entrypoints/) 部署。
4. **依赖安装失败**
- 更新pip:`pip install --upgrade pip`
- 使用国内镜像:`pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
### 2. inventory-only 为什么不需要对象存储?
因为对象存储主要服务于 moldinsight 分析产物(HTML、导出文件等)。纯 inventory 部署不需要这部分基础设施。
## 10. 备份和恢复
### 3. moldinsight-only 为什么建议同时部署 Celery?
因为模具分析任务通常走异步处理链路,仅启动 API 而不启动 worker,会影响上传后的任务处理。
---
## 11. Docker Compose 说明
仓库根目录维护 3 个独立 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` |
> 注意:各 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 / RustFS
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息;前端则由 `frontend` 容器独立提供(仅 unified 模式包含),通过同域反代转发到后端。
镜像构建:
### 数据库备份
```bash
# 备份数据库
sudo -u postgres pg_dump moldinsight > moldinsight_backup.sql
# 恢复数据库
sudo -u postgres psql -d moldinsight < moldinsight_backup.sql
bash deploy/build.sh # base → backend → frontend(celery 复用 backend 镜像)
# 或让 compose 构建:docker compose up -d --build
# 注意:docker compose up -d 对本地已有同名镜像不会自动重建,更新代码后需 --build
```
### 文件备份
```bash
# 备份上传的文件和配置
tar -czf moldinsight_backup.tar.gz uploads/ html_output/ .env requirements.txt
```
---
## 快速启动脚本
## 12. 推荐阅读
创建启动脚本 `start.sh`:
```bash
#!/bin/bash
cd /opt/moldinsight/moldinsight_project
source venv/bin/activate
python src/main.py
```
赋予执行权限:
```bash
chmod +x start.sh
./start.sh
```
现在您的MoldInsight项目已经可以在Linux环境下正常运行!
- [README.md](../../README.md)
- [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](../archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
- [DEPLOY_PORT.md](./DEPLOY_PORT.md)
- [PORT_CONFIG.md](./PORT_CONFIG.md)
+134 -78
View File
@@ -1,107 +1,163 @@
# 端口配置说明
# 端口配置说明(模块化架构)
## ⚠️ 重要提示
> 文档定位:**模块化部署下的端口与环境变量配置补充说明**。
> 当前部署主题入口见 [../DEPLOYMENT.md](../DEPLOYMENT.md),详细 Linux 部署步骤见 [LINUX_SETUP.md](LINUX_SETUP.md)。
> 本文只补充环境变量、端口配置项与 direct run / compose 的映射;端口规划与对外暴露方式以 [DEPLOY_PORT.md](./DEPLOY_PORT.md) 为准。
本项目现在采用**统一端口配置管理**,所有端口配置集中在一个地方:
当前配置中主要需要区分:
**唯一修改端口的地方:** `.env` 文件中的端口配置部分
- **moldinsight API 端口**
- **inventory API 端口**
- **数据库 / Redis / 对象存储端口**
- **前端访问地址**
## 配置说明
---
编辑项目根目录下的 `.env` 文件:
## 1. 当前主配置位置
```bash
# ================================
# 端口配置 - 唯一修改端口的地方
# ================================
# 应用端口(容器内端口)
PORT=8000
# Docker映射到宿主机的端口(docker-compose使用)
HOST_PORT=10001
# ================================
对于 Docker Compose 部署,当前主要端口配置来源于:
- [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 / 对象存储容器。
核心环境变量:
```env
FRONTEND_PORT=10003 # unified 模式浏览器入口(前端 Nginx 对外)
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署时使用
INVENTORY_PORT=10004 # inventory-only 独立部署时使用
# 注意:unified backend 不暴露宿主机端口,仅经前端 /api 反代(docker 网络内部 backend:8000 互通)
```
### 端口含义
其余基础设施通常为:
| 变量 | 用途 | 默认值 | 说明 |
|------|------|--------|------|
| `PORT` | 应用监听端口 | 8000 | FastAPI/Uvicorn 服务监听的端口 |
| `HOST_PORT` | 宿主机映射端口 | 10001 | Docker Compose 映射到宿主机的端口 |
### 使用场景
#### 1. 本地直接运行(Python)
```bash
python src/main.py
```env
DB_PORT=5432
REDIS_PORT=6379
```
服务将在 `http://localhost:8000` 启动(使用 `PORT` 配置)
#### 2. Docker Compose 运行
```bash
docker-compose up
对象存储常见端口(由服务器既有服务提供):
```env
RUSTFS_ENDPOINT=http://localhost:9000
```
服务将在 `http://localhost:10001` 访问(使用 `HOST_PORT` 配置)
容器内部使用 `PORT` 配置的端口(8000)
#### 3. 修改端口
---
**场景A:只想修改外部访问端口(Docker)**
```bash
# .env 文件
PORT=8000 # 容器内不变
HOST_PORT=8080 # 宿主机改为8080
## 2. 各端口的含义
| 变量 / 端口 | 用途 |
|---|---|
| `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 |
| `9001` | MinIO 控制台 |
---
## 3. 推荐配置方式
### 3.1 moldinsight-only
```env
MOLDINSIGHT_PORT=8000
DB_PORT=5432
REDIS_PORT=6379
```
访问地址:`http://localhost:8080`
**场景B:修改应用端口(容器内/本地运行)**
```bash
# .env 文件
PORT=9000 # 应用改为9000
HOST_PORT=10001 # 宿主机映射到10001
### 3.2 inventory-only
```env
INVENTORY_PORT=8001
DB_PORT=5432
REDIS_PORT=6379
```
- 本地运行:`http://localhost:9000`
- Docker运行:`http://localhost:10001` (映射到容器内9000)
**场景C:同时修改两个端口**
```bash
# .env 文件
PORT=9000
HOST_PORT=9000
### 3.3 full / 模块同时部署
```env
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
DB_PORT=5432
REDIS_PORT=6379
```
- 本地运行:`http://localhost:9000`
- Docker运行:`http://localhost:9000`
## 配置文件说明
---
### 配置读取优先级
## 4. direct run 与 Compose 的映射
1. **`config/settings.py`** - 从 `.env` 读取 `PORT` 和 `HOST`
2. **`src/main.py`** - 从 `settings` 获取端口配置
3. **`docker-compose.yml`** - 从 `.env` 读取 `HOST_PORT` 和 `PORT`
通过直接运行或 Docker Compose 部署时,端口含义保持一致,但映射方式不同。
### 相关文件
### direct run
- **`.env`** - ⭐ 唯一需要修改的配置文件
- **`config/settings.py`** - 配置读取逻辑(无需修改)
- **`src/main.py`** - 使用配置启动服务(无需修改)
- **`docker-compose.yml`** - Docker端口映射(自动读取 `.env`)
- **`start.sh` / `start_fixed.sh`** - 启动脚本(自动读取 `.env`)
moldinsight:
## 常见问题
```bash
uvicorn src.entrypoints.moldinsight:app --port 8000
```
### Q: 为什么 Docker 宿主机端口和应用端口分开配置?
A: 这样可以灵活调整容器端口而不影响外部访问,也避免端口冲突。
inventory:
### Q: 修改后需要重启吗?
A: 是的,修改 `.env` 后需要重启服务才能生效:
- 本地运行:Ctrl+C 停止后重新 `python src/main.py`
- Docker: `docker-compose down && docker-compose up`
```bash
uvicorn src.entrypoints.inventory:app --port 8001
```
### Q: 如何避免端口冲突?
A: 确保 `HOST_PORT` 不与其他服务冲突,可以使用 `netstat -an | grep <端口>` 检查端口占用情况。
### Docker Compose
### Q: 可以使用 80 端口吗?
A: 可以,但需要管理员权限:
- Linux/Mac: 使用 sudo
- Docker: 需要容器有足够权限
- 生产环境建议使用反向代理(如 Nginx)
Compose 通过端口映射暴露服务:
- moldinsight → `${MOLDINSIGHT_PORT}:8000`
- inventory → `${INVENTORY_PORT}:8001`
当前实际定义见:
- [docker-compose.yml](../../docker-compose.yml)(unified / moldinsight-only)
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)(inventory-only 的 `INVENTORY_PORT`)
---
## 5. 与前端配置的关系
前端是否使用 unified / split deployment,会影响前端 API 地址配置。
### unified
- 一个 API 基地址
### split
- moldinsight 与 inventory 各自基地址
因此,修改后端端口后,可能还需要同步:
- 前端 `.env`
- Nginx 反向代理
- 浏览器访问地址
---
## 6. 推荐实践
1. **本地开发**
- moldinsight:8000
- inventory:8001
2. **服务器部署**
- 外网只暴露 80/443
- Nginx 反代到 8000 / 8001
3. 修改端口后,同步检查 `.env`、Compose 端口映射、前端环境变量与反向代理配置
---
## 7. 关联文档
- [LINUX_SETUP.md](./LINUX_SETUP.md)
- [DEPLOY_PORT.md](./DEPLOY_PORT.md)
- [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](../archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
- [README.md](../../README.md)
-192
View File
@@ -1,192 +0,0 @@
# 端口配置重构总结
## 修改内容
本次重构将项目的端口配置统一到 `.env` 文件中,确保整个项目只有一个地方需要修改端口。
## 修改的文件
### 1. ⭐ `.env` - 唯一配置入口
**变更:** 添加了统一的端口配置区域
```bash
# ================================
# 端口配置 - 唯一修改端口的地方
# ================================
# 应用端口(容器内端口)
PORT=8000
# Docker映射到宿主机的端口(docker-compose使用)
HOST_PORT=10001
# ================================
```
### 2. `src/main.py`
**变更:** 从硬编码的环境变量读取改为从 `config.settings` 读取
```python
# 修改前
host = os.getenv('HOST', '0.0.0.0')
port = int(os.getenv('PORT', '8000'))
# 修改后
from config.settings import settings
# ...
host=settings.HOST,
port=settings.PORT
```
### 3. `docker-compose.yml`
**变更:** 端口映射从硬编码改为从环境变量读取
```yaml
# 修改前
ports:
- "10001:8000"
environment:
- PORT=8000
# 修改后
ports:
- "${HOST_PORT:-10001}:${CONTAINER_PORT:-8000}"
environment:
- HOST=${HOST:-0.0.0.0}
- PORT=${CONTAINER_PORT:-8000}
```
### 4. `start.sh` 和 `start_fixed.sh`
**变更:** 自动从 `.env` 读取端口并显示正确的访问地址
```bash
# 添加
PORT=$(grep '^PORT=' .env 2>/dev/null | cut -d'=' -f2 || echo '8000')
echo "🌐 服务将在 http://localhost:${PORT} 启动"
```
### 5. `README.md`
**变更:** 更新访问说明,提示端口配置位置
### 6. 新增文件
- `.env.example` - 配置文件模板
- `PORT_CONFIG.md` - 端口配置详细说明
- `DEPLOY_PORT.md` - 部署时端口配置指南
## 配置读取流程
```
.env 文件
↓
config/settings.py (读取 PORT 和 HOST)
↓
src/main.py (使用 settings.PORT)
↓
uvicorn 启动服务
```
Docker 部署流程:
```
.env 文件
↓
docker-compose.yml (读取 HOST_PORT 和 PORT)
↓
容器映射和内部启动
```
## 如何修改端口
### 方法 1:修改应用端口
```bash
# 编辑 .env
PORT=9000 # 修改此行
```
- 本地运行:`http://localhost:9000`
- Docker运行:需同时修改 `HOST_PORT=9000`
### 方法 2:修改 Docker 外部访问端口
```bash
# 编辑 .env
HOST_PORT=8080 # 修改此行(PORT 保持不变)
```
- Docker运行:`http://localhost:8080`
- 容器内仍使用 PORT 配置的端口
## 测试验证
### 测试 1:本地运行
```bash
# 修改 .env 中的 PORT
PORT=9999
# 启动服务
python src/main.py
# 验证
curl http://localhost:9999/health
```
### 测试 2:Docker 运行
```bash
# 修改 .env
PORT=8000
HOST_PORT=9999
# 启动容器
docker-compose up -d
# 验证
curl http://localhost:9999/health
```
### 测试 3:启动脚本
```bash
# 修改 .env
PORT=8888
# 运行启动脚本
./start.sh
# 检查输出是否显示正确的端口
```
## 注意事项
1. ✅ 所有端口配置集中在 `.env` 文件
2. ✅ 无需修改代码文件即可更改端口
3. ✅ 支持本地运行和 Docker 部署两种场景
4. ✅ 提供了详细的配置文档
5. ⚠️ 修改端口后需要重启服务
6. ⚠️ Docker 部署时需要同时考虑容器内外端口
7. ⚠️ 确保新端口没有被占用
## 文件清单
### 修改的文件
- `.env` - 添加端口配置区域
- `src/main.py` - 统一使用 settings 配置
- `docker-compose.yml` - 支持环境变量配置端口
- `start.sh` - 自动读取和显示端口
- `start_fixed.sh` - 自动读取和显示端口
- `README.md` - 更新访问说明
### 新增的文件
- `.env.example` - 配置模板
- `PORT_CONFIG.md` - 端口配置详细说明
- `DEPLOY_PORT.md` - 部署配置指南
- `PORT_REFACTOR_SUMMARY.md` - 本文档
## 回滚方案
如果需要回滚,按以下步骤操作:
```bash
git checkout -- src/main.py
git checkout -- docker-compose.yml
git checkout -- start.sh start_fixed.sh
git checkout -- .env
git checkout -- README.md
# 删除新增文件
rm .env.example PORT_CONFIG.md DEPLOY_PORT.md PORT_REFACTOR_SUMMARY.md
```
## 联系支持
如有问题,请查看:
- `PORT_CONFIG.md` - 端口配置详细说明
- `DEPLOY_PORT.md` - 部署配置指南
- `.env.example` - 配置示例
@@ -1,5 +1,8 @@
# 外部接口与集成盘点模板(ERP/财务/供应商门户/客户门户)
> 文档定位:**接口梳理与集成盘点模板文档**。
> 本文是用于外部系统对接时的模板,不作为当前项目状态或当前接口实现清单的权威说明。当前状态见 [../STATUS.md](../STATUS.md),当前架构见 [../ARCHITECTURE.md](../ARCHITECTURE.md)。
## 1. 接口清单
| 接口名称 | 调用方向 | 协议 | 鉴权 | 频率 | 单次数据量 | 幂等键 | 超时 | 重试策略 | 死信/补偿 | 负责人 |
@@ -1,5 +1,8 @@
# UAT 验收清单(模具制造进销存主线)
> 文档定位:**UAT 验收模板 / 验收过程文档**。
> 本文用于业务验收与签字过程,不作为当前项目状态或当前架构的权威说明。当前状态见 [../STATUS.md](../STATUS.md),当前架构见 [../ARCHITECTURE.md](../ARCHITECTURE.md)。
## 1. 业务流程签字
| 模块 | 场景 | 验收点 | 结果 | 业务签字 | 日期 |
+86
View File
@@ -0,0 +1,86 @@
# OCC 处理吞吐与隔离方案设计(OCC_THROUGHPUT)
> 文档定位:**OCC(PythonOCC)处理吞吐与故障隔离的专题设计文档**。
> 本文回答"OCC 串行瓶颈与超时线程泄漏的根治路线";现状事实以 [../../../STATUS.md](../../../STATUS.md) 为准,债务归属 [../../TECH_DEBT.md](../../TECH_DEBT.md) D10。
> 2026-09-17 随批次 4 产出:**方案设计先行**,短期项(方案 A)零代码可用,中期的接口演进与进程池实施留待后续批次。
---
## 1. 现状与硬约束
### 1.1 运行时事实
- 所有 OCC 操作(STEP 解析 / 布尔运算 / 三角化 / 倒扣检测 / STEP 转换等)统一经 [processing_service.py](../../../src/moldinsight/services/processing_service.py) 的 `run_occ(op_name, payload)` 投入 **常驻 OCC 工作进程池**([occ_process_pool.py](../../../src/moldinsight/services/occ_process_pool.py),默认 1 进程 = 1 串行通道)——OCC 非线程安全,通道内串行是正确性要求,不是实现偷懒。
- 操作在 [occ_worker.py](../../../src/moldinsight/core/occ_worker.py) 以注册表形式实现(parse_stp / generate_mesh / generate_cavity / analyze_mold_design / detect_undercuts / convert_component_step 等);输入输出全部是**文件路径 + 普通字典**,TopoDS_Shape 不跨进程传输(方案 B 硬约束,见 §1.2)。
- Celery worker 为 prefork 模式,`processing_service` 是模块级单例:**每个 worker 子进程各持一个常驻 OCC 工作进程**。因此 OCC 并行度 = worker 子进程数,与 web 进程数无关(web 侧 `run_occ` 仅服务于轻量同步调用,如倒扣检测)。
- 超时/崩溃 = `terminate()` 该工作进程并换新补位——进程边界干净回收,无线程滞留。
### 1.2 硬约束(决定方案边界)
| 约束 | 含义 |
|---|---|
| OCC 非线程安全 | 任何方案中,**一个进程内 OCC 操作必须串行**;并行只能靠多进程 |
| C++ 栈不可中断 | 线程级超时只能"抛弃"不能"击杀";**只有进程级 kill 是干净的故障恢复** |
| `run_occ(fn, *args)` 传闭包/绑定方法 | 函数对象不可跨进程 pickle——进程化方案必须改接口为"操作名 + 可序列化参数" |
| STEP 重载成本 | 进程间不共享 OCC 形状对象;跨进程方案每次调用需重新读文件/传 BRep(几秒级) |
---
## 2. 方案对比
### 方案 A:Celery prefork 并行伸缩(短期,零新代码)
**做法**:承认"每子进程一个串行 OCC 通道"的既有事实,把 OCC 吞吐问题转化为 worker 进程数问题:`celery -A celery_app worker --concurrency=N`,N = 期望的并行分析数(受 CPU 核数与每进程内存约束)。
- **优点**:零代码改动;进程边界天然兜住线程泄漏——泄漏线程随子进程存亡,配合 `--max-tasks-per-child=M`(子进程处理 M 个任务后重启回收)可把滞留线程的存续时间限制在一个批次内。
- **代价**:每个子进程常驻完整 Python + OCC 运行时(数百 MB),N 不能无脑调大;DB 连接按 celery 角色池(pool_size=5)随子进程倍增,PG `max_connections` 需要相应预算。
- **不解决**:单任务超时后该子进程内的线程滞留(被 max-tasks-per-child 兜底回收);单任务无加速(串行本质不变)。
**结论:立即可用的推荐做法**。部署侧调整(concurrency / max-tasks-per-child)随下次镜像与 compose 评审落地,先在 [OPERATIONS.md](../../OPERATIONS.md) 记录启动参数建议。
### 方案 B:常驻 OCC 进程池 + kill-on-timeout(中期,推荐演进方向)
**做法**:在 `run_occ` 接口之下替换执行器——不再是 `ThreadPoolExecutor`,而是**常驻的单线程 OCC 工作进程池**(每进程一个事件循环:接任务 → 执行 → 回报)。超时由主进程 `terminate()` 工作进程并更换新进程补位。
- 接口演进:`run_occ(fn, *args)` → `run_occ(op_name: str, payload: dict)`,操作名注册表映射到模块级函数(STEP 文件路径进、JSON/BRep 文件出,杜绝 pickle 大对象);各调用点(解析、型腔、倒扣、导出三角化……)逐一迁移。
- **优点**:超时 = 杀进程,**故障恢复干净彻底**(D10 残留泄漏根治);OCC 崩溃(segfault)不再波及 API/worker 主进程;进程池大小与 celery 并发解耦。
- **代价**:一次明确的接口迁移(所有 `run_occ` 调用点 + 结果序列化);进程池自管理(补位、健康检查、启动预热——spawn 下 import OCC 秒级,需常驻而非按任务拉起);跨进程只传文件路径 + JSON,现有"传形状对象"的内部调用要改为落盘中转。
- **风险**:自建进程池的运维复杂度;Windows 开发环境 spawn 语义与 Linux fork 差异需测试覆盖。
### 方案 C:OCC sidecar 服务(长期,视伸缩需求)
**做法**:OCC 能力独立成进程/容器(HTTP 或 gRPC),API 与 worker 都是客户端;STEP 按路径/对象键传入,返回 JSON 摘要 + 产物对象键。
- **优点**:隔离最彻底;OCC 可独立伸缩、独立发布、独立扩容 GPU/内存型节点;多语言可复用。
- **代价**:新增一个部署单元与序列化边界(大网格/形状数据传输设计);超出当前"单 compose 栈"的部署叙事,需与 DEPLOYMENT 文档体系一起演进。
**结论:除非出现独立伸缩/隔离性硬需求,暂不启动。**
---
## 3. 决策与路线
| 阶段 | 动作 | 状态 |
|---|---|---|
| 短期 | 方案 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,仅在出现独立伸缩需求时启动 | 暂不启动 |
## 4. 已落地的缓解(2026-09-17,批次 4)
`_reset_occ_executor` 的 `shutdown(wait=False)` 补 `cancel_futures=True`。这不只是卫生问题:旧实现下旧 executor 的**排队任务不会消失**——若挂死线程后来"慢恢复",旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争 / 崩溃风险)。补参后排队任务即被丢弃,残留问题收敛为"运行中线程滞留 1 个",由方案 A 的进程回收兜底。
## 5. 方案 B 实施记录(2026-09-18)
**接口**:`run_occ(fn, *args)` → `run_occ(op_name, payload)`;执行器由进程内线程池替换为常驻进程池。
- **新增** [occ_process_pool.py](../../../src/moldinsight/services/occ_process_pool.py):`OccProcessPool`(默认 size=1)。每个 `_OccWorker` 是一个 spawn 出的常驻子进程 + 双工管道 + 独立 `asyncio.Lock`(通道串行);阻塞收发经 `asyncio.to_thread` 不卡事件循环。操作超时或进程死亡 → `terminate()` + 换新补位;任务级整体超时(`process_file_with_storage` 外层 wait_for)→ `recover()` 整体重建。`shutdown()` 供应用退出/测试清理。
- **新增** [occ_worker.py](../../../src/moldinsight/core/occ_worker.py):操作注册表 + `worker_main` 消息循环。OCC 模块在 handler 内惰性导入(父进程 pip 环境无 OCC 也可 import),进程内单例缓存(parser/planner/analyzer/mesh_gen 等)。全部操作输入输出为**文件路径 + 普通字典**,TopoDS 不跨进程。
- **调用点迁移**(processing_service):
- `parse_stp` = 原 load_step_file + analyze_geometry 两步合一(形状在子进程内即生即用)
- `generate_mesh` / `analyze_mold_design` / `detect_undercuts` / `convert_component_step` 同名对位
- `generate_cavity` = 分模 + 方案形状持久化 STEP 导出全在子进程内;`_export_shapes`(TopoDS)不再回主进程,返回 export_manifest(与旧 `_persist_step_exports` 结构一致,主进程原样存 export_artifacts)
- 旧 `_cache_export_shapes` / `get_export_shapes` / `_persist_step_exports` / `_export_shapes_cache` 及线程 executor / `_reset_occ_executor` 全部删除(跨进程本就不存在内存形状缓存,export_router 相应移除 `export_mold_results` 内存分支)
- [shape_loader.py](../../../src/moldinsight/services/shape_loader.py) → [stp_materializer.py](../../../src/moldinsight/services/stp_materializer.py):只把 STP 原件落盘临时文件,OCC 解析交给子进程操作
- **成本确认**:每个 spawn 子进程首次操作需 import OCC(秒级);进程常驻后后续操作复用缓存实例。每个操作从 STP 原件重新加载形状(STEP 重载成本,见 §1.2)——原线程方案跨步骤共享 shape 的内存优势让位于进程隔离,符合方案 B 设计取舍。
- **测试**:[tests/test_occ_process_pool.py](../../../tests/test_occ_process_pool.py)(OCC-gated,6 例):spawn+管道往返 / 未知操作错误回传 / 子进程异常浮出 / 超时换新补位 / 真实盒体 STP 解析端到端 / generate_cavity 分模+STEP 落盘端到端。
@@ -1,5 +1,7 @@
# RustFS 对象存储集成说明
> 文档定位:**RustFS / 对象存储集成的专题说明文档**。
> 本文解释对象存储侧的接口与集成思路,不作为当前部署入口文档。当前部署方式见 [../../DEPLOYMENT.md](../../DEPLOYMENT.md) 与 [../../deployment/LINUX_SETUP.md](../../deployment/LINUX_SETUP.md),当前状态见 [../../STATUS.md](../../STATUS.md)。
## 架构概述
本项目采用 **RustFS** 作为对象存储和 **PostgreSQL** 作为元数据存储的双层存储架构。
+158
View File
@@ -0,0 +1,158 @@
# 存储架构说明
> 文档定位:**存储分层与数据流的专题说明文档**。
> 本文主要解释 geMoldInsight 的存储架构,不作为当前部署入口或当前状态的权威说明。当前部署方式见 [../../DEPLOYMENT.md](../../DEPLOYMENT.md),当前状态见 [../../STATUS.md](../../STATUS.md),总体架构见 [../../ARCHITECTURE.md](../../ARCHITECTURE.md)。
> 其历史中的“本项目自行拉起 PostgreSQL / MinIO 并通过 `python src/main.py` 启动单体”的部分,**已不再代表当前默认部署方式**。
当前默认部署前提是:
- PostgreSQL 由服务器已有服务提供
- Redis 由服务器已有服务提供
- RustFS / MinIO 兼容对象存储由服务器已有服务提供
- 项目自身只部署:`moldinsight` / `moldinsight-celery` / `inventory`
如需查看当前部署方式,请优先参考:
- [../../../README.md](../../../README.md)
- [../../deployment/LINUX_SETUP.md](../../deployment/LINUX_SETUP.md)
- [../../deployment/DEPLOY_PORT.md](../../deployment/DEPLOY_PORT.md)
- [../../archive/BACKEND_MODULARIZATION_BLUEPRINT.md](../../archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
---
## 架构概述
本项目采用 **RustFS(S3兼容)对象存储** + **PostgreSQL 元数据存储** 的双层存储架构。
```text
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (moldinsight / inventory) │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ │
┌───────▼────────┐ ┌─────────▼─────────┐
│ PostgreSQL │ │ RustFS / S3 │
│ (元数据) │ │ (对象存储) │
│ │ │ │
│ - users │ │ - stp-files │
│ - stp_files │ │ - geometry │
│ - geometry_data│ │ - mold-cavities │
│ - processing │ │ - html-files │
│ - logs │ │ - user-files │
└────────────────┘ └──────────────────┘
```
---
## PostgreSQL 数据表(摘要)
### 用户管理
- `users` - 用户信息
- `roles` / `permissions` - 权限体系
### moldinsight 相关
- `stp_files` - STP 文件元数据
- `html_files` - HTML 报告元数据
- `geometry_data` - 几何分析数据
- `mold_cavity_data` - 模具型腔数据
- `feature_detections` - 特征检测结果
- `design_recommendations` - 设计建议
- `processing_tasks` - 处理任务记录
- `analysis_metrics` - 分析指标
### inventory 相关
- `products` - 产品/物料
- `product_materials` - BOM
- `inventory` - 库存
- `stock_movements` - 库存流水
- `purchase_orders` / `sales_orders` - 订单
- `finance_transactions` - 财务流水
---
## RustFS / S3 存储桶
| 存储桶名称 | 用途 | 存储内容 |
|---|---|---|
| `moldinsight-stp-files` | STP/STEP 文件 | 用户上传的原始 3D 模型 |
| `moldinsight-geometry` | 几何结果 | 几何分析 JSON |
| `moldinsight-mold-cavities` | 模具结果 | 模具设计 JSON |
| `moldinsight-html` | HTML 报告 | 生成的 HTML 报告文件 |
| `moldinsight-user-files` | 用户文件 | 其他附件/用户文件 |
---
## 当前推荐初始化方式
### 1. 准备环境变量
```bash
cp .env.example .env
nano .env
```
确保以下变量指向**服务器上已存在的真实服务**:
```env
DB_HOST=your-db-host
DB_PORT=5432
DB_NAME=moldinsight
DB_USER=moldinsight
DB_PASSWORD=change-me
REDIS_HOST=your-redis-host
REDIS_PORT=6379
REDIS_PASSWORD=
RUSTFS_ENDPOINT=http://your-storage-host:9000
RUSTFS_ACCESS_KEY=change-me
RUSTFS_SECRET_KEY=change-me
```
### 2. 初始化数据库
当前初始化入口参考:
- [init_db.py](../../../src/shared/database/init_db.py)
### 3. 启动项目服务
当前推荐通过(按模式对应不同 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` 作为默认方式。
---
## 监控与维护
### 对象存储
- 检查对象存储服务可达性
- 定期清理历史产物
- 配置生命周期策略
### PostgreSQL
- 定期备份
- 监控连接池与慢查询
- 保持 Alembic 迁移链一致
---
## 故障排除
### 对象存储连接失败
- 检查 `RUSTFS_ENDPOINT`
- 检查 access key / secret key
- 检查服务端口与网络策略
### 数据库连接失败
- 检查 `DB_HOST` / `DB_PORT`
- 检查数据库账号密码
- 检查防火墙与白名单
### 文件上传失败
- 检查对象存储可用性
- 检查 Celery worker 是否运行
- 检查 Redis 是否可达
+68 -3
View File
@@ -1,5 +1,70 @@
# Vue 3 + TypeScript + Vite
# geMoldInsight Frontend
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
这是 geMoldInsight 的独立前端工程,基于:
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
- Vue 3
- Vite
- TypeScript
- Pinia
- Vue Router
当前推荐部署方式为:
> **独立前端部署 + 同域反代 + unified backend**
即:
- 前端由 Nginx 静态站点独立提供
- `/api`、`/health`、`/html` 通过同域反代统一转发到一个 unified backend
- 前端继续使用相对路径调用后端接口
---
## 本地开发
```bash
npm install
npm run dev
```
开发服务器默认:
- 端口:`5173`
- 已代理:`/api`、`/health`、`/html`
---
## 生产构建
```bash
npm run build
```
构建产物输出到:
- `frontend/dist/`
该目录由前端 Nginx 镜像或外部静态站点托管,不再输出到仓库根目录 `static/`。
---
## 部署
当前仓库已提供:
- [deploy/Dockerfile.frontend](../deploy/Dockerfile.frontend)
- [deploy/nginx/frontend.conf](../deploy/nginx/frontend.conf)
以及根目录 [docker-compose.yml](../docker-compose.yml)(unified 模式)中的 `frontend` 服务。完整系统一键启动:
```bash
docker compose up -d
```
> 旧 `--profile frontend` 仅起前端的写法已随 compose 拆分移除;前端同域反代依赖 unified backend,推荐整栈启动。前后端分离开发时,前端本地 `npm run dev`、后端直跑 `uvicorn`(见 [LINUX_SETUP.md](../docs/deployment/LINUX_SETUP.md) §6)。
---
## 说明
前端历史上曾通过后端 `static/` 目录托管;当前已切换为独立部署模式。后端默认不再负责提供 SPA 页面,但仍提供:
- `/api/*`
- `/health`
- `/html/*`(gemold 分析产物)
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"gen:api": "npx --yes openapi-typescript@7.13.0 ../openapi.json -o src/types/api.ts"
},
"dependencies": {
"air-datepicker": "^3.6.0",
+15 -3
View File
@@ -9,9 +9,9 @@
<div class="card aluminum-price-card">
<div class="card-header">
<div class="card-title">
<span class="aluminum-icon">🪙</span> 铝金属价格 (SHFE)
<span class="aluminum-icon">🪙</span> 铝金属价格
</div>
<span class="aluminum-source">数据来源: 上海期货交易所 | 更新于 {{ aluminum.date }}</span>
<span class="aluminum-source">{{ aluminumSourceLabel }}</span>
</div>
<div class="aluminum-content">
<div class="aluminum-price-row">
@@ -78,7 +78,7 @@
</template>
<script setup lang="ts">
import { reactive, onMounted, nextTick, ref } from 'vue'
import { computed, reactive, onMounted, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiRequest } from '@/shared/api'
import { Chart, registerables } from 'chart.js'
@@ -98,6 +98,8 @@ interface AluminumPrice {
low: number
prev_close: number
week_ago_price: number
// D2:数据来源声明——simulated 为模拟走势(参考数据,非实时行情)
source?: string
}
interface HistoryItem {
@@ -112,6 +114,7 @@ const aluminum = reactive({
change: 0,
change_percent: 0,
open: 0,
source: '',
high: 0,
low: 0,
prev_close: 0,
@@ -124,12 +127,21 @@ const formatAluminumPrice = (val: number | null) => {
return Math.round(val).toLocaleString('zh-CN')
}
// D2:来源标注以接口 source 字段为准,不再硬编码交易所名称——
// 此前显示"数据来源: 上海期货交易所"而数据实为模拟走势,属虚假来源声明
const aluminumSourceLabel = computed(() => {
if (aluminum.source === 'simulated') return '模拟数据 · 参考走势,非实时行情'
if (aluminum.source) return `数据来源: ${aluminum.source}`
return `更新于 ${aluminum.date}`
})
const loadAluminumPrice = async () => {
try {
const data = await apiRequest<AluminumPrice>('/api/aluminum-price/current')
aluminum.price = data.price
aluminum.unit = data.unit
aluminum.date = data.date
aluminum.source = data.source ?? ''
aluminum.change = data.change
aluminum.change_percent = data.change_percent
aluminum.open = data.open
@@ -1,48 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { addNotification } from '@/shared/notification'
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { useInventoryStore } from '@/stores/inventory'
import InventorySidebar from './components/InventorySidebar.vue'
import DashboardTab from './components/DashboardTab.vue'
import ProductsTab from './components/ProductsTab.vue'
import MaterialsTab from './components/MaterialsTab.vue'
import InventoryTab from './components/InventoryTab.vue'
import PurchaseOrdersTab from './components/PurchaseOrdersTab.vue'
import SalesOrdersTab from './components/SalesOrdersTab.vue'
import SuppliersTab from './components/SuppliersTab.vue'
import CustomersTab from './components/CustomersTab.vue'
import FinanceTab from './components/FinanceTab.vue'
import MovementsTab from './components/MovementsTab.vue'
const store = useAppStore()
const store = useInventoryStore()
const appStore = useAppStore()
const router = useRouter()
const {
state,
activeMenu,
checkBackendHealth,
loadDashboard,
destroyPickers
} = useInventory()
const route = useRoute()
// Derive activeTab from route path (last segment)
const tabFromRoute = () => {
const segments = route.path.split('/')
return segments[segments.length - 1] || 'dashboard'
}
// Sync store.activeTab when route changes
watch(() => route.path, () => {
store.activeTab = tabFromRoute()
}, { immediate: true })
onMounted(() => {
if (!store.user) {
if (!appStore.user) {
router.push('/login')
return
}
checkBackendHealth().then(() => {
if (!state.backendDbReady) {
addNotification(state.backendDbMessage || '业务服务不可用', 'warning')
store.activeTab = tabFromRoute()
store.checkBackendHealth().then(() => {
if (!store.backendDbReady) {
addNotification(store.backendDbMessage || '业务服务不可用', 'warning')
return
}
loadDashboard()
store.loadDashboard()
})
})
onUnmounted(() => {
destroyPickers()
store.destroyPickers()
})
</script>
@@ -61,32 +58,23 @@ onUnmounted(() => {
<section class="inventory-content">
<div class="inventory-content-header">
<div class="inventory-breadcrumb">
<span>{{ activeMenu?.group?.title || '进销存' }}</span>
<span>{{ store.activeMenu?.group?.title || '进销存' }}</span>
<span class="sep">/</span>
<span class="current">{{ activeMenu?.item?.label || '' }}</span>
<span class="current">{{ store.activeMenu?.item?.label || '' }}</span>
</div>
</div>
<t-alert
v-if="!state.backendDbReady"
:title="state.backendDbMessage"
v-if="!store.backendDbReady"
:title="store.backendDbMessage"
theme="warning"
show-icon
:closable="false"
style="margin-bottom: 16px;"
/>
<t-loading :loading="state.loading">
<DashboardTab v-if="state.activeTab === 'dashboard'" />
<ProductsTab v-else-if="state.activeTab === 'products'" />
<MaterialsTab v-else-if="state.activeTab === 'materials'" />
<InventoryTab v-else-if="state.activeTab === 'inventory'" />
<PurchaseOrdersTab v-else-if="state.activeTab === 'purchases'" />
<SalesOrdersTab v-else-if="state.activeTab === 'sales_orders'" />
<SuppliersTab v-else-if="state.activeTab === 'suppliers'" />
<CustomersTab v-else-if="state.activeTab === 'customers'" />
<FinanceTab v-else-if="state.activeTab === 'finance'" />
<MovementsTab v-else-if="state.activeTab === 'movements'" />
<t-loading :loading="store.loading">
<router-view />
</t-loading>
</section>
</div>
@@ -37,22 +37,22 @@ onMounted(() => {
refreshFinance()
})
// 字段名对齐后端 FinanceSummaryResponse:
// receivable_total / payable_total / period_receipt_total / period_payment_total
// 字段名对齐后端 FinanceSummaryResponse。
// 后端 Decimal 序列化为 JSON 字符串,故用 Number() 转换再填充 0。
const totalReceivable = computed(() => {
return state.financeSummary?.receivable_total ?? 0
return Number(state.financeSummary?.receivable_total) || 0
})
const totalPayable = computed(() => {
return state.financeSummary?.payable_total ?? 0
return Number(state.financeSummary?.payable_total) || 0
})
const periodReceived = computed(() => {
return state.financeSummary?.period_receipt_total ?? 0
return Number(state.financeSummary?.period_receipt_total) || 0
})
const periodPaid = computed(() => {
return state.financeSummary?.period_payment_total ?? 0
return Number(state.financeSummary?.period_payment_total) || 0
})
function getTransactionTypeLabel(type: string): string {
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { useRouter } from 'vue-router'
import { useInventoryStore } from '@/stores/inventory'
const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInventory()
const router = useRouter()
const store = useInventoryStore()
const { menuGroups, openGroups, toggleGroup } = store
const navigateTo = (itemKey: string) => {
store.activeTab = itemKey
router.push(`/inventory/${itemKey}`)
}
</script>
<template>
@@ -15,8 +23,8 @@ const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInven
<div
v-for="item in group.items"
:key="item.key"
:class="['sidebar-item', { active: state.activeTab === item.key }]"
@click="handleMenuClick(item.key)"
:class="['sidebar-item', { active: store.activeTab === item.key }]"
@click="navigateTo(item.key)"
>
{{ item.label }}
</div>
@@ -98,16 +98,16 @@ async function openRestockModal() {
}
function addRestockItem() {
restockItems.value.push({
restockItems.value = [...restockItems.value, {
material_id: state.materials[0]?.id || null,
quantity: 1,
unit_price: 0,
remark: ''
})
}]
}
function removeRestockItem(index: number) {
restockItems.value.splice(index, 1)
restockItems.value = restockItems.value.filter((_, i) => i !== index)
}
const restockTotalAmount = computed(() => {
@@ -51,14 +51,14 @@ async function editProduct(product: any) {
}
function addBomItem() {
bomItems.value.push({
bomItems.value = [...bomItems.value, {
material_id: state.materials[0]?.id || null,
quantity: 1
})
}]
}
function removeBomItem(index: number) {
bomItems.value.splice(index, 1)
bomItems.value = bomItems.value.filter((_, i) => i !== index)
}
async function saveProduct() {
@@ -3,7 +3,9 @@ import { ref, computed, onMounted } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
import { useInventory } from '../composables/useInventory'
import { apiRequest } from '@/shared/api'
import { inventoryApi } from '@/shared/api-client'
import { addNotification, handleApiError } from '@/shared/notification'
import type { Schema } from '@/types/schemas'
function confirmDialog(header: string, body: string, theme: string, confirmText: string, cancelText: string): Promise<boolean> {
return new Promise((resolve) => {
@@ -47,6 +49,46 @@ const receiveWarehouseId = ref<number | null>(null)
const receiveRemark = ref('')
const receiving = ref(false)
// ── 采购需求推导 ──
const showDemandModal = ref(false)
const demandLoading = ref(false)
const salesOrdersForDemand = ref<Schema<'SalesOrderResponse'>[]>([])
const selectedSalesOrderIds = ref<number[]>([])
const demandResult = ref<Schema<'PurchaseDemandResponse'> | null>(null)
async function openDemandDialog() {
showDemandModal.value = true
demandResult.value = null
selectedSalesOrderIds.value = []
try {
const data = await inventoryApi.listSalesOrders({ limit: 100 })
salesOrdersForDemand.value = (data as any)?.items || data || []
} catch (e) {
handleApiError(e, '加载销售订单')
}
}
async function calculateDemands() {
if (!selectedSalesOrderIds.value.length) {
addNotification('请选择至少一个销售订单', 'warning')
return
}
demandLoading.value = true
try {
demandResult.value = await inventoryApi.calculatePurchaseDemands(selectedSalesOrderIds.value)
} catch (e) {
handleApiError(e, '计算采购需求')
} finally {
demandLoading.value = false
}
}
function closeDemandModal() {
showDemandModal.value = false
demandResult.value = null
selectedSalesOrderIds.value = []
}
function openCreateOrder() {
editingItem.value = null
form.value = {
@@ -59,22 +101,22 @@ function openCreateOrder() {
}
function addOrderItem() {
orderItems.value.push({
orderItems.value = [...orderItems.value, {
product_id: state.materials[0]?.id || null,
quantity: 1,
remark: ''
})
}]
}
function removeOrderItem(index: number) {
orderItems.value.splice(index, 1)
orderItems.value = orderItems.value.filter((_, i) => i !== index)
}
const orderTotalAmount = computed(() => {
return orderItems.value.reduce((sum: number, item: any) => {
const material = state.materials.find((m: any) => m.id === item.product_id)
const unitPrice = material?.cost_price || 0
return sum + unitPrice * (item.quantity || 0)
const unitPrice = Number(material?.cost_price || 0)
return sum + unitPrice * Number(item.quantity || 0)
}, 0)
})
@@ -299,6 +341,7 @@ onMounted(() => {
</t-select>
<t-button @click="loadPurchaseOrders">刷新</t-button>
<t-button type="primary" @click="openCreateOrder">新增采购订单</t-button>
<t-button theme="success" variant="outline" @click="openDemandDialog">采购建议</t-button>
</div>
<t-table :data="state.purchaseOrders" :loading="state.loading" stripe>
@@ -448,7 +491,7 @@ onMounted(() => {
</t-table-column>
<t-table-column label="总价" width="120">
<template #default="{ row: line }">
{{ formatCurrency((state.materials.find((m: any) => m.id === line.product_id)?.cost_price || 0) * (line.quantity || 0)) }}
{{ formatCurrency(Number(state.materials.find((m: any) => m.id === line.product_id)?.cost_price || 0) * Number(line.quantity || 0)) }}
</template>
</t-table-column>
<t-table-column label="备注" width="150">
@@ -515,6 +558,87 @@ onMounted(() => {
<t-button type="primary" :loading="receiving" :disabled="receiving" @click="receivePurchaseOrder">确认入库</t-button>
</template>
</t-dialog>
<!-- 采购需求推导对话框 -->
<t-dialog
v-model:visible="showDemandModal"
title="采购需求推导"
width="1000px"
@closed="closeDemandModal"
>
<div style="margin-bottom: 16px;">
<p style="margin-bottom: 8px; color: var(--td-text-color-secondary);">
选择销售订单,系统将自动按 BOM 展开物料需求、对比库存、推荐供应商。
</p>
<div style="display: flex; gap: 12px; align-items: center;">
<t-select
v-model="selectedSalesOrderIds"
multiple
placeholder="请选择销售订单"
style="flex: 1;"
:loading="salesOrdersForDemand.length === 0"
>
<t-option
v-for="order in salesOrdersForDemand"
:key="order.id"
:label="`${order.order_no} - ${order.customer_name || ''}`"
:value="order.id"
/>
</t-select>
<t-button
type="primary"
:loading="demandLoading"
:disabled="!selectedSalesOrderIds.length"
@click="calculateDemands"
>
计算
</t-button>
</div>
</div>
<template v-if="demandResult">
<div style="display: flex; gap: 24px; margin-bottom: 12px; font-weight: 600;">
<span>来源订单:{{ demandResult.source_order_nos?.join(', ') || '-' }}</span>
<span>缺货物料:<t-tag :type="demandResult.shortage_count > 0 ? 'danger' : 'success'" size="small">{{ demandResult.shortage_count }}</t-tag></span>
<span>预计采购总额:{{ formatCurrency(Number(demandResult.total_estimated_cost || 0)) }}</span>
</div>
<t-table :data="demandResult.items || []" stripe border size="small" max-height="400">
<t-table-column prop="material_sku" label="物料SKU" width="120" />
<t-table-column prop="material_name" label="物料名称" min-width="140" />
<t-table-column label="需求量" width="90">
<template #default="{ row }">{{ row.required_quantity }}</template>
</t-table-column>
<t-table-column label="库存量" width="90">
<template #default="{ row }">{{ row.available_quantity }}</template>
</t-table-column>
<t-table-column label="缺口" width="90">
<template #default="{ row }">
<t-tag :type="row.shortage_quantity > 0 ? 'danger' : 'success'" size="small">
{{ row.shortage_quantity }}
</t-tag>
</template>
</t-table-column>
<t-table-column label="单价" width="90">
<template #default="{ row }">{{ formatCurrency(Number(row.unit_cost || 0)) }}</template>
</t-table-column>
<t-table-column label="预计金额" width="110">
<template #default="{ row }">{{ formatCurrency(Number(row.estimated_cost || 0)) }}</template>
</t-table-column>
<t-table-column prop="suggested_supplier_name" label="建议供应商" min-width="120">
<template #default="{ row }">{{ row.suggested_supplier_name || '-' }}</template>
</t-table-column>
<t-table-column label="交期(天)" width="80">
<template #default="{ row }">{{ row.supplier_lead_time ?? '-' }}</template>
</t-table-column>
</t-table>
<t-empty v-if="(demandResult.items || []).length === 0" description="无物料需求(BOM 为空或订单无明细)" />
</template>
<template #footer>
<t-button @click="showDemandModal = false">关闭</t-button>
</template>
</t-dialog>
</div>
</template>
@@ -66,7 +66,7 @@ function openCreateOrder() {
}
function addMoldItem() {
moldItems.value.push({
moldItems.value = [...moldItems.value, {
mold_mode: 'new',
mold_sku: '',
mold_name: '',
@@ -74,11 +74,11 @@ function addMoldItem() {
quantity: 1,
unit_price: 0,
remark: ''
})
}]
}
function removeMoldItem(index: number) {
moldItems.value.splice(index, 1)
moldItems.value = moldItems.value.filter((_, i) => i !== index)
}
const orderTotalAmount = computed(() => {
@@ -263,22 +263,22 @@ function openConsumptionModal() {
}
function addConsumptionItem() {
consumptionItems.value.push({
consumptionItems.value = [...consumptionItems.value, {
material_id: state.materials[0]?.id || null,
quantity: 1,
remark: ''
})
}]
}
function removeConsumptionItem(index: number) {
consumptionItems.value.splice(index, 1)
consumptionItems.value = consumptionItems.value.filter((_, i) => i !== index)
}
const consumptionTotalAmount = computed(() => {
return consumptionItems.value.reduce((sum: number, item: any) => {
const material = state.materials.find((m: any) => m.id === item.material_id)
const unitPrice = material?.cost_price || 0
return sum + unitPrice * (item.quantity || 0)
const unitPrice = Number(material?.cost_price || 0)
return sum + unitPrice * Number(item.quantity || 0)
}, 0)
})
@@ -604,7 +604,7 @@ onMounted(() => {
</t-table-column>
<t-table-column label="总价" width="120">
<template #default="{ row: line }">
{{ formatCurrency((state.materials.find((m: any) => m.id === line.material_id)?.cost_price || 0) * (line.quantity || 0)) }}
{{ formatCurrency(Number(state.materials.find((m: any) => m.id === line.material_id)?.cost_price || 0) * Number(line.quantity || 0)) }}
</template>
</t-table-column>
<t-table-column label="备注" width="150">
@@ -1,517 +1,10 @@
import { reactive, ref, computed } from 'vue'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
declare const AirDatepicker: any
const state = reactive({
activeTab: 'dashboard' as string,
backendDbReady: true,
backendDbMessage: '' as string,
productCategory: 'finished' as string,
dashboard: null as any,
financeSummary: null as any,
financePeriod: {
year: new Date().getFullYear(),
quarter: '' as string
},
financeTransactions: [] as any[],
receivables: [] as any[],
payables: [] as any[],
customerFinanceStatement: [] as any[],
supplierFinanceStatement: [] as any[],
customerProductStatement: [] as any[],
supplierProductStatement: [] as any[],
products: [] as any[],
materials: [] as any[],
finishedProducts: [] as any[],
purchaseOrders: [] as any[],
purchaseWarehouseId: null as number | null,
purchaseReceiveItems: [] as any[],
productionOrders: [] as any[],
productionPlan: null as any,
productionWarehouseId: null as number | null,
suppliers: [] as any[],
customers: [] as any[],
warehouses: [] as any[],
inventory: [] as any[],
movements: [] as any[],
loading: false,
showModal: false,
modalType: '' as string,
editingItem: null as any,
productBomItems: [] as any[],
materialConsumptionItems: [] as any[],
showMaterialConsumptionModal: false,
consumedMaterials: [] as any[],
restockItems: [] as any[],
showRestockModal: false,
form: {} as any
})
let deliveryPicker: any = null
let expectedPicker: any = null
const deliveryDateInput = ref<HTMLElement | null>(null)
const expectedDateInput = ref<HTMLElement | null>(null)
const deliveryDateNativeInput = ref<HTMLElement | null>(null)
const expectedDateNativeInput = ref<HTMLElement | null>(null)
/**
* Backward-compatible wrapper — delegates to Pinia store.
* All existing components calling `useInventory()` keep working unchanged.
* New code should import `useInventoryStore` directly from `@/stores/inventory`.
*/
import { useInventoryStore } from '@/stores/inventory'
export function useInventory() {
const parseDateTimeLocal = (text: string | null | undefined): Date | null => {
if (!text) return null
const raw = String(text).trim()
const normalized = raw.replace('T', ' ').slice(0, 16)
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/)
if (!m) return null
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
const hour = Number(m[4])
const minute = Number(m[5])
if (!Number.isFinite(year + month + day + hour + minute)) return null
return new Date(year, month - 1, day, hour, minute, 0)
}
const toPickerValue = (value: any): string => {
if (!value) return ''
const raw = String(value).trim()
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16)
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16)
const dt = new Date(raw)
if (!Number.isFinite(dt.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
const toApiDateTime = (value: any): string | null => {
if (!value) return null
const text = String(value).trim()
if (text.includes('T')) return text.split('T')[0]
if (text.includes(' ')) return text.split(' ')[0]
if (text.length === 10) return text
if (value instanceof Date) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
return text
}
const toNativeValue = (value: any): string => {
if (!value) return ''
const text = String(value).trim()
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
const isoText = text.replace(' ', 'T')
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText
}
const fromNativeValue = (value: any): string => {
if (!value) return ''
const text = String(value).trim()
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
return text.replace('T', ' ').slice(0, 16)
}
const destroyPickers = () => {
if (deliveryPicker) {
deliveryPicker.destroy()
deliveryPicker = null
}
if (expectedPicker) {
expectedPicker.destroy()
expectedPicker = null
}
}
const initPickers = () => {
destroyPickers()
if (typeof AirDatepicker !== 'function') return
if (state.modalType === 'salesOrder' && deliveryDateInput.value) {
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
timepicker: false,
autoClose: true,
zIndex: 2005,
dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
state.form.delivery_date = formattedDate || ''
state.form.delivery_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(state.form.delivery_date)
if (initial) deliveryPicker.selectDate(initial, { silent: true })
}
if (state.modalType === 'purchaseOrder' && expectedDateInput.value) {
expectedPicker = new AirDatepicker(expectedDateInput.value, {
timepicker: false,
autoClose: true,
zIndex: 2005,
dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
state.form.expected_date = formattedDate || ''
state.form.expected_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(state.form.expected_date)
if (initial) expectedPicker.selectDate(initial, { silent: true })
}
}
const openDateTimePicker = (pickerKind: string) => {
if (pickerKind === 'delivery' && deliveryPicker) {
deliveryPicker.show()
return
}
if (pickerKind === 'expected' && expectedPicker) {
expectedPicker.show()
return
}
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value
if (!nativeInput) return
if (typeof (nativeInput as any).showPicker === 'function') {
(nativeInput as any).showPicker()
return
}
nativeInput.focus()
nativeInput.click()
}
const getMovementTypeLabel = (movementType: string): string => {
const movementLabelMap: Record<string, string> = {
in: '其他入库',
out: '其他出库',
adjust: '库存调整',
purchase_in: '采购入库',
return_from_production: '生产退料入库',
outsource_return: '外协回库',
finish_in: '完工入库',
issue_to_production: '生产领料出库',
outsource_send: '外协发料出库',
shipment_out: '销售出库',
scrap_out: '报废出库'
}
return movementLabelMap[movementType] || movementType
}
const getMovementBadgeClass = (movementType: string): string => {
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) {
return 'badge-success'
}
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) {
return 'badge-error'
}
return 'badge-warning'
}
const getPurchaseOrderStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
draft: '已下单',
pending: '已下单',
partial_received: '部分收货',
received: '已收货',
paid: '已付款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const isPurchaseOrderLocked = (status: string): boolean => {
return ['received', 'paid', 'cancelled'].includes(status)
}
const getSalesOrderStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
manufacturing: '制造中',
delivered: '已交付',
paid: '已收款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const getDeliveryStatusLabel = (ds: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', cancelled: '已作废' }
return map[ds] || ds
}
const getPaymentStatusLabel = (ps: string): string => {
const map: Record<string, string> = { unpaid: '未收款', paid: '已收款' }
return map[ps] || ps
}
const getReceiptStatusLabel = (rs: string): string => {
const map: Record<string, string> = { pending: '已下单', partial_received: '部分收货', received: '已收货', cancelled: '已作废' }
return map[rs] || rs
}
const checkBackendHealth = async () => {
try {
const resp = await fetch('/health', { method: 'GET' })
if (!resp.ok) {
state.backendDbReady = false
state.backendDbMessage = '后端服务异常,暂无法加载业务数据'
return
}
const health = await resp.json().catch(() => null)
if (health && health.database_connected === false) {
state.backendDbReady = false
state.backendDbMessage = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'
return
}
state.backendDbReady = true
state.backendDbMessage = ''
} catch {
state.backendDbReady = false
state.backendDbMessage = '无法连接后端服务'
}
}
const loadDashboard = async () => {
state.loading = true
try { state.dashboard = await apiRequest('/api/dashboard') }
catch (e) { handleApiError(e, '加载仪表盘') }
finally { state.loading = false }
}
const loadFinishedProducts = async () => {
state.loading = true
try { state.finishedProducts = await apiRequest('/api/products?item_type=finished&limit=100') }
catch (e) { handleApiError(e, '加载成品') }
finally { state.loading = false }
}
const loadProducts = async () => { await loadFinishedProducts() }
const loadMaterials = async () => {
state.loading = true
try { state.materials = await apiRequest('/api/products?item_type=material&limit=100') }
catch (e) { handleApiError(e, '加载物料') }
finally { state.loading = false }
}
const loadWarehouses = async () => {
state.loading = true
try { state.warehouses = await apiRequest('/api/warehouses') }
catch (e) { handleApiError(e, '加载仓库') }
finally { state.loading = false }
}
const ensureStockBaseData = async () => {
if (!state.materials.length) await loadMaterials()
if (!state.warehouses.length) await loadWarehouses()
if (!state.warehouses.length) {
try {
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
await loadWarehouses()
addNotification('已自动创建默认仓库', 'success')
} catch (e) { handleApiError(e, '自动创建默认仓库') }
}
}
const loadSuppliers = async () => {
state.loading = true
try { state.suppliers = await apiRequest('/api/suppliers') }
catch (e) { handleApiError(e, '加载供应商') }
finally { state.loading = false }
}
const loadProductionOrders = async () => {
state.loading = true
try {
const [orders, warehouses] = await Promise.all([
apiRequest('/api/sales-orders?limit=100'),
apiRequest('/api/warehouses')
])
state.productionOrders = orders?.items || []
state.warehouses = warehouses || []
if (!state.productionWarehouseId) {
state.productionWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
}
} catch (e) { handleApiError(e, '加载按单生产数据') }
finally { state.loading = false }
}
const loadPurchaseOrders = async () => {
state.loading = true
try {
const [orders, warehouses] = await Promise.all([
apiRequest('/api/purchase-orders?limit=100'),
apiRequest('/api/warehouses')
])
state.purchaseOrders = orders?.items || []
state.warehouses = warehouses || []
if (!state.purchaseWarehouseId) {
state.purchaseWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
}
} catch (e) { handleApiError(e, '加载采购订单') }
finally { state.loading = false }
}
const loadCustomers = async () => {
state.loading = true
try { state.customers = await apiRequest('/api/customers') }
catch (e) { handleApiError(e, '加载客户') }
finally { state.loading = false }
}
const loadInventory = async () => {
state.loading = true
try { state.inventory = (await apiRequest('/api/inventory'))?.items || [] }
catch (e) { handleApiError(e, '加载库存') }
finally { state.loading = false }
}
const loadMovements = async () => {
state.loading = true
try { state.movements = (await apiRequest('/api/stock-movements'))?.items || [] }
catch (e) { handleApiError(e, '加载变动记录') }
finally { state.loading = false }
}
const loadFinance = async () => {
state.loading = true
try {
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear()
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null
const periodQuery = selectedQuarter
? `year=${selectedYear}&quarter=${selectedQuarter}`
: `year=${selectedYear}`
const [summary, transactions, receivables, payables, customerStatement, supplierStatement, customerProductStatement, supplierProductStatement] = await Promise.all([
apiRequest(`/api/finance/summary?${periodQuery}`),
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
])
state.financeSummary = summary
state.financeTransactions = transactions
state.receivables = receivables
state.payables = payables
state.customerFinanceStatement = customerStatement.items || []
state.supplierFinanceStatement = supplierStatement.items || []
state.customerProductStatement = customerProductStatement.items || []
state.supplierProductStatement = supplierProductStatement.items || []
} catch (e) { handleApiError(e, '加载财务数据') }
finally { state.loading = false }
}
const refreshFinanceByPeriod = () => {
if (state.activeTab === 'finance') loadFinance()
}
const switchTab = (tab: string) => {
state.activeTab = tab
}
const closeModal = () => {
state.showModal = false
state.modalType = ''
state.editingItem = null
state.productBomItems = []
state.purchaseReceiveItems = []
state.form = {}
destroyPickers()
}
const modalTitle = computed(() => {
const prefix = state.editingItem ? '编辑' : '新增'
const typeMap: Record<string, string> = {
product: state.form.item_type === 'finished' ? '成品' : '物料',
inventoryItem: '物料库存',
salesOrder: '销售订单',
purchaseOrder: '采购订单',
purchaseReceive: '采购到货入库',
supplier: '供应商',
customer: '客户'
}
return prefix + (typeMap[state.modalType] || '')
})
const menuGroups = [
{ key: 'overview', title: '概览', items: [{ key: 'dashboard', label: '仪表盘' }] },
{ key: 'sales', title: '销售', items: [{ key: 'sales_orders', label: '销售订单管理' }] },
{ key: 'purchase', title: '采购', items: [{ key: 'purchases', label: '采购订单管理' }] },
{ key: 'product', title: '产品', items: [{ key: 'products', label: '成品管理' }, { key: 'materials', label: '物料管理' }] },
{ key: 'partner', title: '往来单位', items: [{ key: 'customers', label: '客户管理' }, { key: 'suppliers', label: '供应商管理' }] },
{ key: 'warehouse', title: '仓库', items: [{ key: 'inventory', label: '库存管理' }, { key: 'movements', label: '库存变动记录' }] },
{ key: 'finance', title: '财务', items: [{ key: 'finance', label: '财务概览' }] }
]
const openGroups = reactive<Record<string, boolean>>(
Object.fromEntries(menuGroups.map(g => [g.key, true]))
)
const toggleGroup = (groupKey: string) => { openGroups[groupKey] = !openGroups[groupKey] }
const activeMenu = computed(() => {
for (const group of menuGroups) {
const item = group.items.find(i => i.key === state.activeTab)
if (item) return { group, item }
}
return null
})
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
const switchProductCategory = (category: string) => { state.productCategory = category }
return {
state,
deliveryDateInput,
expectedDateInput,
deliveryDateNativeInput,
expectedDateNativeInput,
menuGroups,
openGroups,
activeMenu,
modalTitle,
parseDateTimeLocal,
toPickerValue,
toApiDateTime,
toNativeValue,
fromNativeValue,
destroyPickers,
initPickers,
openDateTimePicker,
getMovementTypeLabel,
getMovementBadgeClass,
getPurchaseOrderStatusLabel,
isPurchaseOrderLocked,
getSalesOrderStatusLabel,
getDeliveryStatusLabel,
getPaymentStatusLabel,
getReceiptStatusLabel,
checkBackendHealth,
loadDashboard,
loadFinishedProducts,
loadProducts,
loadMaterials,
loadWarehouses,
ensureStockBaseData,
loadSuppliers,
loadProductionOrders,
loadPurchaseOrders,
loadCustomers,
loadInventory,
loadMovements,
loadFinance,
refreshFinanceByPeriod,
switchTab,
closeModal,
toggleGroup,
handleMenuClick,
switchProductCategory,
formatCurrency,
formatNumber,
formatDateTime,
formatDate
}
return useInventoryStore()
}
@@ -0,0 +1,372 @@
<template>
<div class="page-container">
<div class="page-header">
<t-button variant="text" @click="router.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回
</t-button>
<h1>批量分析</h1>
<p>同时上传多个 STP 文件,并行分析、统一查看进度</p>
</div>
<!-- 上传区 -->
<div class="upload-layout" v-if="!state.batchId">
<div class="upload-main-card">
<h2 class="section-title">1. 选择多个 STP 文件</h2>
<div
:class="['upload-zone', { 'drag-over': state.dragOver }]"
@dragover.prevent="state.dragOver = true"
@dragleave.prevent="state.dragOver = false"
@drop="handleDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
accept=".stp,.step"
multiple
@change="handleFileChange"
hidden
/>
<div class="upload-icon">📁</div>
<div class="upload-text">
<span class="upload-title">点击选择或拖拽多个 STP/STEP 文件</span>
<span class="upload-hint">支持批量上传,单次最多 20 个文件</span>
</div>
</div>
<div v-if="state.selectedFiles.length" class="batch-file-list">
<div class="batch-file-header">
<span>已选择 {{ state.selectedFiles.length }} 个文件</span>
<t-button variant="text" size="small" @click="state.selectedFiles = []">清空</t-button>
</div>
<div v-for="(file, idx) in state.selectedFiles" :key="idx" class="batch-file-item">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<t-button variant="text" size="small" @click="removeFile(idx)">×</t-button>
</div>
</div>
<div v-if="state.error" class="error-message">{{ state.error }}</div>
</div>
<div class="upload-side-card">
<h2 class="section-title">2. 注塑模参数</h2>
<div class="material-panel compact-panel">
<t-form-item label="产品材料">
<t-select v-model="state.selectedMaterial">
<t-option value="ABS" label="ABS (1.05 g/cm³)" />
<t-option value="PP" label="PP (0.90 g/cm³)" />
<t-option value="PE" label="PE (0.95 g/cm³)" />
<t-option value="PC" label="PC (1.20 g/cm³)" />
<t-option value="PA" label="PA (1.14 g/cm³)" />
<t-option value="POM" label="POM (1.41 g/cm³)" />
<t-option value="PMMA" label="PMMA (1.18 g/cm³)" />
<t-option value="PBT" label="PBT (1.31 g/cm³)" />
</t-select>
</t-form-item>
</div>
<t-button
v-if="state.selectedFiles.length"
theme="primary"
class="upload-submit-btn"
@click="batchUpload"
:disabled="state.uploading"
>
{{ state.uploading ? '上传中...' : '3. 开始批量分析' }}
</t-button>
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始批量分析。</div>
</div>
</div>
<!-- 进度看板 -->
<div v-else class="batch-dashboard">
<div class="batch-progress-header">
<div class="batch-progress-info">
<h2>批量任务:{{ state.batchId.slice(0, 8) }}...</h2>
<p>
共 {{ state.batchData?.total }} 个任务,
<t-tag type="success" variant="light">完成 {{ state.batchData?.completed }}</t-tag>
<t-tag type="danger" variant="light" v-if="state.batchData?.failed">失败 {{ state.batchData?.failed }}</t-tag>
<t-tag type="warning" variant="light" v-if="state.batchData?.processing">进行中 {{ state.batchData?.processing }}</t-tag>
</p>
</div>
<t-button
variant="outline"
size="small"
@click="state.batchId = ''; state.batchData = null"
>
新建批量
</t-button>
</div>
<!-- 整体进度条 -->
<div class="progress-bar" style="margin-bottom: var(--space-4);">
<div
class="progress-fill"
:style="{ width: (state.batchData?.progress_percent || 0) + '%' }"
></div>
</div>
<!-- 任务列表 -->
<div class="table-container">
<t-table :data="state.batchData?.tasks || []" row-key="task_id" stripe>
<t-table-column title="文件名" colKey="filename" />
<t-table-column title="状态">
<template #default="{ row }">
<t-tag
:type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'"
variant="light"
>
{{ statusLabel(row.status) }}
</t-tag>
</template>
</t-table-column>
<t-table-column title="进度">
<template #default="{ row }">
<div v-if="row.status === 'processing'" class="progress-bar" style="width: 100px; display: inline-block;">
<div class="progress-fill" :style="{ width: (row.progress || 0) + '%' }"></div>
</div>
<span v-else>{{ row.progress || 0 }}%</span>
</template>
</t-table-column>
<t-table-column title="错误信息" colKey="error" />
<t-table-column title="操作">
<template #default="{ row }">
<t-button
v-if="row.status === 'completed'"
theme="primary"
size="small"
@click="router.push(`/moldinsight/result/${row.task_id}`)"
>
查看结果
</t-button>
<span v-else-if="row.status === 'failed'" style="color: var(--danger-color);">
{{ row.error || '分析失败' }}
</span>
<span v-else style="color: var(--text-secondary);">处理中...</span>
</template>
</t-table-column>
</t-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { handleApiError, addNotification } from '@/shared/notification'
import { clearAuth } from '@/shared/auth'
import { formatFileSize } from '@/shared/utils'
interface BatchTask {
task_id: string
filename: string
status: string
progress?: number
error?: string
html_file?: string
}
interface BatchData {
batch_id: string
total: number
completed: number
failed: number
processing: number
progress_percent: number
tasks: BatchTask[]
}
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
let pollTimer: ReturnType<typeof setTimeout> | null = null
const state = reactive({
selectedFiles: [] as File[],
selectedMaterial: 'ABS',
dragOver: false,
uploading: false,
error: '',
batchId: null as string | null,
batchData: null as BatchData | null,
})
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files || [])
addFiles(files)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
state.dragOver = false
const files = Array.from(event.dataTransfer?.files || [])
addFiles(files)
}
const addFiles = (files: File[]) => {
const valid = files.filter(f => {
const lower = f.name.toLowerCase()
return lower.endsWith('.stp') || lower.endsWith('.step')
})
if (valid.length < files.length) {
state.error = '部分文件格式不支持,已自动过滤非 STP/STEP 文件'
}
const combined = [...state.selectedFiles, ...valid]
if (combined.length > 20) {
state.error = '单次批量上传最多 20 个文件'
state.selectedFiles = combined.slice(0, 20)
} else {
state.selectedFiles = combined
}
}
const removeFile = (idx: number) => {
state.selectedFiles.splice(idx, 1)
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
processing: '处理中',
completed: '已完成',
failed: '失败',
unknown: '未知',
rejected: '已拒绝',
}
return map[status] || status
}
const batchUpload = async () => {
if (!appStore.token) {
state.error = '请先登录后再上传文件'
router.push('/login')
return
}
if (!state.selectedFiles.length) return
state.uploading = true
state.error = ''
const formData = new FormData()
for (const file of state.selectedFiles) {
formData.append('files', file)
}
formData.append('material', state.selectedMaterial)
formData.append('draft_angle', '2.0')
formData.append('shrinkage_rate', '0.5')
formData.append('parting_precision', '0.1')
formData.append('cavity_match', '95')
try {
const res = await fetch('/api/batch-upload', {
method: 'POST',
headers: { Authorization: `Bearer ${appStore.token}` },
body: formData,
})
if (res.status === 401) {
clearAuth()
router.push('/login')
return
}
if (!res.ok) throw new Error(`上传失败: ${res.status}`)
const data = await res.json()
state.batchId = data.batch_id
addNotification(`已创建批量任务:${data.accepted} 个文件已接受`, 'success')
startBatchPolling(data.batch_id)
} catch (e) {
state.error = handleApiError(e, '批量上传')
} finally {
state.uploading = false
}
}
const startBatchPolling = (batchId: string) => {
const poll = async () => {
try {
const data = await apiRequest<BatchData>(`/api/batch/${batchId}`)
state.batchData = data
// 全部完成或全部失败则停止轮询
if (data.processing === 0) {
addNotification(
`批量任务完成:${data.completed} 个成功,${data.failed} 个失败`,
data.failed > 0 ? 'warning' : 'success'
)
return
}
pollTimer = setTimeout(poll, 3000)
} catch (e) {
handleApiError(e, '查询批量状态')
}
}
poll()
}
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
})
onUnmounted(() => {
if (pollTimer) clearTimeout(pollTimer)
})
</script>
<style scoped>
.batch-file-list {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-2);
}
.batch-file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
font-weight: 600;
}
.batch-file-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-light);
}
.batch-file-item .file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.batch-file-item .file-size {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
}
.batch-dashboard {
margin-top: var(--space-4);
}
.batch-progress-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-2);
}
.batch-progress-info h2 {
margin: 0 0 var(--space-1);
}
.batch-progress-info p {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
</style>
@@ -3,6 +3,9 @@
<div class="page-header">
<h1>注塑模 STP 分析</h1>
<p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
<t-button variant="outline" size="small" @click="router.push('/moldinsight/batch')" style="margin-top: var(--space-2);">
📦 批量分析
</t-button>
</div>
<div class="moldinsight-intro-grid">
+263 -3
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">
@@ -120,12 +130,30 @@
<t-button type="default" size="small" @click="exportCAD('brep')" title="导出BRep格式(FreeCAD原生)">
导出 BRep
</t-button>
<t-button type="default" size="small" @click="createProductFromAnalysis" :loading="state.creatingProduct" title="将本次模具分析创建为进销存成品,可在进销存模块继续配置 BOM / 销售">
📋 创建为成品
</t-button>
<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
@@ -385,6 +413,97 @@
</template>
</div>
<div id="cost-estimate" class="viewer-section">
<div class="summary-header">
<h3>成本估算</h3>
<t-tag v-if="state.costResult" :type="state.costResult.source === 'ai' ? 'success' : 'warning'">
{{ state.costResult.source === 'ai' ? 'AI 估算' : '规则估算' }}
</t-tag>
<t-tag v-else theme="primary">待生成</t-tag>
</div>
<div class="result-card" style="margin-bottom: var(--space-3);">
<t-button
type="primary"
size="small"
:disabled="state.costLoading"
@click="estimateCost()"
title="基于当前方案估算模具造价与单件成本"
>
{{ state.costLoading ? '⏳ 估算中...' : '💰 生成成本估算' }}
</t-button>
</div>
<t-alert v-if="state.costError" theme="warning" title="成本估算失败" :message="state.costError" />
<template v-if="state.costResult">
<div class="result-grid">
<div class="result-card result-card-highlight">
<h4>模具造价</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">总造价</span>
<span class="info-value" style="font-size: 1.2rem; font-weight: 700; color: var(--primary-color);">
{{ state.costResult.total_mold_cost }}
</span>
</div>
<div class="info-item">
<span class="info-label">材料费</span>
<span class="info-value">{{ state.costResult.mold_cost.material }}</span>
</div>
<div class="info-item">
<span class="info-label">加工费</span>
<span class="info-value">{{ state.costResult.mold_cost.machining }}</span>
</div>
<div class="info-item">
<span class="info-label">复杂度系数</span>
<span class="info-value">{{ state.costResult.mold_cost.complexity_factor }}</span>
</div>
</div>
</div>
<div class="result-card">
<h4>单件成本</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">单件费用</span>
<span class="info-value" style="font-size: 1.1rem; font-weight: 600; color: var(--warning-color);">
{{ state.costResult.cost_per_part }}
</span>
</div>
<div class="info-item">
<span class="info-label">材料用量</span>
<span class="info-value">{{ state.costResult.part_cost.material }}</span>
</div>
<div class="info-item">
<span class="info-label">成型周期</span>
<span class="info-value">{{ state.costResult.part_cost.cycle_time }}</span>
</div>
<div class="info-item">
<span class="info-label">置信度</span>
<span class="info-value">{{ Math.round((state.costResult.confidence || 0) * 100) }}%</span>
</div>
</div>
</div>
</div>
<div v-if="state.costResult.assumptions?.length" class="result-card full-width" style="margin-top: var(--space-3);">
<h4>估算假设</h4>
<div class="recommendations-list">
<div
v-for="(assumption, idx) in state.costResult.assumptions"
:key="'cost-assumption-' + idx"
class="recommendation-item low"
>
<div class="recommendation-text">ℹ️ {{ assumption }}</div>
</div>
</div>
</div>
</template>
<t-alert v-else-if="!state.costError" theme="info" title="尚未生成" message="点击“生成成本估算”后,将基于当前方案估算模具造价与单件成本。" />
</div>
<div id="llm-report" class="viewer-section" v-if="hasVisibleDesignReport">
<div class="summary-header">
<h3>LLM 设计报告</h3>
@@ -438,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>
@@ -446,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()
@@ -476,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
@@ -561,6 +697,25 @@ interface CamOperation {
estimated_time_min?: number
}
interface CostEstimate {
mold_cost: {
material: string
machining: string
complexity_factor: string
subtotal: string
}
part_cost: {
material: string
cycle_time: string
cost_per_part: string
}
total_mold_cost: string
cost_per_part: string
confidence: number
assumptions: string[]
source?: string
}
interface SideActionAiAdvice {
source: string
status: string
@@ -603,7 +758,23 @@ const state = reactive({
surface_quality: 'standard',
controller: 'fanuc',
include_gcode: false
}
},
creatingProduct: false,
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 = [
@@ -644,12 +815,76 @@ const loadTask = async () => {
}
}
const createProductFromAnalysis = async () => {
const taskId = route.params.taskId as string
try {
state.creatingProduct = true
const product = await apiRequest<any>(`/api/products/from-task/${taskId}`, { method: 'POST' })
addNotification(`已创建成品:${product.name}(SKU: ${product.sku})`, 'success')
} catch (e) {
handleApiError(e, '创建成品')
} finally {
state.creatingProduct = false
}
}
// 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) => {
@@ -872,6 +1107,7 @@ const resultAnchorLinks = [
{ id: 'preview-3d', label: '预览' },
{ id: 'ai-side-action', label: 'AI倒扣分析' },
{ id: 'export-cam', label: 'CAM/CNC' },
{ id: 'cost-estimate', label: '成本估算' },
{ id: 'llm-report', label: '设计报告' }
]
@@ -1124,4 +1360,28 @@ const generateCamPlan = async () => {
state.camLoading = false
}
}
const estimateCost = async () => {
try {
state.costLoading = true
state.costError = ''
const taskId = route.params.taskId as string
const result = await apiRequest<any>('/api/cost-estimate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
})
state.costResult = result?.data || null
addNotification(
result?.data?.source === 'ai' ? 'AI 成本估算完成' : '规则成本估算完成',
'success'
)
} catch (e: any) {
state.costResult = null
state.costError = e.message || '成本估算失败'
addNotification(`成本估算失败: ${state.costError}`, 'error')
} finally {
state.costLoading = false
}
}
</script>
@@ -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>
+1 -1
View File
@@ -240,7 +240,7 @@ const resetPassword = async (user: UserItem) => {
try {
await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
method: 'PUT',
body: JSON.stringify(newPassword),
body: JSON.stringify({ new_password: newPassword }),
})
addNotification('密码已重置', 'success')
} catch (e) {
+18 -1
View File
@@ -8,8 +8,25 @@ const router = createRouter({
{ path: '/login', component: () => import('@/modules/login/LoginView.vue') },
{ path: '/users', component: () => import('@/modules/users/UsersView.vue') },
{ path: '/moldinsight', component: () => import('@/modules/moldinsight/MoldInsightView.vue') },
{ path: '/moldinsight/batch', component: () => import('@/modules/moldinsight/BatchView.vue') },
{ path: '/moldinsight/result/:taskId', component: () => import('@/modules/moldinsight/ResultView.vue') },
{ path: '/inventory', component: () => import('@/modules/inventory/InventoryView.vue') },
{
path: '/inventory',
component: () => import('@/modules/inventory/InventoryView.vue'),
redirect: '/inventory/dashboard',
children: [
{ path: 'dashboard', component: () => import('@/modules/inventory/components/DashboardTab.vue') },
{ path: 'products', component: () => import('@/modules/inventory/components/ProductsTab.vue') },
{ path: 'materials', component: () => import('@/modules/inventory/components/MaterialsTab.vue') },
{ path: 'inventory', component: () => import('@/modules/inventory/components/InventoryTab.vue') },
{ path: 'purchases', component: () => import('@/modules/inventory/components/PurchaseOrdersTab.vue') },
{ path: 'sales_orders', component: () => import('@/modules/inventory/components/SalesOrdersTab.vue') },
{ path: 'suppliers', component: () => import('@/modules/inventory/components/SuppliersTab.vue') },
{ path: 'customers', component: () => import('@/modules/inventory/components/CustomersTab.vue') },
{ path: 'finance', component: () => import('@/modules/inventory/components/FinanceTab.vue') },
{ path: 'movements', component: () => import('@/modules/inventory/components/MovementsTab.vue') },
],
},
{ path: '/_design-system', component: () => import('@/design-system/DesignSystemView.vue') },
{ path: '/_release', component: () => import('@/design-system/ReleaseView.vue') },
],
+449
View File
@@ -0,0 +1,449 @@
/**
* Typed domain API client — wraps `apiRequest` with OpenAPI-generated types.
*
* Usage:
* import { inventoryApi } from '@/shared/api-client'
* const products = await inventoryApi.listProducts({ item_type: 'finished' })
* // products is typed as Schema<'ProductResponse'>[]
*/
import { apiRequest } from './api'
import type { Schema } from '@/types/schemas'
// ── Auth ───────────────────────────────────────────────────────
export const authApi = {
login(username: string, password: string) {
const body = new URLSearchParams({ username, password })
return apiRequest<Schema<'Token'>>('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
},
loginJson(username: string, password: string) {
return apiRequest<Schema<'Token'>>('/api/auth/login/json', {
method: 'POST',
body: JSON.stringify({ username, password } satisfies Schema<'LoginRequest'>),
})
},
me() {
return apiRequest<Schema<'UserResponse'>>('/api/auth/me')
},
logout() {
return apiRequest('/api/auth/logout', { method: 'POST' })
},
// Users
listUsers() {
return apiRequest<Schema<'UserResponse'>[]>('/api/auth/users')
},
createUser(data: Schema<'UserCreate'>) {
return apiRequest<Schema<'UserResponse'>>('/api/auth/users', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateUser(userId: number, data: Schema<'UserUpdate'>) {
return apiRequest<Schema<'UserResponse'>>(`/api/auth/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteUser(userId: number) {
return apiRequest(`/api/auth/users/${userId}`, { method: 'DELETE' })
},
resetPassword(userId: number, newPassword: string) {
return apiRequest(`/api/auth/users/${userId}/reset-password`, {
method: 'PUT',
body: JSON.stringify({ new_password: newPassword }),
})
},
// Roles
listRoles() {
return apiRequest<Schema<'RoleResponse'>[]>('/api/auth/roles')
},
createRole(data: Schema<'RoleCreate'>) {
return apiRequest<Schema<'RoleResponse'>>('/api/auth/roles', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateRole(roleId: number, data: Schema<'RoleCreate'>) {
return apiRequest<Schema<'RoleResponse'>>(`/api/auth/roles/${roleId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteRole(roleId: number) {
return apiRequest(`/api/auth/roles/${roleId}`, { method: 'DELETE' })
},
setRolePermissions(roleId: number, permissionIds: number[]) {
return apiRequest(`/api/auth/roles/${roleId}/permissions`, {
method: 'PUT',
body: JSON.stringify({ permission_ids: permissionIds }),
})
},
// Permissions
listPermissions() {
return apiRequest<Schema<'PermissionResponse'>[]>('/api/auth/permissions')
},
}
// ── Inventory / ERP ────────────────────────────────────────────
export const inventoryApi = {
// Dashboard
dashboard() {
return apiRequest('/api/dashboard')
},
// Products
listProducts(params?: { item_type?: string; limit?: number }) {
const q = new URLSearchParams()
if (params?.item_type) q.set('item_type', params.item_type)
if (params?.limit) q.set('limit', String(params.limit))
const qs = q.toString()
return apiRequest<Schema<'ProductResponse'>[]>(`/api/products${qs ? `?${qs}` : ''}`)
},
createProduct(data: Schema<'ProductCreate'>) {
return apiRequest<Schema<'ProductResponse'>>('/api/products', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateProduct(id: number, data: Partial<Schema<'ProductCreate'>>) {
return apiRequest<Schema<'ProductResponse'>>(`/api/products/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteProduct(id: number) {
return apiRequest(`/api/products/${id}`, { method: 'DELETE' })
},
getProductMaterials(productId: number) {
return apiRequest<{ items: Schema<'ProductMaterialItemResponse'>[] }>(`/api/products/${productId}/materials`)
},
// from-task (mold analysis → product)
createProductFromTask(taskId: string) {
return apiRequest<Schema<'ProductResponse'>>(`/api/products/from-task/${taskId}`, {
method: 'POST',
})
},
// Warehouses
listWarehouses() {
return apiRequest<Schema<'WarehouseResponse'>[]>('/api/warehouses')
},
createWarehouse(data: Schema<'WarehouseCreate'>) {
return apiRequest<Schema<'WarehouseResponse'>>('/api/warehouses', {
method: 'POST',
body: JSON.stringify(data),
})
},
// Inventory
listInventory() {
return apiRequest<{ items: Schema<'InventoryResponse'>[] }>('/api/inventory')
},
// Stock Movements
listStockMovements(params?: { limit?: number }) {
const q = params?.limit ? `?limit=${params.limit}` : ''
return apiRequest<{ items: Schema<'StockMovementResponse'>[] }>(`/api/stock-movements${q}`)
},
// Suppliers
listSuppliers() {
return apiRequest<Schema<'SupplierResponse'>[]>('/api/suppliers')
},
createSupplier(data: Schema<'SupplierCreate'>) {
return apiRequest<Schema<'SupplierResponse'>>('/api/suppliers', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateSupplier(id: number, data: Partial<Schema<'SupplierCreate'>>) {
return apiRequest<Schema<'SupplierResponse'>>(`/api/suppliers/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteSupplier(id: number) {
return apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' })
},
// Customers
listCustomers() {
return apiRequest<Schema<'CustomerResponse'>[]>('/api/customers')
},
createCustomer(data: Schema<'CustomerCreate'>) {
return apiRequest<Schema<'CustomerResponse'>>('/api/customers', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateCustomer(id: number, data: Partial<Schema<'CustomerCreate'>>) {
return apiRequest<Schema<'CustomerResponse'>>(`/api/customers/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteCustomer(id: number) {
return apiRequest(`/api/customers/${id}`, { method: 'DELETE' })
},
// Purchase Orders
listPurchaseOrders(params?: { limit?: number }) {
const q = params?.limit ? `?limit=${params.limit}` : ''
return apiRequest<{ items: Schema<'PurchaseOrderResponse'>[] }>(`/api/purchase-orders${q}`)
},
createPurchaseOrder(data: Schema<'PurchaseOrderCreate'>) {
return apiRequest<Schema<'PurchaseOrderResponse'>>('/api/purchase-orders', {
method: 'POST',
body: JSON.stringify(data),
})
},
updatePurchaseOrder(id: number, data: Partial<Schema<'PurchaseOrderCreate'>>) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
receivePurchaseOrder(id: number, data: Schema<'PurchaseOrderReceiveRequest'>) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}/receive`, {
method: 'POST',
body: JSON.stringify(data),
})
},
updatePurchaseOrderStatus(id: number, status: string) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
},
// Sales Orders
listSalesOrders(params?: { limit?: number }) {
const q = params?.limit ? `?limit=${params.limit}` : ''
return apiRequest<{ items: Schema<'SalesOrderResponse'>[] }>(`/api/sales-orders${q}`)
},
getSalesOrder(id: number) {
return apiRequest<Schema<'SalesOrderDetailResponse'>>(`/api/sales-orders/${id}`)
},
createSalesOrder(data: Schema<'SalesOrderCreate'>) {
return apiRequest<Schema<'SalesOrderResponse'>>('/api/sales-orders', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateSalesOrder(id: number, data: Partial<Schema<'SalesOrderCreate'>>) {
return apiRequest<Schema<'SalesOrderResponse'>>(`/api/sales-orders/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteSalesOrder(id: number) {
return apiRequest(`/api/sales-orders/${id}`, { method: 'DELETE' })
},
// Purchase Demands (采购需求推导)
calculatePurchaseDemands(salesOrderIds: number[]) {
return apiRequest<Schema<'PurchaseDemandResponse'>>('/api/purchase-demands/calculate', {
method: 'POST',
body: JSON.stringify({ sales_order_ids: salesOrderIds } satisfies Schema<'PurchaseDemandCalculateRequest'>),
})
},
// Finance
financeSummary(params?: { year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'FinanceSummaryResponse'>>(`/api/finance/summary?${q}`)
},
financeTransactions(params?: { status?: string; limit?: number; year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.status) q.set('status', params.status)
if (params?.limit) q.set('limit', String(params.limit))
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<{ items: Schema<'FinanceTransactionResponse'>[] }>(`/api/finance/transactions?${q}`)
},
financeReceivables(params?: { limit?: number; year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'ReceivableItemResponse'>[]>(`/api/finance/receivables?${q}`)
},
financePayables(params?: { limit?: number; year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'PayableItemResponse'>[]>(`/api/finance/payables?${q}`)
},
financePartnerStatement(partnerType: 'customer' | 'supplier', params?: { year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'FinancePartnerStatementResponse'>>(`/api/finance/partner-statement/${partnerType}?${q}`)
},
financePartnerProductStatement(partnerType: 'customer' | 'supplier', params?: { year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'FinancePartnerProductStatementResponse'>>(`/api/finance/partner-product-statement/${partnerType}?${q}`)
},
}
// ── MoldInsight ────────────────────────────────────────────────
export const moldinsightApi = {
uploadStp(formData: FormData) {
return apiRequest<{ task_id: string }>('/api/upload', {
method: 'POST',
body: formData,
})
},
getStatus(taskId: string) {
return apiRequest<{
task_id: string
status: string
progress: number
result?: Record<string, unknown>
error?: string
}>(`/api/status/${taskId}`)
},
batchUpload(formData: FormData) {
return apiRequest<{
batch_id: string
accepted: number
rejected: number
task_ids: string[]
}>('/api/batch-upload', {
method: 'POST',
body: formData,
})
},
getBatchStatus(batchId: string) {
return apiRequest<{
batch_id: string
total: number
completed: number
failed: number
processing: number
progress_percent: number
tasks: Array<{
task_id: string
filename: string
status: string
progress?: number
error?: string
html_file?: string
}>
}>(`/api/batch/${batchId}`)
},
estimateCost(data: {
task_id?: string
material?: string
mold_type?: string
cavity_count?: number
weight?: number
dimensions?: { length: number; width: number; height: number }
}) {
return apiRequest<{
mold_cost?: number
part_cost?: number
total_mold_cost?: number
confidence?: number
assumptions?: string[]
currency?: string
}>('/api/cost-estimate', {
method: 'POST',
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),
})
},
}
+463
View File
@@ -0,0 +1,463 @@
import { defineStore } from 'pinia'
import { reactive, ref, computed } from 'vue'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
import type { Schema } from '@/types/schemas'
declare const AirDatepicker: any
// Dashboard has no Pydantic schema in backend, define it here
interface DashboardData {
finished_product_count: number
total_stock: number
total_value: number
supplier_count: number
customer_count: number
warehouse_count: number
}
export const useInventoryStore = defineStore('inventory', () => {
// ── state ──
const activeTab = ref('dashboard')
const backendDbReady = ref(true)
const backendDbMessage = ref('')
const productCategory = ref('finished')
const dashboard = ref<DashboardData | null>(null)
const financeSummary = ref<Schema<'FinanceSummaryResponse'> | null>(null)
const financePeriod = reactive({ year: new Date().getFullYear(), quarter: '' as string })
const financeTransactions = ref<Schema<'FinanceTransactionResponse'>[]>([])
const receivables = ref<Schema<'ReceivableItemResponse'>[]>([])
const payables = ref<Schema<'PayableItemResponse'>[]>([])
const customerFinanceStatement = ref<Schema<'PartnerStatementItemResponse'>[]>([])
const supplierFinanceStatement = ref<Schema<'PartnerStatementItemResponse'>[]>([])
const customerProductStatement = ref<Schema<'PartnerProductStatementItemResponse'>[]>([])
const supplierProductStatement = ref<Schema<'PartnerProductStatementItemResponse'>[]>([])
const products = ref<Schema<'ProductResponse'>[]>([])
const materials = ref<Schema<'ProductResponse'>[]>([])
const finishedProducts = ref<Schema<'ProductResponse'>[]>([])
const purchaseOrders = ref<Schema<'PurchaseOrderResponse'>[]>([])
const purchaseWarehouseId = ref<number | null>(null)
const purchaseReceiveItems = ref<any[]>([])
const productionOrders = ref<Schema<'SalesOrderResponse'>[]>([])
const productionPlan = ref<any>(null)
const productionWarehouseId = ref<number | null>(null)
const suppliers = ref<Schema<'SupplierResponse'>[]>([])
const customers = ref<Schema<'CustomerResponse'>[]>([])
const warehouses = ref<Schema<'WarehouseResponse'>[]>([])
const inventory = ref<Schema<'InventoryResponse'>[]>([])
const movements = ref<Schema<'StockMovementResponse'>[]>([])
const loading = ref(false)
const showModal = ref(false)
const modalType = ref('')
const editingItem = ref<any>(null)
const productBomItems = ref<any[]>([])
const materialConsumptionItems = ref<any[]>([])
const showMaterialConsumptionModal = ref(false)
const consumedMaterials = ref<any[]>([])
const restockItems = ref<any[]>([])
const showRestockModal = ref(false)
const form = ref<any>({})
// ── date-picker refs ──
let deliveryPicker: any = null
let expectedPicker: any = null
const deliveryDateInput = ref<HTMLElement | null>(null)
const expectedDateInput = ref<HTMLElement | null>(null)
const deliveryDateNativeInput = ref<HTMLElement | null>(null)
const expectedDateNativeInput = ref<HTMLElement | null>(null)
// ── legacy state object (backward-compat for components still using `state.xxx`) ──
const state = reactive({
get activeTab() { return activeTab.value }, set activeTab(v) { activeTab.value = v },
get backendDbReady() { return backendDbReady.value }, set backendDbReady(v) { backendDbReady.value = v },
get backendDbMessage() { return backendDbMessage.value }, set backendDbMessage(v) { backendDbMessage.value = v },
get productCategory() { return productCategory.value }, set productCategory(v) { productCategory.value = v },
get dashboard() { return dashboard.value }, set dashboard(v) { dashboard.value = v },
get financeSummary() { return financeSummary.value }, set financeSummary(v) { financeSummary.value = v },
financePeriod,
get financeTransactions() { return financeTransactions.value }, set financeTransactions(v) { financeTransactions.value = v },
get receivables() { return receivables.value }, set receivables(v) { receivables.value = v },
get payables() { return payables.value }, set payables(v) { payables.value = v },
get customerFinanceStatement() { return customerFinanceStatement.value }, set customerFinanceStatement(v) { customerFinanceStatement.value = v },
get supplierFinanceStatement() { return supplierFinanceStatement.value }, set supplierFinanceStatement(v) { supplierFinanceStatement.value = v },
get customerProductStatement() { return customerProductStatement.value }, set customerProductStatement(v) { customerProductStatement.value = v },
get supplierProductStatement() { return supplierProductStatement.value }, set supplierProductStatement(v) { supplierProductStatement.value = v },
get products() { return products.value }, set products(v) { products.value = v },
get materials() { return materials.value }, set materials(v) { materials.value = v },
get finishedProducts() { return finishedProducts.value }, set finishedProducts(v) { finishedProducts.value = v },
get purchaseOrders() { return purchaseOrders.value }, set purchaseOrders(v) { purchaseOrders.value = v },
get purchaseWarehouseId() { return purchaseWarehouseId.value }, set purchaseWarehouseId(v) { purchaseWarehouseId.value = v },
get purchaseReceiveItems() { return purchaseReceiveItems.value }, set purchaseReceiveItems(v) { purchaseReceiveItems.value = v },
get productionOrders() { return productionOrders.value }, set productionOrders(v) { productionOrders.value = v },
get productionPlan() { return productionPlan.value }, set productionPlan(v) { productionPlan.value = v },
get productionWarehouseId() { return productionWarehouseId.value }, set productionWarehouseId(v) { productionWarehouseId.value = v },
get suppliers() { return suppliers.value }, set suppliers(v) { suppliers.value = v },
get customers() { return customers.value }, set customers(v) { customers.value = v },
get warehouses() { return warehouses.value }, set warehouses(v) { warehouses.value = v },
get inventory() { return inventory.value }, set inventory(v) { inventory.value = v },
get movements() { return movements.value }, set movements(v) { movements.value = v },
get loading() { return loading.value }, set loading(v) { loading.value = v },
get showModal() { return showModal.value }, set showModal(v) { showModal.value = v },
get modalType() { return modalType.value }, set modalType(v) { modalType.value = v },
get editingItem() { return editingItem.value }, set editingItem(v) { editingItem.value = v },
get productBomItems() { return productBomItems.value }, set productBomItems(v) { productBomItems.value = v },
get materialConsumptionItems() { return materialConsumptionItems.value }, set materialConsumptionItems(v) { materialConsumptionItems.value = v },
get showMaterialConsumptionModal() { return showMaterialConsumptionModal.value }, set showMaterialConsumptionModal(v) { showMaterialConsumptionModal.value = v },
get consumedMaterials() { return consumedMaterials.value }, set consumedMaterials(v) { consumedMaterials.value = v },
get restockItems() { return restockItems.value }, set restockItems(v) { restockItems.value = v },
get showRestockModal() { return showRestockModal.value }, set showRestockModal(v) { showRestockModal.value = v },
get form() { return form.value }, set form(v) { form.value = v },
})
// ── helpers ──
const parseDateTimeLocal = (text: string | null | undefined): Date | null => {
if (!text) return null
const raw = String(text).trim()
const normalized = raw.replace('T', ' ').slice(0, 16)
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/)
if (!m) return null
const year = Number(m[1]); const month = Number(m[2]); const day = Number(m[3])
const hour = Number(m[4]); const minute = Number(m[5])
if (!Number.isFinite(year + month + day + hour + minute)) return null
return new Date(year, month - 1, day, hour, minute, 0)
}
const toPickerValue = (value: any): string => {
if (!value) return ''
const raw = String(value).trim()
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16)
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16)
const dt = new Date(raw)
if (!Number.isFinite(dt.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
const toApiDateTime = (value: any): string | null => {
if (!value) return null
const text = String(value).trim()
if (text.includes('T')) return text.split('T')[0]
if (text.includes(' ')) return text.split(' ')[0]
if (text.length === 10) return text
if (value instanceof Date) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
return text
}
const toNativeValue = (value: any): string => {
if (!value) return ''
const text = String(value).trim()
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
const isoText = text.replace(' ', 'T')
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText
}
const fromNativeValue = (value: any): string => {
if (!value) return ''
const text = String(value).trim()
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
return text.replace('T', ' ').slice(0, 16)
}
// ── pickers ──
const destroyPickers = () => {
if (deliveryPicker) { deliveryPicker.destroy(); deliveryPicker = null }
if (expectedPicker) { expectedPicker.destroy(); expectedPicker = null }
}
const initPickers = () => {
destroyPickers()
if (typeof AirDatepicker !== 'function') return
if (modalType.value === 'salesOrder' && deliveryDateInput.value) {
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
timepicker: false, autoClose: true, zIndex: 2005, dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
form.value.delivery_date = formattedDate || ''
form.value.delivery_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(form.value.delivery_date)
if (initial) deliveryPicker.selectDate(initial, { silent: true })
}
if (modalType.value === 'purchaseOrder' && expectedDateInput.value) {
expectedPicker = new AirDatepicker(expectedDateInput.value, {
timepicker: false, autoClose: true, zIndex: 2005, dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
form.value.expected_date = formattedDate || ''
form.value.expected_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(form.value.expected_date)
if (initial) expectedPicker.selectDate(initial, { silent: true })
}
}
const openDateTimePicker = (pickerKind: string) => {
if (pickerKind === 'delivery' && deliveryPicker) { deliveryPicker.show(); return }
if (pickerKind === 'expected' && expectedPicker) { expectedPicker.show(); return }
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value
if (!nativeInput) return
if (typeof (nativeInput as any).showPicker === 'function') { (nativeInput as any).showPicker(); return }
nativeInput.focus(); nativeInput.click()
}
// ── label helpers ──
const getMovementTypeLabel = (movementType: string): string => {
const map: Record<string, string> = {
in: '其他入库', out: '其他出库', adjust: '库存调整',
purchase_in: '采购入库', return_from_production: '生产退料入库',
outsource_return: '外协回库', finish_in: '完工入库',
issue_to_production: '生产领料出库', outsource_send: '外协发料出库',
shipment_out: '销售出库', scrap_out: '报废出库'
}
return map[movementType] || movementType
}
const getMovementBadgeClass = (movementType: string): string => {
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) return 'badge-success'
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) return 'badge-error'
return 'badge-warning'
}
const getPurchaseOrderStatusLabel = (status: string): string => {
const map: Record<string, string> = { draft: '已下单', pending: '已下单', partial_received: '部分收货', received: '已收货', paid: '已付款', cancelled: '已作废' }
return map[status] || status
}
const isPurchaseOrderLocked = (status: string): boolean => ['received', 'paid', 'cancelled'].includes(status)
const getSalesOrderStatusLabel = (status: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', paid: '已收款', cancelled: '已作废' }
return map[status] || status
}
const getDeliveryStatusLabel = (ds: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', cancelled: '已作废' }
return map[ds] || ds
}
const getPaymentStatusLabel = (ps: string): string => {
const map: Record<string, string> = { unpaid: '未收款', paid: '已收款' }
return map[ps] || ps
}
const getReceiptStatusLabel = (rs: string): string => {
const map: Record<string, string> = { pending: '已下单', partial_received: '部分收货', received: '已收货', cancelled: '已作废' }
return map[rs] || rs
}
// ── data loading ──
const checkBackendHealth = async () => {
try {
const resp = await fetch('/health', { method: 'GET' })
if (!resp.ok) { backendDbReady.value = false; backendDbMessage.value = '后端服务异常,暂无法加载业务数据'; return }
const health = await resp.json().catch(() => null)
if (health && health.database_connected === false) { backendDbReady.value = false; backendDbMessage.value = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'; return }
backendDbReady.value = true; backendDbMessage.value = ''
} catch { backendDbReady.value = false; backendDbMessage.value = '无法连接后端服务' }
}
const loadDashboard = async () => {
loading.value = true
try { dashboard.value = await apiRequest('/api/dashboard') }
catch (e) { handleApiError(e, '加载仪表盘') }
finally { loading.value = false }
}
const loadFinishedProducts = async () => {
loading.value = true
try { finishedProducts.value = await apiRequest('/api/products?item_type=finished&limit=100') }
catch (e) { handleApiError(e, '加载成品') }
finally { loading.value = false }
}
const loadProducts = async () => { await loadFinishedProducts() }
const loadMaterials = async () => {
loading.value = true
try { materials.value = await apiRequest('/api/products?item_type=material&limit=100') }
catch (e) { handleApiError(e, '加载物料') }
finally { loading.value = false }
}
const loadWarehouses = async () => {
loading.value = true
try { warehouses.value = await apiRequest('/api/warehouses') }
catch (e) { handleApiError(e, '加载仓库') }
finally { loading.value = false }
}
const ensureStockBaseData = async () => {
if (!materials.value.length) await loadMaterials()
if (!warehouses.value.length) await loadWarehouses()
if (!warehouses.value.length) {
try {
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
await loadWarehouses()
addNotification('已自动创建默认仓库', 'success')
} catch (e) { handleApiError(e, '自动创建默认仓库') }
}
}
const loadSuppliers = async () => {
loading.value = true
try { suppliers.value = await apiRequest('/api/suppliers') }
catch (e) { handleApiError(e, '加载供应商') }
finally { loading.value = false }
}
const loadProductionOrders = async () => {
loading.value = true
try {
const [orders, wh] = await Promise.all([apiRequest('/api/sales-orders?limit=100'), apiRequest('/api/warehouses')])
productionOrders.value = orders?.items || []
warehouses.value = wh || []
if (!productionWarehouseId.value) {
productionWarehouseId.value = warehouses.value.find((w: any) => w.is_default)?.id || warehouses.value[0]?.id || null
}
} catch (e) { handleApiError(e, '加载按单生产数据') }
finally { loading.value = false }
}
const loadPurchaseOrders = async () => {
loading.value = true
try {
const [orders, wh] = await Promise.all([apiRequest('/api/purchase-orders?limit=100'), apiRequest('/api/warehouses')])
purchaseOrders.value = orders?.items || []
warehouses.value = wh || []
if (!purchaseWarehouseId.value) {
purchaseWarehouseId.value = warehouses.value.find((w: any) => w.is_default)?.id || warehouses.value[0]?.id || null
}
} catch (e) { handleApiError(e, '加载采购订单') }
finally { loading.value = false }
}
const loadCustomers = async () => {
loading.value = true
try { customers.value = await apiRequest('/api/customers') }
catch (e) { handleApiError(e, '加载客户') }
finally { loading.value = false }
}
const loadInventory = async () => {
loading.value = true
try { inventory.value = (await apiRequest('/api/inventory'))?.items || [] }
catch (e) { handleApiError(e, '加载库存') }
finally { loading.value = false }
}
const loadMovements = async () => {
loading.value = true
try { movements.value = (await apiRequest('/api/stock-movements'))?.items || [] }
catch (e) { handleApiError(e, '加载变动记录') }
finally { loading.value = false }
}
const loadFinance = async () => {
loading.value = true
try {
const selectedYear = Number(financePeriod.year) || new Date().getFullYear()
const selectedQuarter = financePeriod.quarter ? Number(financePeriod.quarter) : null
const periodQuery = selectedQuarter ? `year=${selectedYear}&quarter=${selectedQuarter}` : `year=${selectedYear}`
const [summary, transactions, recv, pay, custStmt, suppStmt, custProdStmt, suppProdStmt] = await Promise.all([
apiRequest(`/api/finance/summary?${periodQuery}`),
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
])
financeSummary.value = summary
financeTransactions.value = transactions?.items || []
receivables.value = recv
payables.value = pay
customerFinanceStatement.value = custStmt.items || []
supplierFinanceStatement.value = suppStmt.items || []
customerProductStatement.value = custProdStmt.items || []
supplierProductStatement.value = suppProdStmt.items || []
} catch (e) { handleApiError(e, '加载财务数据') }
finally { loading.value = false }
}
const refreshFinanceByPeriod = () => { if (activeTab.value === 'finance') loadFinance() }
const switchTab = (tab: string) => { activeTab.value = tab }
const closeModal = () => {
showModal.value = false; modalType.value = ''; editingItem.value = null
productBomItems.value = []; purchaseReceiveItems.value = []; form.value = {}
destroyPickers()
}
const modalTitle = computed(() => {
const prefix = editingItem.value ? '编辑' : '新增'
const typeMap: Record<string, string> = {
product: form.value.item_type === 'finished' ? '成品' : '物料',
inventoryItem: '物料库存', salesOrder: '销售订单', purchaseOrder: '采购订单',
purchaseReceive: '采购到货入库', supplier: '供应商', customer: '客户'
}
return prefix + (typeMap[modalType.value] || '')
})
const menuGroups = [
{ key: 'overview', title: '概览', items: [{ key: 'dashboard', label: '仪表盘' }] },
{ key: 'sales', title: '销售', items: [{ key: 'sales_orders', label: '销售订单管理' }] },
{ key: 'purchase', title: '采购', items: [{ key: 'purchases', label: '采购订单管理' }] },
{ key: 'product', title: '产品', items: [{ key: 'products', label: '成品管理' }, { key: 'materials', label: '物料管理' }] },
{ key: 'partner', title: '往来单位', items: [{ key: 'customers', label: '客户管理' }, { key: 'suppliers', label: '供应商管理' }] },
{ key: 'warehouse', title: '仓库', items: [{ key: 'inventory', label: '库存管理' }, { key: 'movements', label: '库存变动记录' }] },
{ key: 'finance', title: '财务', items: [{ key: 'finance', label: '财务概览' }] }
]
const openGroups = reactive<Record<string, boolean>>(
Object.fromEntries(menuGroups.map(g => [g.key, true]))
)
const toggleGroup = (groupKey: string) => { openGroups[groupKey] = !openGroups[groupKey] }
const activeMenu = computed(() => {
for (const group of menuGroups) {
const item = group.items.find(i => i.key === activeTab.value)
if (item) return { group, item }
}
return null
})
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
const switchProductCategory = (category: string) => { productCategory.value = category }
return {
// state refs (new Pinia style)
activeTab, backendDbReady, backendDbMessage, productCategory, dashboard,
financeSummary, financePeriod, financeTransactions, receivables, payables,
customerFinanceStatement, supplierFinanceStatement, customerProductStatement, supplierProductStatement,
products, materials, finishedProducts, purchaseOrders, purchaseWarehouseId, purchaseReceiveItems,
productionOrders, productionPlan, productionWarehouseId, suppliers, customers, warehouses,
inventory, movements, loading, showModal, modalType, editingItem, productBomItems,
materialConsumptionItems, showMaterialConsumptionModal, consumedMaterials,
restockItems, showRestockModal, form,
// legacy state object (backward compat)
state,
// refs
deliveryDateInput, expectedDateInput, deliveryDateNativeInput, expectedDateNativeInput,
// computed
menuGroups, openGroups, activeMenu, modalTitle,
// actions
parseDateTimeLocal, toPickerValue, toApiDateTime, toNativeValue, fromNativeValue,
destroyPickers, initPickers, openDateTimePicker,
getMovementTypeLabel, getMovementBadgeClass, getPurchaseOrderStatusLabel, isPurchaseOrderLocked,
getSalesOrderStatusLabel, getDeliveryStatusLabel, getPaymentStatusLabel, getReceiptStatusLabel,
checkBackendHealth, loadDashboard, loadFinishedProducts, loadProducts, loadMaterials,
loadWarehouses, ensureStockBaseData, loadSuppliers, loadProductionOrders, loadPurchaseOrders,
loadCustomers, loadInventory, loadMovements, loadFinance, refreshFinanceByPeriod,
switchTab, closeModal, toggleGroup, handleMenuClick, switchProductCategory,
formatCurrency, formatNumber, formatDateTime, formatDate
}
})
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
/**
* 后端 OpenAPI schema 类型快捷引用
*
* 类型由 `npm run gen:api` 从后端 openapi-inventory.json 自动生成(见 ./api.ts)。
* 后端字段变更后重新生成即可,前端类型自动同步,杜绝手写类型漂移。
*
* 用法:Schema<'FinanceSummaryResponse'>、Schema<'SalesOrderResponse'>[]
*/
import type { components } from './api'
export type Schema<K extends keyof components['schemas']> = components['schemas'][K]
+11 -3
View File
@@ -2,16 +2,16 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig(({ mode }) => ({
export default defineConfig(() => ({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
base: mode === 'development' ? '/' : '/static/',
base: '/',
build: {
outDir: '../static',
outDir: 'dist',
emptyOutDir: true,
},
server: {
@@ -21,6 +21,14 @@ export default defineConfig(({ mode }) => ({
target: 'http://localhost:8000',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/html': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
}))
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+76
View File
@@ -0,0 +1,76 @@
"""Alembic 迁移环境配置
- 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用
(项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2)
- target_metadata 指向 shared.models.base.Base.metadata(全量模型注册见下方 import)
- 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db)
"""
from logging.config import fileConfig
from pathlib import Path
import os
import sys
from sqlalchemy import engine_from_config, pool
from alembic import context
# 让 alembic 能 import 项目模块(src 在项目根下)
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from shared.config.settings import settings # noqa: E402
from shared.models.base import Base # noqa: E402
# 导入全部三包模型,确保 metadata 注册(全量注册点约定见 shared/models/base.py)
import shared.models.identity # noqa: E402,F401
import moldinsight.models # noqa: E402,F401
import inventory.models # noqa: E402,F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 构造同步 URL:asyncpg -> psycopg2
_sync_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
# 支持 ALEMBIC_URL 覆盖(离线生成/测试用)
config.set_main_option("sqlalchemy.url", os.getenv("ALEMBIC_URL", _sync_url))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线模式:生成 SQL 脚本,不连接 DB"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""在线模式:连接 DB 执行迁移"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,38 @@
"""add product_id to stp_files
Revision ID: 006c18c51b0d
Revises: 9928d7f8c1ef
Create Date: 2026-07-23 10:37:56.516787
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '006c18c51b0d'
down_revision: Union[str, Sequence[str], None] = '9928d7f8c1ef'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema: stp_files 加 product_id 外键,关联进销存成品。"""
op.add_column("stp_files", sa.Column("product_id", sa.Integer(), nullable=True))
op.create_index("ix_stp_files_product_id", "stp_files", ["product_id"])
op.create_foreign_key(
"fk_stp_files_product_id_products",
"stp_files",
"products",
["product_id"],
["id"],
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_constraint("fk_stp_files_product_id_products", "stp_files", type_="foreignkey")
op.drop_index("ix_stp_files_product_id", table_name="stp_files")
op.drop_column("stp_files", "product_id")
@@ -0,0 +1,728 @@
"""initial schema
Revision ID: 9928d7f8c1ef
Revises:
Create Date: 2026-07-20 10:30:30.249866
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9928d7f8c1ef'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('customers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('contact_person', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('bank_name', sa.String(length=100), nullable=True),
sa.Column('bank_account', sa.String(length=50), nullable=True),
sa.Column('tax_number', sa.String(length=50), nullable=True),
sa.Column('credit_limit', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_customers_code'), 'customers', ['code'], unique=True)
op.create_index(op.f('ix_customers_id'), 'customers', ['id'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('module', sa.String(length=50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_permissions_code'), 'permissions', ['code'], unique=True)
op.create_index(op.f('ix_permissions_id'), 'permissions', ['id'], unique=False)
op.create_table('products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sku', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('unit', sa.String(length=20), nullable=True),
sa.Column('item_type', sa.String(length=20), nullable=True),
sa.Column('cost_price', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('sale_price', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('min_stock', sa.Integer(), nullable=True),
sa.Column('max_stock', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False)
op.create_index(op.f('ix_products_item_type'), 'products', ['item_type'], unique=False)
op.create_index(op.f('ix_products_sku'), 'products', ['sku'], unique=True)
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_system', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_roles_code'), 'roles', ['code'], unique=True)
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
op.create_table('suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('contact_person', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('bank_name', sa.String(length=100), nullable=True),
sa.Column('bank_account', sa.String(length=50), nullable=True),
sa.Column('tax_number', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_suppliers_code'), 'suppliers', ['code'], unique=True)
op.create_index(op.f('ix_suppliers_id'), 'suppliers', ['id'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=100), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('warehouses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('manager', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_warehouses_code'), 'warehouses', ['code'], unique=True)
op.create_index(op.f('ix_warehouses_id'), 'warehouses', ['id'], unique=False)
op.create_table('finance_transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('txn_no', sa.String(length=50), nullable=False),
sa.Column('txn_type', sa.String(length=20), nullable=False),
sa.Column('partner_type', sa.String(length=20), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('txn_date', sa.DateTime(), nullable=True),
sa.Column('method', sa.String(length=30), nullable=True),
sa.Column('account_name', sa.String(length=100), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_finance_transactions_created_at'), 'finance_transactions', ['created_at'], unique=False)
op.create_index(op.f('ix_finance_transactions_id'), 'finance_transactions', ['id'], unique=False)
op.create_index(op.f('ix_finance_transactions_partner_id'), 'finance_transactions', ['partner_id'], unique=False)
op.create_index(op.f('ix_finance_transactions_partner_type'), 'finance_transactions', ['partner_type'], unique=False)
op.create_index(op.f('ix_finance_transactions_status'), 'finance_transactions', ['status'], unique=False)
op.create_index(op.f('ix_finance_transactions_txn_date'), 'finance_transactions', ['txn_date'], unique=False)
op.create_index(op.f('ix_finance_transactions_txn_no'), 'finance_transactions', ['txn_no'], unique=True)
op.create_index(op.f('ix_finance_transactions_txn_type'), 'finance_transactions', ['txn_type'], unique=False)
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('locked_quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('batch_number', sa.String(length=50), nullable=True),
sa.Column('location', sa.String(length=100), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.CheckConstraint('quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity', name='ck_inventory_qty_nonnegative'),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id', 'warehouse_id', name='uq_inventory_product_warehouse')
)
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
op.create_index(op.f('ix_inventory_product_id'), 'inventory', ['product_id'], unique=False)
op.create_index(op.f('ix_inventory_warehouse_id'), 'inventory', ['warehouse_id'], unique=False)
op.create_table('material_price_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('effective_date', sa.DateTime(), nullable=True),
sa.Column('supplier_id', sa.Integer(), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_material_price_history_effective_date'), 'material_price_history', ['effective_date'], unique=False)
op.create_index(op.f('ix_material_price_history_id'), 'material_price_history', ['id'], unique=False)
op.create_index(op.f('ix_material_price_history_product_id'), 'material_price_history', ['product_id'], unique=False)
op.create_index(op.f('ix_material_price_history_supplier_id'), 'material_price_history', ['supplier_id'], unique=False)
op.create_table('material_suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('supplier_id', sa.Integer(), nullable=False),
sa.Column('is_primary', sa.Boolean(), nullable=True),
sa.Column('contact_person', sa.String(length=100), nullable=True),
sa.Column('contact_phone', sa.String(length=50), nullable=True),
sa.Column('lead_time', sa.Integer(), nullable=True),
sa.Column('min_order_quantity', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_material_suppliers_id'), 'material_suppliers', ['id'], unique=False)
op.create_index(op.f('ix_material_suppliers_product_id'), 'material_suppliers', ['product_id'], unique=False)
op.create_index(op.f('ix_material_suppliers_supplier_id'), 'material_suppliers', ['supplier_id'], unique=False)
op.create_table('product_materials',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('finished_product_id', sa.Integer(), nullable=False),
sa.Column('material_product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(precision=12, scale=4), nullable=False),
sa.Column('loss_rate', sa.Numeric(precision=5, scale=4), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['finished_product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['material_product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('finished_product_id', 'material_product_id', name='uq_product_material_unique')
)
op.create_index(op.f('ix_product_materials_finished_product_id'), 'product_materials', ['finished_product_id'], unique=False)
op.create_index(op.f('ix_product_materials_id'), 'product_materials', ['id'], unique=False)
op.create_index(op.f('ix_product_materials_material_product_id'), 'product_materials', ['material_product_id'], unique=False)
op.create_table('purchase_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_no', sa.String(length=50), nullable=False),
sa.Column('supplier_id', sa.Integer(), nullable=False),
sa.Column('order_date', sa.DateTime(), nullable=True),
sa.Column('expected_date', sa.Date(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('paid_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('received_date', sa.DateTime(), nullable=True),
sa.Column('paid_date', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchase_orders_id'), 'purchase_orders', ['id'], unique=False)
op.create_index(op.f('ix_purchase_orders_order_no'), 'purchase_orders', ['order_no'], unique=True)
op.create_index(op.f('ix_purchase_orders_supplier_id'), 'purchase_orders', ['supplier_id'], unique=False)
op.create_table('role_permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_role_permissions_id'), 'role_permissions', ['id'], unique=False)
op.create_index(op.f('ix_role_permissions_permission_id'), 'role_permissions', ['permission_id'], unique=False)
op.create_index(op.f('ix_role_permissions_role_id'), 'role_permissions', ['role_id'], unique=False)
op.create_table('sales_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_no', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.Integer(), nullable=False),
sa.Column('order_date', sa.DateTime(), nullable=True),
sa.Column('delivery_date', sa.Date(), nullable=True),
sa.Column('manufacturing_date', sa.DateTime(), nullable=True),
sa.Column('actual_delivery_date', sa.DateTime(), nullable=True),
sa.Column('actual_payment_date', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('production_status', sa.String(length=20), nullable=True),
sa.Column('production_no', sa.String(length=50), nullable=True),
sa.Column('planned_material_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('actual_material_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('received_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sales_orders_customer_id'), 'sales_orders', ['customer_id'], unique=False)
op.create_index(op.f('ix_sales_orders_id'), 'sales_orders', ['id'], unique=False)
op.create_index(op.f('ix_sales_orders_order_no'), 'sales_orders', ['order_no'], unique=True)
op.create_index(op.f('ix_sales_orders_production_no'), 'sales_orders', ['production_no'], unique=False)
op.create_index(op.f('ix_sales_orders_production_status'), 'sales_orders', ['production_status'], unique=False)
op.create_table('stock_movements',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('movement_type', sa.String(length=20), nullable=False),
sa.Column('quantity', sa.Numeric(precision=12, scale=4), nullable=False),
sa.Column('before_quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('after_quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('reference_type', sa.String(length=50), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('reference_no', sa.String(length=50), nullable=True),
sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_stock_movements_created_at'), 'stock_movements', ['created_at'], unique=False)
op.create_index(op.f('ix_stock_movements_id'), 'stock_movements', ['id'], unique=False)
op.create_index(op.f('ix_stock_movements_product_id'), 'stock_movements', ['product_id'], unique=False)
op.create_table('stp_files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('file_hash', sa.String(length=64), nullable=True),
sa.Column('mime_type', sa.String(length=50), nullable=True),
sa.Column('upload_batch', sa.String(length=36), nullable=True),
sa.Column('upload_time', sa.DateTime(), nullable=True),
sa.Column('processed_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('volume', sa.Float(), nullable=True),
sa.Column('surface_area', sa.Float(), nullable=True),
sa.Column('product_weight', sa.Float(), nullable=True),
sa.Column('file_path', sa.String(length=500), nullable=True),
sa.Column('file_content', sa.LargeBinary(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_stp_files_file_hash'), 'stp_files', ['file_hash'], unique=False)
op.create_index(op.f('ix_stp_files_id'), 'stp_files', ['id'], unique=False)
op.create_index(op.f('ix_stp_files_object_key'), 'stp_files', ['object_key'], unique=False)
op.create_index(op.f('ix_stp_files_original_filename'), 'stp_files', ['original_filename'], unique=False)
op.create_index(op.f('ix_stp_files_status'), 'stp_files', ['status'], unique=False)
op.create_index(op.f('ix_stp_files_upload_batch'), 'stp_files', ['upload_batch'], unique=False)
op.create_index(op.f('ix_stp_files_user_id'), 'stp_files', ['user_id'], unique=False)
op.create_table('system_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('level', sa.String(length=20), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('module', sa.String(length=100), nullable=True),
sa.Column('function_name', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('request_id', sa.String(length=100), nullable=True),
sa.Column('execution_time_ms', sa.Integer(), nullable=True),
sa.Column('resource_type', sa.String(length=50), nullable=True),
sa.Column('resource_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_system_logs_created_at'), 'system_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_system_logs_id'), 'system_logs', ['id'], unique=False)
op.create_index(op.f('ix_system_logs_level'), 'system_logs', ['level'], unique=False)
op.create_table('user_activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('activity_type', sa.String(length=50), nullable=False),
sa.Column('resource_type', sa.String(length=50), nullable=True),
sa.Column('resource_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('meta_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_activities_activity_type'), 'user_activities', ['activity_type'], unique=False)
op.create_index(op.f('ix_user_activities_created_at'), 'user_activities', ['created_at'], unique=False)
op.create_index(op.f('ix_user_activities_id'), 'user_activities', ['id'], unique=False)
op.create_index(op.f('ix_user_activities_user_id'), 'user_activities', ['user_id'], unique=False)
op.create_table('user_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_roles_id'), 'user_roles', ['id'], unique=False)
op.create_index(op.f('ix_user_roles_role_id'), 'user_roles', ['role_id'], unique=False)
op.create_index(op.f('ix_user_roles_user_id'), 'user_roles', ['user_id'], unique=False)
op.create_table('analysis_metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('volume_utilization', sa.Float(), nullable=True),
sa.Column('topology_complexity', sa.Float(), nullable=True),
sa.Column('wall_uniformity', sa.Float(), nullable=True),
sa.Column('analysis_summary', sa.Text(), nullable=True),
sa.Column('verification_status', sa.String(length=20), nullable=True),
sa.Column('verification_volume_diff', sa.Float(), nullable=True),
sa.Column('verification_area_diff', sa.Float(), nullable=True),
sa.Column('verification_details', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_analysis_metrics_id'), 'analysis_metrics', ['id'], unique=False)
op.create_index(op.f('ix_analysis_metrics_stp_file_id'), 'analysis_metrics', ['stp_file_id'], unique=False)
op.create_table('design_recommendations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('rec_type', sa.String(length=50), nullable=False),
sa.Column('priority', sa.String(length=20), nullable=False),
sa.Column('description', sa.String(length=500), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('user_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_design_recommendations_id'), 'design_recommendations', ['id'], unique=False)
op.create_index(op.f('ix_design_recommendations_stp_file_id'), 'design_recommendations', ['stp_file_id'], unique=False)
op.create_table('finance_allocations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=False),
sa.Column('order_type', sa.String(length=20), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('allocated_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['transaction_id'], ['finance_transactions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_finance_allocations_created_at'), 'finance_allocations', ['created_at'], unique=False)
op.create_index(op.f('ix_finance_allocations_id'), 'finance_allocations', ['id'], unique=False)
op.create_index(op.f('ix_finance_allocations_order_id'), 'finance_allocations', ['order_id'], unique=False)
op.create_index(op.f('ix_finance_allocations_order_type'), 'finance_allocations', ['order_type'], unique=False)
op.create_index(op.f('ix_finance_allocations_transaction_id'), 'finance_allocations', ['transaction_id'], unique=False)
op.create_table('geometry_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('analysis_method', sa.String(length=50), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('volume', sa.Float(), nullable=True),
sa.Column('surface_area', sa.Float(), nullable=True),
sa.Column('bounding_box_min', sa.JSON(), nullable=True),
sa.Column('bounding_box_max', sa.JSON(), nullable=True),
sa.Column('center_of_mass', sa.JSON(), nullable=True),
sa.Column('topology_faces', sa.Integer(), nullable=True),
sa.Column('topology_edges', sa.Integer(), nullable=True),
sa.Column('topology_vertices', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_geometry_data_id'), 'geometry_data', ['id'], unique=False)
op.create_index(op.f('ix_geometry_data_stp_file_id'), 'geometry_data', ['stp_file_id'], unique=False)
op.create_table('html_files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('generated_time', sa.DateTime(), nullable=True),
sa.Column('visualization_type', sa.String(length=50), nullable=True),
sa.Column('has_interactive_elements', sa.Boolean(), nullable=True),
sa.Column('file_path', sa.String(length=500), nullable=True),
sa.Column('html_content', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_html_files_id'), 'html_files', ['id'], unique=False)
op.create_index(op.f('ix_html_files_stp_file_id'), 'html_files', ['stp_file_id'], unique=False)
op.create_table('mesh_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('quality', sa.String(length=20), nullable=True),
sa.Column('vertex_count', sa.Integer(), nullable=True),
sa.Column('face_count', sa.Integer(), nullable=True),
sa.Column('point_count', sa.Integer(), nullable=True),
sa.Column('bounding_box_min', sa.JSON(), nullable=True),
sa.Column('bounding_box_max', sa.JSON(), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mesh_data_id'), 'mesh_data', ['id'], unique=False)
op.create_index(op.f('ix_mesh_data_stp_file_id'), 'mesh_data', ['stp_file_id'], unique=False)
op.create_table('mold_cavity_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('detailed_object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('mold_material', sa.String(length=100), nullable=True),
sa.Column('mold_type', sa.String(length=50), nullable=True),
sa.Column('shrinkage_rate', sa.Float(), nullable=False),
sa.Column('draft_angle', sa.Float(), nullable=False),
sa.Column('parting_line_length', sa.Float(), nullable=True),
sa.Column('generated_time', sa.DateTime(), nullable=True),
sa.Column('cavity_key_info', sa.JSON(), nullable=True),
sa.Column('mold_size_length', sa.Float(), nullable=True),
sa.Column('mold_size_width', sa.Float(), nullable=True),
sa.Column('mold_size_height', sa.Float(), nullable=True),
sa.Column('estimated_clamping_force', sa.String(length=50), nullable=True),
sa.Column('product_weight', sa.String(length=50), nullable=True),
sa.Column('product_volume', sa.Float(), nullable=True),
sa.Column('wall_thickness_range', sa.String(length=50), nullable=True),
sa.Column('complexity_score', sa.Float(), nullable=True),
sa.Column('weld_line_risk', sa.String(length=50), nullable=True),
sa.Column('sink_mark_risk', sa.String(length=50), nullable=True),
sa.Column('warpage_risk', sa.String(length=50), nullable=True),
sa.Column('best_scheme_id', sa.String(length=64), nullable=True),
sa.Column('confidence_score', sa.Float(), nullable=True),
sa.Column('is_fallback', sa.Boolean(), nullable=True),
sa.Column('fallback_reason', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mold_cavity_data_best_scheme_id'), 'mold_cavity_data', ['best_scheme_id'], unique=False)
op.create_index(op.f('ix_mold_cavity_data_id'), 'mold_cavity_data', ['id'], unique=False)
op.create_index(op.f('ix_mold_cavity_data_is_fallback'), 'mold_cavity_data', ['is_fallback'], unique=False)
op.create_index(op.f('ix_mold_cavity_data_stp_file_id'), 'mold_cavity_data', ['stp_file_id'], unique=False)
op.create_table('processing_tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('task_id', sa.String(length=36), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('task_type', sa.String(length=50), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('started_time', sa.DateTime(), nullable=True),
sa.Column('completed_time', sa.DateTime(), nullable=True),
sa.Column('progress', sa.Integer(), nullable=True),
sa.Column('current_step', sa.String(length=100), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('error_stack', sa.Text(), nullable=True),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_processing_tasks_id'), 'processing_tasks', ['id'], unique=False)
op.create_index(op.f('ix_processing_tasks_stp_file_id'), 'processing_tasks', ['stp_file_id'], unique=False)
op.create_index(op.f('ix_processing_tasks_task_id'), 'processing_tasks', ['task_id'], unique=True)
op.create_table('purchase_order_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('received_quantity', sa.Integer(), nullable=True),
sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('remark', sa.Text(), nullable=True),
sa.CheckConstraint('quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity', name='ck_purchase_order_items_qty'),
sa.ForeignKeyConstraint(['order_id'], ['purchase_orders.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchase_order_items_id'), 'purchase_order_items', ['id'], unique=False)
op.create_table('sales_order_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('delivered_quantity', sa.Integer(), nullable=True),
sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('remark', sa.Text(), nullable=True),
sa.CheckConstraint('quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity', name='ck_sales_order_items_qty'),
sa.ForeignKeyConstraint(['order_id'], ['sales_orders.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sales_order_items_id'), 'sales_order_items', ['id'], unique=False)
op.create_table('feature_detections',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('feature_type', sa.String(length=50), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('location', sa.JSON(), nullable=True),
sa.Column('dimensions', sa.JSON(), nullable=True),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('detected_at', sa.DateTime(), nullable=True),
sa.Column('geometry_data_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['geometry_data_id'], ['geometry_data.id'], ),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_feature_detections_feature_type'), 'feature_detections', ['feature_type'], unique=False)
op.create_index(op.f('ix_feature_detections_id'), 'feature_detections', ['id'], unique=False)
op.create_index(op.f('ix_feature_detections_stp_file_id'), 'feature_detections', ['stp_file_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_feature_detections_stp_file_id'), table_name='feature_detections')
op.drop_index(op.f('ix_feature_detections_id'), table_name='feature_detections')
op.drop_index(op.f('ix_feature_detections_feature_type'), table_name='feature_detections')
op.drop_table('feature_detections')
op.drop_index(op.f('ix_sales_order_items_id'), table_name='sales_order_items')
op.drop_table('sales_order_items')
op.drop_index(op.f('ix_purchase_order_items_id'), table_name='purchase_order_items')
op.drop_table('purchase_order_items')
op.drop_index(op.f('ix_processing_tasks_task_id'), table_name='processing_tasks')
op.drop_index(op.f('ix_processing_tasks_stp_file_id'), table_name='processing_tasks')
op.drop_index(op.f('ix_processing_tasks_id'), table_name='processing_tasks')
op.drop_table('processing_tasks')
op.drop_index(op.f('ix_mold_cavity_data_stp_file_id'), table_name='mold_cavity_data')
op.drop_index(op.f('ix_mold_cavity_data_is_fallback'), table_name='mold_cavity_data')
op.drop_index(op.f('ix_mold_cavity_data_id'), table_name='mold_cavity_data')
op.drop_index(op.f('ix_mold_cavity_data_best_scheme_id'), table_name='mold_cavity_data')
op.drop_table('mold_cavity_data')
op.drop_index(op.f('ix_mesh_data_stp_file_id'), table_name='mesh_data')
op.drop_index(op.f('ix_mesh_data_id'), table_name='mesh_data')
op.drop_table('mesh_data')
op.drop_index(op.f('ix_html_files_stp_file_id'), table_name='html_files')
op.drop_index(op.f('ix_html_files_id'), table_name='html_files')
op.drop_table('html_files')
op.drop_index(op.f('ix_geometry_data_stp_file_id'), table_name='geometry_data')
op.drop_index(op.f('ix_geometry_data_id'), table_name='geometry_data')
op.drop_table('geometry_data')
op.drop_index(op.f('ix_finance_allocations_transaction_id'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_order_type'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_order_id'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_id'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_created_at'), table_name='finance_allocations')
op.drop_table('finance_allocations')
op.drop_index(op.f('ix_design_recommendations_stp_file_id'), table_name='design_recommendations')
op.drop_index(op.f('ix_design_recommendations_id'), table_name='design_recommendations')
op.drop_table('design_recommendations')
op.drop_index(op.f('ix_analysis_metrics_stp_file_id'), table_name='analysis_metrics')
op.drop_index(op.f('ix_analysis_metrics_id'), table_name='analysis_metrics')
op.drop_table('analysis_metrics')
op.drop_index(op.f('ix_user_roles_user_id'), table_name='user_roles')
op.drop_index(op.f('ix_user_roles_role_id'), table_name='user_roles')
op.drop_index(op.f('ix_user_roles_id'), table_name='user_roles')
op.drop_table('user_roles')
op.drop_index(op.f('ix_user_activities_user_id'), table_name='user_activities')
op.drop_index(op.f('ix_user_activities_id'), table_name='user_activities')
op.drop_index(op.f('ix_user_activities_created_at'), table_name='user_activities')
op.drop_index(op.f('ix_user_activities_activity_type'), table_name='user_activities')
op.drop_table('user_activities')
op.drop_index(op.f('ix_system_logs_level'), table_name='system_logs')
op.drop_index(op.f('ix_system_logs_id'), table_name='system_logs')
op.drop_index(op.f('ix_system_logs_created_at'), table_name='system_logs')
op.drop_table('system_logs')
op.drop_index(op.f('ix_stp_files_user_id'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_upload_batch'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_status'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_original_filename'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_object_key'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_id'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_file_hash'), table_name='stp_files')
op.drop_table('stp_files')
op.drop_index(op.f('ix_stock_movements_product_id'), table_name='stock_movements')
op.drop_index(op.f('ix_stock_movements_id'), table_name='stock_movements')
op.drop_index(op.f('ix_stock_movements_created_at'), table_name='stock_movements')
op.drop_table('stock_movements')
op.drop_index(op.f('ix_sales_orders_production_status'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_production_no'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_order_no'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_id'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_customer_id'), table_name='sales_orders')
op.drop_table('sales_orders')
op.drop_index(op.f('ix_role_permissions_role_id'), table_name='role_permissions')
op.drop_index(op.f('ix_role_permissions_permission_id'), table_name='role_permissions')
op.drop_index(op.f('ix_role_permissions_id'), table_name='role_permissions')
op.drop_table('role_permissions')
op.drop_index(op.f('ix_purchase_orders_supplier_id'), table_name='purchase_orders')
op.drop_index(op.f('ix_purchase_orders_order_no'), table_name='purchase_orders')
op.drop_index(op.f('ix_purchase_orders_id'), table_name='purchase_orders')
op.drop_table('purchase_orders')
op.drop_index(op.f('ix_product_materials_material_product_id'), table_name='product_materials')
op.drop_index(op.f('ix_product_materials_id'), table_name='product_materials')
op.drop_index(op.f('ix_product_materials_finished_product_id'), table_name='product_materials')
op.drop_table('product_materials')
op.drop_index(op.f('ix_material_suppliers_supplier_id'), table_name='material_suppliers')
op.drop_index(op.f('ix_material_suppliers_product_id'), table_name='material_suppliers')
op.drop_index(op.f('ix_material_suppliers_id'), table_name='material_suppliers')
op.drop_table('material_suppliers')
op.drop_index(op.f('ix_material_price_history_supplier_id'), table_name='material_price_history')
op.drop_index(op.f('ix_material_price_history_product_id'), table_name='material_price_history')
op.drop_index(op.f('ix_material_price_history_id'), table_name='material_price_history')
op.drop_index(op.f('ix_material_price_history_effective_date'), table_name='material_price_history')
op.drop_table('material_price_history')
op.drop_index(op.f('ix_inventory_warehouse_id'), table_name='inventory')
op.drop_index(op.f('ix_inventory_product_id'), table_name='inventory')
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
op.drop_table('inventory')
op.drop_index(op.f('ix_finance_transactions_txn_type'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_txn_no'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_txn_date'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_status'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_partner_type'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_partner_id'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_id'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_created_at'), table_name='finance_transactions')
op.drop_table('finance_transactions')
op.drop_index(op.f('ix_warehouses_id'), table_name='warehouses')
op.drop_index(op.f('ix_warehouses_code'), table_name='warehouses')
op.drop_table('warehouses')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_suppliers_id'), table_name='suppliers')
op.drop_index(op.f('ix_suppliers_code'), table_name='suppliers')
op.drop_table('suppliers')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.drop_index(op.f('ix_roles_code'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_products_sku'), table_name='products')
op.drop_index(op.f('ix_products_item_type'), table_name='products')
op.drop_index(op.f('ix_products_id'), table_name='products')
op.drop_table('products')
op.drop_index(op.f('ix_permissions_id'), table_name='permissions')
op.drop_index(op.f('ix_permissions_code'), table_name='permissions')
op.drop_table('permissions')
op.drop_index(op.f('ix_customers_id'), table_name='customers')
op.drop_index(op.f('ix_customers_code'), table_name='customers')
op.drop_table('customers')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""add batch_id to processing_tasks
批次 2(D7):批量元数据入库——processing_tasks 增加 batch_id 列,
批量任务聚合查询走 PG,替代 Redis/进程内存中的批量元数据。
Revision ID: a3f8c2d91e47
Revises: 006c18c51b0d
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3f8c2d91e47'
down_revision: Union[str, Sequence[str], None] = '006c18c51b0d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'processing_tasks',
sa.Column('batch_id', sa.String(length=36), nullable=True),
)
op.create_index(
'ix_processing_tasks_batch_id',
'processing_tasks',
['batch_id'],
)
def downgrade() -> None:
op.drop_index('ix_processing_tasks_batch_id', table_name='processing_tasks')
op.drop_column('processing_tasks', 'batch_id')
@@ -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')
File diff suppressed because it is too large Load Diff
+9625
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
asyncio_mode = auto

Some files were not shown because too many files have changed in this diff Show More