Compare commits

..

95 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
cjw 8493f0df03 init 2026-07-13 17:44:50 +08:00
cjw 975fa1ffac init 2026-07-13 17:44:37 +08:00
cjw 6b3188e693 x 2026-06-23 09:38:31 +08:00
cjw f6d8829071 x 2026-06-16 11:15:03 +08:00
cjw f71a48d3b8 x 2026-06-12 13:55:38 +08:00
cjw c4616cbbee x 2026-06-11 15:08:42 +08:00
cjw f4457a5372 x 2026-06-10 13:44:05 +08:00
cjw c39c0b547f x 2026-06-10 11:21:17 +08:00
cjw fdbde938f9 x 2026-06-09 16:19:15 +08:00
cjw 4151e4963b x 2026-06-09 15:44:17 +08:00
cjw dadde0ed5c x 2026-06-09 14:17:21 +08:00
cjw fa2178fbd8 x 2026-06-09 13:44:53 +08:00
cjw 5d73a7d036 x 2026-06-09 11:26:42 +08:00
cjw 7d0fa506b6 x 2026-06-09 11:14:03 +08:00
cjw c6a8bc39da x 2026-06-04 17:51:11 +08:00
cjw 65033666ef 优化前端,使用组件Element 2026-06-02 15:56:04 +08:00
cjw cafa3ef883 xx 2026-06-02 09:55:28 +08:00
cjw 8cca6f9d32 x 2026-06-02 09:55:06 +08:00
cjw ecc6b8ab8a 修复铝价格走势 2026-06-01 14:52:47 +08:00
cjw c6f1901703 x 2026-06-01 14:10:14 +08:00
cjw 1691fa924d x 2026-06-01 13:51:16 +08:00
cjw 39ff62c47e x 2026-06-01 13:40:53 +08:00
cjw b22431d3cb x 2026-06-01 10:44:08 +08:00
cjw a91b6ab21f 修正为python3.12镜像 2026-06-01 10:11:25 +08:00
cjw 77bdf56adf 重写独立dockerfile 2026-05-29 18:19:30 +08:00
cjw 823a387118 后端模块拆分 2026-05-29 18:10:08 +08:00
cjw 5bb9bc84ea 前端重构 Vite 2026-05-29 17:30:26 +08:00
cjw cbd5d98993 x 2026-05-29 10:11:28 +08:00
cjw 7d35728373 x 2026-05-29 10:00:08 +08:00
cjw 3443763fbf x 2026-05-29 09:47:21 +08:00
cjw 44902207c9 增加celery任务队列,任务持久化(服务重启不丢)、支持多 Worker 水平扩展、失败自动重试 2026-05-28 17:59:39 +08:00
cjw 6ad22e4466 优化整体任务性能 2026-05-28 17:41:02 +08:00
cjw e03490279e x 2026-05-27 17:49:48 +08:00
cjw 843af3b8d1 x 2026-05-27 17:08:56 +08:00
cjw 8da6b0b832 x 2026-05-27 16:35:53 +08:00
cjw 2ffe8ecbb3 x 2026-05-27 16:15:01 +08:00
cjw 06cee89e19 x 2026-05-27 15:47:36 +08:00
cjw a73d530f07 x 2026-05-27 15:27:35 +08:00
322 changed files with 55053 additions and 21813 deletions
+68
View File
@@ -0,0 +1,68 @@
# ============================================
# .dockerignore — 排除无关文件,加速构建
# ============================================
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
.eggs/
dist/
build/
# Virtual environments
.env
venv/
env/
.conda/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git/
.gitignore
.gitattributes
# 前端(仅在需要构建时包含)
frontend/node_modules/
frontend/dist/
# 测试
tests/
.pytest_cache/
.coverage
htmlcov/
# 文档
docs/
*.md
README*
# 临时文件
*.log
logs/
temp/
tmp/
# Docker
Dockerfile*
docker-compose*.yml
# 注意:deploy/ 不能排除——Dockerfile.moldinsight COPY deploy/requirements-*.txt、
# Dockerfile.frontend COPY deploy/nginx/frontend.conf,排除会让干净机器首次构建必挂
# 其他
.trae/
scripts/
pip_audit_local.json
temp_requirements_audit.txt
local_src.txt
git_src.txt
start.sh
CHANGELOG.md
LICENSE
+22 -14
View File
@@ -3,11 +3,18 @@ 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 PORT=10003
# Docker映射到宿主机的端口(docker-compose使用)
HOST_PORT=10001
# ================================ # ================================
@@ -24,34 +31,35 @@ POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high MESH_QUALITY=high
PARALLEL_PROCESSING=true PARALLEL_PROCESSING=true
# 数据库配置 # 数据库配置——以部署机实测可达 IP 为准(之前 192.168.3.10 → 192.168.0.11),
DB_HOST=szcjw # 改 IP 后必须重启 backend 才生效
DB_HOST=192.168.0.11
DB_PORT=5432 DB_PORT=5432
DB_NAME=moldinsight DB_NAME=moldinsight
DB_USER=moldinsight DB_USER=moldinsight
DB_PASSWORD=Qqs1996* DB_PASSWORD=Qqs1996
# RustFS 对象存储配置 (S3v4 API) # RustFS 对象存储配置 (S3v4 API)
RUSTFS_ENDPOINT=http://szcjw:8010 RUSTFS_ENDPOINT=http://192.168.0.11:8010
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
RUSTFS_TIMEOUT=30 RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600 RUSTFS_PRESIGNED_URL_EXPIRES=3600
# JWT认证配置 # JWT认证配置
SECRET_KEY=your-secret-key-change-in-production-min-32-chars SECRET_KEY=vGLxbDGj4I3LnWZYQqRrchlVBSWpM73IZ8fT7ldwpsXpDYB82ghkr7sRkO7D-BiR
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Redis配置 # Redis配置(与 DB 同 IP)
REDIS_HOST=szcjw REDIS_HOST=192.168.0.11
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=Qqs1996* REDIS_PASSWORD=Qqs1996
REDIS_DB=0 REDIS_DB=0
# 管理员配置 # 管理员配置
ADMIN_USERNAME=cjw ADMIN_USERNAME=cjw
ADMIN_PASSWORD=Qqs1996* ADMIN_PASSWORD=Qqs1996
ADMIN_EMAIL=792430652@qq.com ADMIN_EMAIL=792430652@qq.com
ADMIN_FULL_NAME=管理员 ADMIN_FULL_NAME=管理员
@@ -60,7 +68,7 @@ ADMIN_FULL_NAME=管理员
# LLM 增强分析配置(可选) # LLM 增强分析配置(可选)
# 启用后自动生成模具设计评审报告和分模方向推荐 # 启用后自动生成模具设计评审报告和分模方向推荐
# 支持 OpenAI 兼容 API(OpenAI / DeepSeek / vLLM / Ollama 等) # 支持 OpenAI 兼容 API(OpenAI / DeepSeek / vLLM / Ollama 等)
LLM_ENABLED=true LLM_ENABLED=false
LLM_API_URL=https://api.deepseek.com/v1 LLM_API_URL=https://api.deepseek.com/v1
LLM_API_KEY=sk-509f968af3e2466bbef8b5949180782c LLM_API_KEY=sk-509f968af3e2466bbef8b5949180782c
LLM_MODEL=deepseek-v4-flash LLM_MODEL=deepseek-v4-flash
+37 -10
View File
@@ -1,17 +1,25 @@
# 服务配置
HOST=0.0.0.0
# ================================ # ================================
# 端口配置 - 唯一修改端口的地方 # 端口配置 — DEPLOYMENT.md §1.2 选择 A 为默认(前端独占宿主端口,backend 不暴露)
# ================================ # ================================
# 应用端口(容器内端口) # 容器内部端口统一 8000(frontend=8000 / backend=8000 / inventory=8001);
PORT=8000 # 服务间通过 docker 网络 gemold_network 上的服务名(如 backend:8000)互通。
# Docker映射到宿主机的端口(docker-compose使用) # 宿主机端口由本文件强制配置,compose 无默认值兜底:
HOST_PORT=10001 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 DEBUG=false
# 日志配置
# LOG_FORMAT: json(生产默认,结构化输出)/ text(开发默认,人可读)
# LOG_LEVEL: DEBUG / INFO / WARNING / ERROR
LOG_FORMAT=json
LOG_LEVEL=INFO
# 文件处理配置 # 文件处理配置
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600 MAX_FILE_SIZE=104857600
@@ -22,25 +30,39 @@ POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high MESH_QUALITY=high
PARALLEL_PROCESSING=true PARALLEL_PROCESSING=true
# 数据库配置 # 数据库配置(服务器已部署,请填写真实地址)
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
# 是否在应用启动时自动执行 alembic 迁移(默认 true,保持单机开发体验)。
# 多副本/容器编排部署建议设为 false:多个实例同时启动会并发迁移,
# 改由部署流程单点执行 alembic CLI 或 python -m shared.database.init_db
AUTO_MIGRATE=true
DB_NAME=moldinsight DB_NAME=moldinsight
DB_USER=moldinsight_user DB_USER=moldinsight_user
DB_PASSWORD=moldinsight_password DB_PASSWORD=moldinsight_password
# RustFS 对象存储配置 (S3v4 API) # RustFS / MinIO 兼容对象存储配置(服务器已部署,请填写真实地址)
RUSTFS_ENDPOINT=http://localhost:9000 RUSTFS_ENDPOINT=http://localhost:9000
RUSTFS_ACCESS_KEY=your-access-key RUSTFS_ACCESS_KEY=your-access-key
RUSTFS_SECRET_KEY=your-secret-key RUSTFS_SECRET_KEY=your-secret-key
RUSTFS_TIMEOUT=30 RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600 RUSTFS_PRESIGNED_URL_EXPIRES=3600
# Redis 配置(服务器已部署,请填写真实地址)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT认证配置 # JWT认证配置
SECRET_KEY=your-secret-key-change-in-production-min-32-chars SECRET_KEY=your-secret-key-change-in-production-min-32-chars
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 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_USERNAME=admin
ADMIN_PASSWORD=change-this-to-a-secure-password 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_MODEL=gpt-4o-mini
LLM_TIMEOUT=60 LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000 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 .DS_Store
*.log *.log
.env
1panel.env 1panel.env
*.env.local *.env.local
logs/ logs/
@@ -24,6 +23,9 @@ logs/
uploads/ uploads/
html_output/ html_output/
# 前端构建产物(由 deploy.sh 在部署时 npm run build 生成,不进 git)
static/
# 不再需要在 src/ 下跟踪这些 # 不再需要在 src/ 下跟踪这些
src/uploads/ src/uploads/
src/html_output/ 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"> <div align="center">
@@ -7,319 +7,199 @@
![FastAPI](https://img.shields.io/badge/fastapi-0.100.0-green) ![FastAPI](https://img.shields.io/badge/fastapi-0.100.0-green)
![Vue.js](https://img.shields.io/badge/vue.js-3-green) ![Vue.js](https://img.shields.io/badge/vue.js-3-green)
![PostgreSQL](https://img.shields.io/badge/postgresql-15-blue) ![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> </div>
## 项目简介 geMoldInsight 是一个面向模具制造场景的综合系统,围绕 **STEP/STP 模型分析、模具方案生成、分析结果沉淀、成品创建、BOM/库存/采购/销售闭环** 展开。
**Gemold** 是一个面向模具制造行业的综合性管理系统,集成了STP文件分析、模具设计建议、用户权限管理和进销存功能。 系统采用现代化的技术栈,提供高效、稳定、易扩展的解决方案。 > **当前实现状态**:见 [docs/STATUS.md](docs/STATUS.md)
> **当前架构与边界**:见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
> **本文是唯一文档导航入口**:请按角色或主题跳转到对应主文档
### 核心功能 ---
| 功能模块 | 描述 | ## 项目概览
|---------|------|
| 🔷 STP文件分析 | 使用PythonOCC解析STP文件,提取几何特征 | 当前项目已经从早期单体演进为:
| 📊 模具设计建议 | 自动生成型腔、型芯、工艺参数等设计方案 |
| 🎨 3D可视化 | 实时预览产品模型和模具结构 | - **moldinsight 模块**:模具分析、几何处理、批量分析、成本估算、结果导出
| 👥 用户权限管理 | 完整的角色权限控制系统 | - **inventory 模块**:产品、BOM、库存、采购、销售、财务
| 📦 进销存管理 | 库存、供应商、客户管理 | - **frontend 模块**:Vue 3 前端工程
| 💾 数据持久化 | PostgreSQL + RustFS存储 | - **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框架 - FastAPI
- **PythonOCC** - 专业CAD几何处理 - SQLAlchemy 2.0
- **SQLAlchemy** - ORM框架 - PostgreSQL
- **PostgreSQL** - 关系型数据库 - Alembic
- **RustFS** - 高性能文件存储 - Redis
- Celery
- PythonOCC / trimesh / pyvista
- RustFS / MinIO 兼容对象存储
### 前端 ### 前端
- **Vue.js 3** - 渐进式JavaScript框架 - Vue 3
- **Three.js** - 3D可视化库 - Vite
- **原生CSS** - 白色简约现代风格 - TypeScript
- Pinia
- Vue Router
- TDesign Vue Next
### 基础设施 ### 基础设施
- **Docker** - 容器化部署 - Docker / Docker Compose
- **Systemd** - 服务管理 - 结构化日志 / request_id
- **Conda** - 环境管理 - OpenAPI → TypeScript 类型生成
--- ---
## 项目结构 ## 当前代码入口
``` - unified: [src/entrypoints/unified.py](src/entrypoints/unified.py)
geMoldInsight/ - moldinsight-only: [src/entrypoints/moldinsight.py](src/entrypoints/moldinsight.py)
├── src/ # 源代码目录 - inventory-only: [src/entrypoints/inventory.py](src/entrypoints/inventory.py)
│ ├── main.py # 主程序入口
│ ├── api/ # API路由层 当前 Compose 入口(一键命令对应文件名):
│ │ ├── routes.py # 模具分析API路由聚合
│ │ ├── auth_routes.py # 认证API - unified: [docker-compose.yml](docker-compose.yml) → `docker compose up -d`
│ │ ├── v1/ # MoldInsight API (v1) - moldinsight-only: [docker-compose.moldinsight.yml](docker-compose.moldinsight.yml) → `docker compose -f docker-compose.moldinsight.yml up -d`
│ │ │ ├── upload_router.py # 文件上传 - inventory-only: [docker-compose.inventory.yml](docker-compose.inventory.yml) → `docker compose -f docker-compose.inventory.yml up -d`
│ │ │ ├── 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)
```
--- ---
## 快速开始 ## 开发建议
### 1. 环境要求 - 新增业务逻辑优先放入对应业务模块,不要继续堆进 `shared`
- Python 3.10+ - 新增 API 时优先考虑模块归属,而不是“能放就放”
- PostgreSQL 13+ - 文档状态统一维护在 [docs/STATUS.md](docs/STATUS.md)
- Conda (推荐) - 部署方式变化统一更新 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
### 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: 测试相关
--- ---
## 许可证 ## 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件 本项目采用 MIT 许可证,详见 [LICENSE](LICENSE)。
---
## 贡献者
感谢所有为这个项目做出贡献的开发者。
---
## 联系方式
- 项目地址: [GitHub](https://github.com/your-org/gemold)
- 问题反馈: [Issues](https://github.com/your-org/gemold/issues)
+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
+63
View File
@@ -0,0 +1,63 @@
# ============================================
# 环境变量模板 — 复用服务器已有基础设施
# ============================================
# 复制为 .env 并按服务器实际服务地址修改
# 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
DB_PASSWORD=change-me
# Redis(服务器已部署)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# 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
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# 管理员
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-to-a-secure-password
ADMIN_EMAIL=admin@gemold.com
ADMIN_FULL_NAME=系统管理员
# FreeCAD 验证(可选)
ENABLE_FREECAD_VERIFICATION=false
FREECAD_VERIFICATION_TIMEOUT=120
# 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
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY deploy/requirements-base.txt .
RUN pip install --no-cache-dir -r requirements-base.txt && \
rm 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
+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
+12
View File
@@ -0,0 +1,12 @@
FROM gemold-base:latest
COPY src/inventory/ /app/src/inventory/
COPY src/entrypoints/ /app/src/entrypoints/
RUN mkdir -p /app/logs
EXPOSE 8001
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=30s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')" || exit 1
CMD ["python", "-m", "uvicorn", "entrypoints.inventory:app", "--host", "0.0.0.0", "--port", "8001"]
+51
View File
@@ -0,0 +1,51 @@
# 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
# 锁定几何栈核心版本;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
ENV PATH=/opt/conda/envs/moldinsight/bin:$PATH \
PYTHONUNBUFFERED=1
WORKDIR /app
# 自包含构建:不再基于 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/
ENV PYTHONPATH=/app/src
RUN mkdir -p /app/logs
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["python", "-m", "uvicorn", "entrypoints.moldinsight:app", "--host", "0.0.0.0", "--port", "8000"]
+26
View File
@@ -0,0 +1,26 @@
@echo off
REM ============================================
REM 构建脚本 — 独立部署 (Windows)
REM ============================================
cd /d "%~dp0\.."
echo === 构建基础镜像 ===
docker build -t gemold-base:latest -f deploy\Dockerfile.base .
echo.
echo === 构建统一后端镜像 ===
docker build -t gemold-backend:latest -f deploy\Dockerfile.moldinsight .
echo.
echo === 构建前端镜像 (Nginx 静态站点) ===
docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
echo.
echo === 全部构建完成 ===
echo.
echo 启动 unified 默认栈(前端 + backend + celery):
echo docker compose up -d
echo.
echo 按文件名切换模式(旧 --profile 写法已失效):
echo docker compose -f docker-compose.moldinsight.yml up -d
echo docker compose -f docker-compose.inventory.yml up -d
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# ============================================
# 构建脚本 — 独立部署
# ============================================
set -e
cd "$(dirname "$0")/.."
echo "=== 构建基础镜像 ==="
docker build -t gemold-base:latest -f deploy/Dockerfile.base .
echo ""
echo "=== 构建统一后端镜像 ==="
docker build -t gemold-backend:latest -f deploy/Dockerfile.moldinsight .
echo ""
echo ""
echo "=== 构建前端镜像 (Nginx 静态站点) ==="
docker build -t gemold-frontend:latest -f deploy/Dockerfile.frontend .
echo ""
echo "=== 全部构建完成 ==="
echo ""
echo "启动 unified 默认栈(前端 + backend + celery):"
echo " docker compose up -d"
echo ""
echo "按文件名切换模式(旧 --profile 写法已失效):"
echo " docker compose -f docker-compose.moldinsight.yml up -d"
echo " docker compose -f docker-compose.inventory.yml up -d"
+38
View File
@@ -0,0 +1,38 @@
@echo off
REM ============================================================
REM geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13,Windows 版)
REM ============================================================
REM
REM 用法:在 gemold conda 环境内执行 deploy\generate_lockfiles.bat
REM
REM 产物:
REM deploy\requirements-base.lock.txt
REM deploy\requirements-moldinsight.lock.txt
REM ============================================================
setlocal enabledelayedexpansion
cd /d "%~dp0\.."
where conda >nul 2>&1
if errorlevel 1 (
echo [ERROR] conda 未安装或不在 PATH,请先激活 conda 环境 ^(推荐 moldinsight^) 1>&2
exit /b 1
)
if not "%CONDA_DEFAULT_ENV%"=="moldinsight" if not "%CONDA_DEFAULT_ENV%"=="gemold" (
echo [WARN] 当前 conda 环境为 '%CONDA_DEFAULT_ENV%',推荐在 'moldinsight' 内执行
)
echo ==^> 生成 base 锁文件
pip freeze --exclude pythonocc-core > deploy\requirements-base.lock.txt
echo ==^> 生成 moldinsight 锁文件
pip freeze --exclude pythonocc-core > deploy\requirements-moldinsight.lock.txt
echo.
echo 已生成:
echo deploy\requirements-base.lock.txt
echo deploy\requirements-moldinsight.lock.txt
echo.
echo 下一步:提交两个 lock.txt,并按团队策略同步更新 requirements-*.txt 下限。
endlocal
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# ============================================================
# geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13)
# ============================================================
#
# 设计要点:
# - 锁文件必须在构建产出的 conda/minimal 环境里生成(只有 geMoldInsight 依赖 + 基础库),
# 本机开发环境的 `pip freeze` 会污染(全开发栈混装),不能直接落锁。
# - 仅在带 moldinsight 的 conda 环境内运行 `pip freeze > deploy/requirements-<x>.lock.txt`
# 才有意义。
# - 锁文件落盘后即可被 CI / 离线构建 / 复现部署直接 `pip install -r` 锁定版本,
# 而不再依赖 >= 下限解析。
#
# 用法(必须在 `gemold` conda 环境内执行):
# bash deploy/generate_lockfiles.sh
#
# 产物:
# deploy/requirements-base.lock.txt
# deploy/requirements-moldinsight.lock.txt
# ============================================================
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v conda >/dev/null 2>&1; then
echo "[ERROR] conda 未安装或不在 PATH,请先激活 conda 环境(推荐环境名 moldinsight)" >&2
exit 1
fi
if [[ "${CONDA_DEFAULT_ENV:-}" != "moldinsight" && "${CONDA_DEFAULT_ENV:-}" != "gemold" ]]; then
echo "[WARN] 当前 conda 环境为 '${CONDA_DEFAULT_ENV:-<未激活>}'," \
"推荐在 'moldinsight' conda 环境内运行(否则锁文件将含宿主污染)" >&2
fi
echo "==> 生成 base 锁文件"
pip freeze --exclude pythonocc-core > deploy/requirements-base.lock.txt
echo "==> 生成 moldinsight 锁文件"
pip freeze --exclude pythonocc-core > deploy/requirements-moldinsight.lock.txt
echo
echo "已生成:"
echo " deploy/requirements-base.lock.txt ($(wc -l < deploy/requirements-base.lock.txt) 行)"
echo " deploy/requirements-moldinsight.lock.txt ($(wc -l < deploy/requirements-moldinsight.lock.txt) 行)"
echo
echo "下一步:"
echo " 1. 提交这两个 lock.txt(仅含项目直接依赖 + conda-minimal 环境产出)"
echo " 2. 同步更新 deploy/requirements-base.txt / requirements-moldinsight.txt 的版本下限" \
"为 lock 中的实际版本(或保留 >=,按团队策略)"
+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;
}
}
+30
View File
@@ -0,0 +1,30 @@
-i https://mirrors.aliyun.com/pypi/simple/
--trusted-host mirrors.aliyun.com
fastapi>=0.100.0
uvicorn[standard]>=0.22.0
pydantic>=2.0.0
python-multipart>=0.0.6
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.0
asyncpg>=0.28.0
alembic>=1.11.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
passlib>=1.7.4
email-validator>=2.0.0
aiofiles>=23.0.0
orjson>=3.9.0
python-dotenv>=1.0.0
jinja2>=3.1.0
Pillow>=12.2.0
pyyaml>=6.0
python-dateutil>=2.8.0
loguru>=0.7.0
httpx>=0.24.0
redis>=4.5.0
+19
View File
@@ -0,0 +1,19 @@
-i https://mirrors.aliyun.com/pypi/simple/
--trusted-host mirrors.aliyun.com
trimesh>=3.21.0
numpy>=1.24.0
scipy>=1.10.0
pyvista>=0.38.0
minio>=7.1.0
aiohttp>=3.13.4
celery[redis]>=5.3.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
aiosqlite>=0.19.0
black>=23.0.0
flake8>=6.0.0
mypy>=1.0.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
+137 -84
View File
@@ -1,91 +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: services:
postgres: frontend:
image: postgres:15 build:
container_name: moldinsight_postgres context: .
environment: dockerfile: deploy/Dockerfile.frontend
POSTGRES_DB: ${DB_NAME:-moldinsight} image: gemold-frontend:latest
POSTGRES_USER: ${DB_USER:-moldinsight_user} container_name: gemold_frontend
POSTGRES_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
ports: ports:
- "5432:5432" # 浏览器入口端口(无默认值:必须由 .env 中 FRONTEND_PORT 显式配置)
volumes: - "${FRONTEND_PORT}:8000"
- 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}
depends_on: depends_on:
postgres: - backend
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped 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: networks:
- moldinsight_network - gemold_network
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:
<<: *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:
- gemold_network
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:
gemold_network:
# 固定 name,便于跨 compose 文件调试时容器互通(如 inventory-only 与 unified 临时联调)
name: gemold_network
driver: bridge
volumes: volumes:
postgres_data: uploads_data:
driver: local name: gemold_uploads_data
minio_data: html_data:
driver: local name: gemold_html_data
networks:
moldinsight_network:
driver: bridge
+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 只保留最短启动说明,不再承担部署手册职责
+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 归档目录结构(建议)
> 文档定位:**外部文档归档结构建议稿**。
> 本文描述的是面向 Confluence/知识库归档时的目录建议,不作为当前仓库内文档体系的权威说明。当前仓库文档入口见 [../../README.md](../../README.md) 与 [README.md](README.md)。
## 业务流程 ## 业务流程
- 01 端到端流程(客户订单→采购→到货→生产→交付) - 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) # 模具制造进销存核心模块分析报告(代码基线:geMoldInsight)
> 文档定位:**业务分析/审计型报告文档**。
> 本文保留一次特定分析基线下的观察结论与流程梳理,不作为当前项目状态或当前架构的权威说明。当前状态见 [../STATUS.md](../STATUS.md),当前架构见 [../ARCHITECTURE.md](../ARCHITECTURE.md),演进路线见 [../ROADMAP.md](../ROADMAP.md)。
## 0. 范围与术语映射 ## 0. 范围与术语映射
- 客户订单(Customer Order):本仓库实现为 SalesOrder(销售订单),其业务语义更贴近“模具订单/按单生产订单”。对应表:`sales_orders`、`sales_order_items`。 - 客户订单(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) # “零成品库存”证明报告(geMoldInsight)
> 文档定位:**特定业务口径下的专题证明/分析报告**。
> 本文解释“零成品库存”这一业务与财务口径,不作为当前项目整体状态的权威说明。当前状态见 [../STATUS.md](../STATUS.md),相关业务分析见 [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)。
## 1. 结论 ## 1. 结论
系统不设置成品入库、成品出库、销售退货等成品库存模块;系统库存口径仅覆盖“物料(material)”,成品(finished/模具)仅作为订单交付对象,不进入库存核算链路。 系统不设置成品入库、成品出库、销售退货等成品库存模块;系统库存口径仅覆盖“物料(material)”,成品(finished/模具)仅作为订单交付对象,不进入库存核算链路。
@@ -1,5 +1,7 @@
# AI 智能引擎设计文档 # AI 智能引擎设计文档
> 文档定位:**AI 能力方向的设计性/专题性文档**。
> 本文描述的是 AI 引擎的设计设想与能力规划,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),当前架构边界见 [../../../ARCHITECTURE.md](../../../ARCHITECTURE.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 一、AI 引擎架构 ## 一、AI 引擎架构
### 1.1 整体架构 ### 1.1 整体架构
@@ -1,5 +1,7 @@
# AI + FreeCAD 集成方案 # AI + FreeCAD 集成方案
> 文档定位:**AI / FreeCAD 集成方向的专题设计文档**。
> 本文描述的是集成设想、能力规划与差距分析,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),当前架构边界见 [../../../ARCHITECTURE.md](../../../ARCHITECTURE.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 一、项目概述 ## 一、项目概述
本文档描述 geMoldInsight 项目集成 AI 智能引擎和 FreeCAD 的完整方案,实现真实的模具型腔生成和 G 代码输出功能。 本文档描述 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. 核心接口基准
| 场景 | 接口 | 指标 | | 场景 | 接口 | 指标 |
@@ -1,5 +1,7 @@
# 性能与扩展性评估补充(模具订单/采购主线) # 性能与扩展性评估补充(模具订单/采购主线)
> 文档定位:**性能与扩展性方向的专题规划文档**。
> 本文描述的是性能评估、慢 SQL 发现、扩展路线等补充规划,不作为当前实现状态的权威说明。当前状态见 [../../../STATUS.md](../../../STATUS.md),后续路线见 [../../../ROADMAP.md](../../../ROADMAP.md)。
## 1. 高并发冲突面与加固点 ## 1. 高并发冲突面与加固点
### 1.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 ```bash
# 创建启动脚本 uvicorn src.entrypoints.moldinsight:app --host 0.0.0.0 --port 8000
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
``` ```
### systemd 服务配置 ### inventory-only
创建 `/etc/systemd/system/moldinsight.service`: ```bash
uvicorn src.entrypoints.inventory:app --host 0.0.0.0 --port 8001
```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
``` ```
**重要:** 在 `[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 ```nginx
upstream moldinsight_backend {
server 127.0.0.1:8000; # 对应 .env 中的 PORT
}
server { server {
listen 80; listen 80;
server_name your-domain.com; server_name moldinsight.example.com;
location / { location / {
proxy_pass http://moldinsight_backend; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /static {
alias /opt/moldinsight/moldinsight_project/static;
}
location /html_output {
alias /opt/moldinsight/moldinsight_project/html_output;
}
} }
``` ```
### Docker 部署 ### inventory-only
Docker 部署自动从 `.env` 读取配置,无需额外设置:
```bash
# .env 文件配置
PORT=8000 # 容器内端口
HOST_PORT=8080 # 宿主机端口
# 启动
docker-compose up -d
```
### 修改生产环境端口
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 ```nginx
upstream moldinsight_backend { server {
server 127.0.0.1:9000; # 更新为新端口 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;
}
} }
``` ```
```bash
sudo nginx -t && sudo nginx -s reload
```
### 防火墙配置 ---
如果修改了端口,需要更新防火墙规则: ## 7. 防火墙建议
如果不通过 Nginx 统一入口而是直接暴露服务端口,则应显式开放:
```bash ```bash
# UFW (Ubuntu/Debian) # moldinsight
sudo ufw allow 9000/tcp sudo ufw allow 8000/tcp
sudo ufw delete allow 8000/tcp # 删除旧端口
# firewall-cmd (CentOS/RHEL) # inventory
sudo firewall-cmd --permanent --add-port=9000/tcp sudo ufw allow 8001/tcp
sudo firewall-cmd --permanent --remove-port=8000/tcp
sudo firewall-cmd --reload
``` ```
### 健康检查 生产环境更推荐:
- 外部只开放 80/443
- 内部仅开放 8000/8001 给 Nginx 或内网访问
修改端口后,更新健康检查命令: ---
## 8. 快速检查
```bash ```bash
# 检查服务状态 curl http://127.0.0.1:8000/health
curl http://localhost:9000/health curl http://127.0.0.1:8001/health
curl http://your-domain.com/health
``` ```
## 快速参考 如果只部署单模块,只检查对应服务即可。
| 部署方式 | 端口配置位置 | 重启命令 | ---
|---------|------------|---------|
| 直接运行 | `.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` |
## 注意事项 ## 9. 结论
⚠️ **重要:** 在当前模块化架构下:
1. 所有端口配置统一在 `.env` 文件中管理
2. 修改端口后需要同步更新相关配置(Nginx、防火墙等) - moldinsight 与 inventory 应视为两个独立后端模块
3. 确保新端口没有被其他服务占用 - `unified` 是当前推荐部署模式,由 unified backend 提供单一后端入口
4. 生产环境建议使用 Nginx 反向代理,对外提供 80/443 端口 - 端口应按模块与部署模式清晰分配;生产环境通常通过同域 Nginx / 网关统一对外暴露 80/443
5. .env 文件不应提交到版本控制系统,使用 `.env.example` 作为模板
+339 -178
View File
@@ -1,177 +1,342 @@
# MoldInsight Linux 部署指南 # geMoldInsight Linux 部署指南
## 系统要求 > 文档定位:**Linux 环境下的详细部署操作文档**。
- Linux 系统 (Ubuntu 20.04+ / CentOS 8+) > 当前部署主题入口见 [../DEPLOYMENT.md](../DEPLOYMENT.md),当前项目状态见 [../STATUS.md](../STATUS.md),当前架构边界见 [../ARCHITECTURE.md](../ARCHITECTURE.md)。
- Python 3.8+ > 本文档描述的是 **当前模块化架构** 下的 Linux 部署方式,而不是历史单体入口方案。
- PostgreSQL 12+
当前项目支持三种部署模式:
- **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 - Git
- 服务器上已可访问的 PostgreSQL 15+
- 服务器上已可访问的 Redis 7+
## 1. 环境准备 ### 按模块附加要求
#### moldinsight / unified 需要
- 服务器上已可访问的 MinIO 或 RustFS 兼容对象存储
- PythonOCC 运行环境
- Celery worker(推荐与 moldinsight 一起部署)
#### inventory-only 需要
- PostgreSQL
- Redis
- 不依赖 MinIO / RustFS
- 不依赖 Celery worker
---
## 2. 代码获取与目录约定
### 安装系统依赖
```bash ```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 cd /opt
sudo git clone <your-repo-url> moldinsight sudo git clone <your-repo-url> geMoldInsight
sudo chown -R $USER:$USER moldinsight sudo chown -R $USER:$USER geMoldInsight
cd moldinsight cd geMoldInsight
# 或者直接复制项目文件到Linux服务器
``` ```
### 创建Python虚拟环境 后续示例默认项目位于:
```bash ```bash
cd moldinsight_project /opt/geMoldInsight
python3 -m venv venv
source venv/bin/activate
``` ```
### 安装依赖 ---
## 3. Python 环境准备
```bash ```bash
python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
``` ```
## 3. 环境配置 > 如果需要 moldinsight 分析能力,请额外准备 PythonOCC 运行环境。该依赖通常通过 conda 或预构建运行镜像提供,而不是直接由 pip 安装。
---
## 4. 环境变量配置
复制模板:
### 修改环境配置文件
编辑 `.env` 文件:
```bash ```bash
nano .env cp .env.example .env
``` ```
修改为Linux环境的配置: 或部署场景参考:
```env
# 数据库配置(Linux环境)
DATABASE_URL=postgresql+asyncpg://molduser:moldpassword@localhost:5432/moldinsight
# 服务配置 ```bash
DEBUG=false cp deploy/.env.example .env
```
至少需要配置(并指向服务器上已存在的基础设施):
```env
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=8000
# Redis配置(可选) DB_HOST=127.0.0.1
REDIS_HOST=localhost DB_PORT=5432
DB_NAME=moldinsight
DB_USER=moldinsight_user
DB_PASSWORD=change-me
REDIS_HOST=127.0.0.1
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=
# Kafka配置(可选) SECRET_KEY=change-me-in-production
KAFKA_BOOTSTRAP_SERVERS=localhost:9092 ADMIN_USERNAME=admin
KAFKA_SECURITY_PROTOCOL=PLAINTEXT 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 ```bash
mkdir -p uploads html_output logs source .venv/bin/activate
chmod 755 uploads html_output logs uvicorn src.entrypoints.inventory:app --host 0.0.0.0 --port 8001
``` ```
## 4. 启动服务 适合:
- 只提供进销存能力
- 不需要模具分析与异步任务
---
## 6.2 moldinsight-only
### 开发模式启动
```bash ```bash
cd moldinsight_project source .venv/bin/activate
source venv/bin/activate uvicorn src.entrypoints.moldinsight:app --host 0.0.0.0 --port 8000
python src/main.py
``` ```
### 生产环境启动(使用Gunicorn) 适合:
- 单独部署模具分析能力
- 文件上传 / 分析 / 导出 / 批量分析
### moldinsight Celery worker
建议同时启动 worker:
```bash ```bash
# 安装Gunicorn source .venv/bin/activate
pip install gunicorn uvloop httptools celery -A src.celery_app.celery_app worker --loglevel=info
# 启动服务
cd moldinsight_project
source venv/bin/activate
gunicorn src.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
``` ```
## 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 ```bash
sudo nano /etc/systemd/system/moldinsight.service sudo nano /etc/systemd/system/moldinsight-inventory.service
``` ```
添加以下内容:
```ini ```ini
[Unit] [Unit]
Description=MoldInsight Geometry Analysis Service Description=geMoldInsight Inventory API
After=network.target postgresql.service After=network.target postgresql.service redis.service
[Service] [Service]
Type=simple Type=simple
User=www-data User=www-data
Group=www-data Group=www-data
WorkingDirectory=/opt/moldinsight/moldinsight_project WorkingDirectory=/opt/geMoldInsight
Environment=PATH=/opt/moldinsight/moldinsight_project/venv/bin Environment=PATH=/opt/geMoldInsight/.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 ExecStart=/opt/geMoldInsight/.venv/bin/uvicorn src.entrypoints.inventory:app --host 0.0.0.0 --port 8001
Restart=always Restart=always
RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
### 启用并启动服务 启用:
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable moldinsight sudo systemctl enable moldinsight-inventory
sudo systemctl start moldinsight sudo systemctl start moldinsight-inventory
sudo systemctl status moldinsight
``` ```
## 6. Nginx反向代理配置(可选) ---
## 7.2 moldinsight-only API 服务
创建:
### 安装Nginx
```bash ```bash
# Ubuntu/Debian sudo nano /etc/systemd/system/moldinsight-moldinsight.service
sudo apt install nginx
# CentOS/RHEL
sudo yum install nginx
``` ```
### 创建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 ```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 ```nginx
server { server {
listen 80; 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 / { location / {
proxy_pass http://127.0.0.1:8000; 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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /static {
alias /opt/moldinsight/moldinsight_project/static;
expires 30d;
}
} }
``` ```
### 启用站点并重启Nginx ### 8.3 unified(示意)
```bash
sudo ln -s /etc/nginx/sites-available/moldinsight /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
## 7. 防火墙配置 统一对外时,可由网关做模块路由,也可直接暴露统一组合后的应用入口。组合层标准化完成前,更推荐通过部署编排或网关来实现统一对外访问。
---
## 9. 验证部署
### 9.1 健康检查
inventory-only:
```bash ```bash
# Ubuntu/Debian (ufw) curl http://127.0.0.1:8001/health
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
``` ```
## 8. 验证部署 moldinsight-only:
### 检查服务状态
```bash ```bash
# 检查应用服务 curl http://127.0.0.1:8000/health
curl http://localhost:8000/health
# 检查数据库连接
sudo -u postgres psql -d moldinsight -c "SELECT version();"
``` ```
### 测试文件上传 ### 9.2 基础能力验证
访问 `http://your-server-ip:8000` 上传STP文件测试功能。
## 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. **端口被占用** ## 10. 常见问题
- 检查端口使用:`netstat -tulpn | grep 8000`
- 修改端口或停止占用进程
3. **权限问题** ### 1. 为什么不再推荐 `src.main:app`?
- 确保目录权限正确:`chmod 755 uploads html_output logs` 因为当前项目已演进为模块化结构,`src.main:app` 更适合作为过渡兼容入口,而不是长期部署标准。应优先围绕 [entrypoints/](../../src/entrypoints/) 部署。
- 检查文件所有者:`ls -la`
4. **依赖安装失败** ### 2. inventory-only 为什么不需要对象存储?
- 更新pip:`pip install --upgrade pip` 因为对象存储主要服务于 moldinsight 分析产物(HTML、导出文件等)。纯 inventory 部署不需要这部分基础设施。
- 使用国内镜像:`pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
## 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 ```bash
# 备份数据库 bash deploy/build.sh # base → backend → frontend(celery 复用 backend 镜像)
sudo -u postgres pg_dump moldinsight > moldinsight_backup.sql # 或让 compose 构建:docker compose up -d --build
# 注意:docker compose up -d 对本地已有同名镜像不会自动重建,更新代码后需 --build
# 恢复数据库
sudo -u postgres psql -d moldinsight < moldinsight_backup.sql
``` ```
### 文件备份 ---
```bash
# 备份上传的文件和配置
tar -czf moldinsight_backup.tar.gz uploads/ html_output/ .env requirements.txt
```
## 快速启动脚本 ## 12. 推荐阅读
创建启动脚本 `start.sh`: - [README.md](../../README.md)
```bash - [archive/BACKEND_MODULARIZATION_BLUEPRINT.md](../archive/BACKEND_MODULARIZATION_BLUEPRINT.md)
#!/bin/bash - [DEPLOY_PORT.md](./DEPLOY_PORT.md)
cd /opt/moldinsight/moldinsight_project - [PORT_CONFIG.md](./PORT_CONFIG.md)
source venv/bin/activate
python src/main.py
```
赋予执行权限:
```bash
chmod +x start.sh
./start.sh
```
现在您的MoldInsight项目已经可以在Linux环境下正常运行!
+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 对于 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)
PORT=8000 - `.env` / `deploy/.env.example`
# Docker映射到宿主机的端口(docker-compose使用)
HOST_PORT=10001 注意:这些 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 互通)
``` ```
### 端口含义 其余基础设施通常为:
| 变量 | 用途 | 默认值 | 说明 | ```env
|------|------|--------|------| DB_PORT=5432
| `PORT` | 应用监听端口 | 8000 | FastAPI/Uvicorn 服务监听的端口 | REDIS_PORT=6379
| `HOST_PORT` | 宿主机映射端口 | 10001 | Docker Compose 映射到宿主机的端口 |
### 使用场景
#### 1. 本地直接运行(Python)
```bash
python src/main.py
``` ```
服务将在 `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)** ## 2. 各端口的含义
```bash
# .env 文件 | 变量 / 端口 | 用途 |
PORT=8000 # 容器内不变 |---|---|
HOST_PORT=8080 # 宿主机改为8080 | `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:修改应用端口(容器内/本地运行)** ### 3.2 inventory-only
```bash
# .env 文件 ```env
PORT=9000 # 应用改为9000 INVENTORY_PORT=8001
HOST_PORT=10001 # 宿主机映射到10001 DB_PORT=5432
REDIS_PORT=6379
``` ```
- 本地运行:`http://localhost:9000`
- Docker运行:`http://localhost:10001` (映射到容器内9000)
**场景C:同时修改两个端口** ### 3.3 full / 模块同时部署
```bash
# .env 文件 ```env
PORT=9000 MOLDINSIGHT_PORT=8000
HOST_PORT=9000 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` 通过直接运行或 Docker Compose 部署时,端口含义保持一致,但映射方式不同。
2. **`src/main.py`** - 从 `settings` 获取端口配置
3. **`docker-compose.yml`** - 从 `.env` 读取 `HOST_PORT` 和 `PORT`
### 相关文件 ### direct run
- **`.env`** - ⭐ 唯一需要修改的配置文件 moldinsight:
- **`config/settings.py`** - 配置读取逻辑(无需修改)
- **`src/main.py`** - 使用配置启动服务(无需修改)
- **`docker-compose.yml`** - Docker端口映射(自动读取 `.env`)
- **`start.sh` / `start_fixed.sh`** - 启动脚本(自动读取 `.env`)
## 常见问题 ```bash
uvicorn src.entrypoints.moldinsight:app --port 8000
```
### Q: 为什么 Docker 宿主机端口和应用端口分开配置? inventory:
A: 这样可以灵活调整容器端口而不影响外部访问,也避免端口冲突。
### Q: 修改后需要重启吗? ```bash
A: 是的,修改 `.env` 后需要重启服务才能生效: uvicorn src.entrypoints.inventory:app --port 8001
- 本地运行:Ctrl+C 停止后重新 `python src/main.py` ```
- Docker: `docker-compose down && docker-compose up`
### Q: 如何避免端口冲突? ### Docker Compose
A: 确保 `HOST_PORT` 不与其他服务冲突,可以使用 `netstat -an | grep <端口>` 检查端口占用情况。
### Q: 可以使用 80 端口吗? Compose 通过端口映射暴露服务:
A: 可以,但需要管理员权限:
- Linux/Mac: 使用 sudo - moldinsight → `${MOLDINSIGHT_PORT}:8000`
- Docker: 需要容器有足够权限 - inventory → `${INVENTORY_PORT}:8001`
- 生产环境建议使用反向代理(如 Nginx)
当前实际定义见:
- [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/财务/供应商门户/客户门户) # 外部接口与集成盘点模板(ERP/财务/供应商门户/客户门户)
> 文档定位:**接口梳理与集成盘点模板文档**。
> 本文是用于外部系统对接时的模板,不作为当前项目状态或当前接口实现清单的权威说明。当前状态见 [../STATUS.md](../STATUS.md),当前架构见 [../ARCHITECTURE.md](../ARCHITECTURE.md)。
## 1. 接口清单 ## 1. 接口清单
| 接口名称 | 调用方向 | 协议 | 鉴权 | 频率 | 单次数据量 | 幂等键 | 超时 | 重试策略 | 死信/补偿 | 负责人 | | 接口名称 | 调用方向 | 协议 | 鉴权 | 频率 | 单次数据量 | 幂等键 | 超时 | 重试策略 | 死信/补偿 | 负责人 |
@@ -1,5 +1,8 @@
# UAT 验收清单(模具制造进销存主线) # UAT 验收清单(模具制造进销存主线)
> 文档定位:**UAT 验收模板 / 验收过程文档**。
> 本文用于业务验收与签字过程,不作为当前项目状态或当前架构的权威说明。当前状态见 [../STATUS.md](../STATUS.md),当前架构见 [../ARCHITECTURE.md](../ARCHITECTURE.md)。
## 1. 业务流程签字 ## 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 对象存储集成说明
> 文档定位:**RustFS / 对象存储集成的专题说明文档**。
> 本文解释对象存储侧的接口与集成思路,不作为当前部署入口文档。当前部署方式见 [../../DEPLOYMENT.md](../../DEPLOYMENT.md) 与 [../../deployment/LINUX_SETUP.md](../../deployment/LINUX_SETUP.md),当前状态见 [../../STATUS.md](../../STATUS.md)。
## 架构概述 ## 架构概述
本项目采用 **RustFS** 作为对象存储和 **PostgreSQL** 作为元数据存储的双层存储架构。 本项目采用 **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 是否可达
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+70
View File
@@ -0,0 +1,70 @@
# geMoldInsight Frontend
这是 geMoldInsight 的独立前端工程,基于:
- 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 分析产物)
+4 -11
View File
@@ -1,8 +1,8 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gemold - 模具制造管理系统</title> <title>Gemold - 模具制造管理系统</title>
<script> <script>
(function () { (function () {
@@ -61,8 +61,6 @@
} catch (e) {} } catch (e) {}
})(); })();
</script> </script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.css">
<link rel="stylesheet" href="/static/style.css?v=20260419-01">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -73,11 +71,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="/src/main.ts"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.js"></script>
<script src="/static/vue-app.js?v=20260506-01"></script>
</body> </body>
</html> </html>
+1580
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"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",
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"tdesign-icons-vue-next": "^0.4.4",
"tdesign-vue-next": "^1.20.1",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
+252
View File
@@ -0,0 +1,252 @@
<template>
<div class="app-container">
<div class="notification-container" v-if="store.notifications.length > 0">
<TransitionGroup name="notification">
<div
v-for="n in store.notifications"
:key="n.id"
:class="['notification', 'notification-' + n.type]"
>
<div class="notification-icon">
{{ n.type === 'success' ? '✓' : n.type === 'error' ? '✕' : n.type === 'warning' ? '!' : 'i' }}
</div>
<div class="notification-content">
<div class="notification-message">{{ n.message }}</div>
</div>
<button class="notification-close" @click="dismissNotification(n.id)">×</button>
</div>
</TransitionGroup>
</div>
<header class="app-header">
<div class="header-content">
<div class="logo" @click="router.push('/')">
<div class="logo-icon">G</div>
<div>
<div class="logo-text">Gemold</div>
<div class="logo-subtitle">模具制造管理系统</div>
</div>
</div>
<nav class="nav-menu" v-if="store.user">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
:class="['nav-item', { active: isActive(item.path) }]"
>
{{ item.label }}
</router-link>
</nav>
<div class="user-section">
<template v-if="store.user">
<div class="user-info">
<div class="user-avatar">{{ (store.user.full_name || store.user.username).charAt(0).toUpperCase() }}</div>
<span class="user-name">{{ store.user.full_name || store.user.username }}</span>
</div>
<t-button theme="default" variant="outline" size="small" @click="handleLogout">退出</t-button>
</template>
<template v-else>
<router-link to="/login" class="btn btn-primary">登录</router-link>
</template>
</div>
</div>
</header>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<footer class="app-footer">
<div class="footer-content">
<a href="https://beian.miit.gov.cn/" target="_blank" class="beian-link">粤ICP备2025386132号-1</a>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { clearAuth, initAuth } from '@/shared/auth'
import { addNotification } from '@/shared/notification'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const menuItems = computed(() => {
const items = [
{ path: '/', label: '首页', icon: '⌂' },
{ path: '/inventory', label: '进销存', icon: '⊞' },
{ path: '/moldinsight', label: 'MoldInsight', icon: '◈' },
]
if (store.user?.is_superuser) {
items.push({ path: '/users', label: '用户管理', icon: '👤' })
}
return items
})
const isActive = (path: string) => {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
const handleLogout = async () => {
try {
await apiRequest('/api/auth/logout', { method: 'POST' })
} catch { /* ignore */ }
clearAuth()
addNotification('已退出登录', 'success')
router.push('/login')
}
const dismissNotification = (id: number) => {
const notification = store.notifications.find(n => n.id === id)
if (notification) {
notification.visible = false
setTimeout(() => {
store.dismissNotification(id)
}, 300)
}
}
onMounted(() => {
initAuth()
})
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
padding-bottom: 48px;
}
.app-header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-light);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--space-6);
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo { display: flex; align-items: center; gap: var(--space-3); cursor: pointer; }
.logo-icon {
width: 36px; height: 36px;
background: var(--primary-500);
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
color: white; font-size: var(--text-lg); font-weight: var(--font-bold);
}
.logo-text { font-size: var(--text-xl); font-weight: var(--font-semibold); color: var(--text-primary); }
.logo-subtitle { font-size: var(--text-xs); color: var(--text-tertiary); }
.nav-menu { display: flex; align-items: center; gap: var(--space-1); }
.nav-item {
padding: var(--space-2) var(--space-4); border-radius: var(--radius-md);
color: var(--text-secondary); text-decoration: none;
font-size: var(--text-sm); font-weight: var(--font-medium);
transition: all var(--duration-fast) var(--ease-default);
}
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.nav-item.active { background: var(--primary-50); color: var(--primary-600); }
.nav-item:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22); }
.user-section { display: flex; align-items: center; gap: var(--space-4); }
.user-info { display: flex; align-items: center; gap: var(--space-3); }
.user-avatar {
width: 32px; height: 32px;
background: var(--primary-100); color: var(--primary-600);
border-radius: var(--radius-full);
display: flex; align-items: center; justify-content: center;
font-size: var(--text-sm); font-weight: var(--font-semibold);
}
.user-name { font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--text-primary); }
.main-content {
flex: 1; width: 100%; margin: 0 auto;
padding: var(--space-8) var(--space-6);
padding-bottom: var(--space-8);
}
@media (max-width: 768px) { .main-content { padding: var(--space-6) var(--space-4); } }
@media (min-width: 1400px) { .main-content { max-width: 95vw; padding: var(--space-8) var(--space-8); } }
@media (min-width: 1600px) { .main-content { max-width: 90vw; } }
@media (min-width: 1920px) { .main-content { max-width: 85vw; } }
.app-footer {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--bg-primary); border-top: 1px solid var(--border-light);
padding: var(--space-3) 0; z-index: 99;
}
.footer-content { max-width: 1400px; margin: 0 auto; padding: 0 var(--space-6); text-align: center; }
.beian-link { font-size: var(--text-xs); color: var(--text-tertiary); text-decoration: none; transition: color var(--duration-fast) var(--ease-default); }
.beian-link:hover { color: var(--primary-600); text-decoration: underline; }
.notification-container {
position: fixed; top: var(--space-6); right: var(--space-6); z-index: 1000;
display: flex; flex-direction: column; gap: var(--space-3);
}
.notification {
display: flex; align-items: flex-start; gap: var(--space-3);
padding: var(--space-4); background: var(--bg-primary);
border: 1px solid var(--border-light); border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg); min-width: 320px; max-width: 400px;
}
.notification-success { border-left: 3px solid var(--success); }
.notification-error { border-left: 3px solid var(--error); }
.notification-warning { border-left: 3px solid var(--warning); }
.notification-icon { width: 20px; height: 20px; flex-shrink: 0; }
.notification-content { flex: 1; }
.notification-message { font-size: var(--text-sm); color: var(--text-primary); }
.notification-close {
width: 20px; height: 20px; border: none; background: transparent;
color: var(--text-muted); cursor: pointer; padding: 0;
}
.notification-enter-active, .notification-leave-active { transition: all var(--duration-normal) var(--ease-default); }
.notification-enter-from, .notification-leave-to { opacity: 0; transform: translateX(100%); }
.fade-enter-active, .fade-leave-active { transition: opacity var(--duration-normal) var(--ease-default); }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.loading-overlay {
position: fixed; inset: 0; background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.loading-content { background: var(--bg-primary); padding: var(--space-8); border-radius: var(--radius-xl); text-align: center; }
.loading-spinner {
width: 24px; height: 24px; border: 2px solid var(--border-default);
border-top-color: var(--primary-500); border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.header-content { padding: 0 var(--space-4); height: 56px; }
.logo-subtitle { display: none; }
.nav-menu { gap: 0; }
.nav-item { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); }
.user-name { display: none; }
.footer-content { padding: 0 var(--space-4); }
.beian-link { font-size: 11px; }
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
<template>
<div>
<div class="page-header">
<h1 class="page-title">设计体系</h1>
<p class="page-subtitle">迁移中...</p>
</div>
</div>
</template>
@@ -0,0 +1,8 @@
<template>
<div>
<div class="page-header">
<h1 class="page-title">灰度发布与回滚</h1>
<p class="page-subtitle">迁移中...</p>
</div>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/styles/main.css'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(TDesign)
app.mount('#app')
+234
View File
@@ -0,0 +1,234 @@
<template>
<div>
<div class="hero-section">
<h1 class="hero-title">Gemold 模具制造管理系统</h1>
<p class="hero-subtitle">智能模具设计与制造一体化平台,从零件分析到模具生成,全流程数字化解决方案</p>
</div>
<div class="aluminum-section" v-if="aluminum.price">
<div class="card aluminum-price-card">
<div class="card-header">
<div class="card-title">
<span class="aluminum-icon">🪙</span> 铝金属价格
</div>
<span class="aluminum-source">{{ aluminumSourceLabel }}</span>
</div>
<div class="aluminum-content">
<div class="aluminum-price-row">
<div class="aluminum-current">
<span class="aluminum-price-value">{{ formatAluminumPrice(aluminum.price) }}</span>
<span class="aluminum-price-unit">元/吨</span>
</div>
<div :class="['aluminum-change', aluminum.change >= 0 ? 'price-up' : 'price-down']">
<span class="change-arrow">{{ aluminum.change >= 0 ? '▲' : '▼' }}</span>
<span>{{ formatAluminumPrice(Math.abs(aluminum.change)) }}</span>
<span>({{ aluminum.change_percent >= 0 ? '+' : '' }}{{ aluminum.change_percent }}%)</span>
</div>
</div>
<div class="aluminum-detail-row">
<div class="aluminum-detail-item">
<span class="detail-label">开盘价</span>
<span class="detail-value">{{ formatAluminumPrice(aluminum.open) }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">最高价</span>
<span class="detail-value">{{ formatAluminumPrice(aluminum.high) }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">最低价</span>
<span class="detail-value">{{ formatAluminumPrice(aluminum.low) }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">昨收价</span>
<span class="detail-value">{{ formatAluminumPrice(aluminum.prev_close) }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">7日前</span>
<span class="detail-value">{{ formatAluminumPrice(aluminum.week_ago_price) }}</span>
</div>
</div>
<div class="aluminum-chart-wrapper" v-if="aluminum.history.length > 0">
<div class="aluminum-chart-title">近30日价格走势</div>
<div class="aluminum-chart-container">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="features-grid">
<div class="feature-card" @click="router.push('/moldinsight')" style="cursor: pointer;">
<div class="feature-icon">◈</div>
<h3 class="feature-title">MoldInsight</h3>
<p class="feature-desc">智能模具分析引擎,支持 STP/STEP 文件解析、分模设计、CAM 刀路规划</p>
</div>
<div class="feature-card" @click="router.push('/inventory')" style="cursor: pointer;">
<div class="feature-icon">⊞</div>
<h3 class="feature-title">进销存管理</h3>
<p class="feature-desc">产品管理、采购销售、库存管理、财务管理,一站式 ERP 解决方案</p>
</div>
<div class="feature-card">
<div class="feature-icon">⚙</div>
<h3 class="feature-title">AI 驱动</h3>
<p class="feature-desc">AI 分型面检测、模具方案评分、智能设计建议,提升设计效率</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, onMounted, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiRequest } from '@/shared/api'
import { Chart, registerables } from 'chart.js'
const router = useRouter()
const chartCanvas = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
interface AluminumPrice {
price: number
unit: string
date: string
change: number
change_percent: number
open: number
high: number
low: number
prev_close: number
week_ago_price: number
// D2:数据来源声明——simulated 为模拟走势(参考数据,非实时行情)
source?: string
}
interface HistoryItem {
date: string
close: number
}
const aluminum = reactive({
price: null as number | null,
unit: '',
date: '',
change: 0,
change_percent: 0,
open: 0,
source: '',
high: 0,
low: 0,
prev_close: 0,
week_ago_price: 0,
history: [] as HistoryItem[],
})
const formatAluminumPrice = (val: number | null) => {
if (val === null || val === undefined) return '--'
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
aluminum.high = data.high
aluminum.low = data.low
aluminum.prev_close = data.prev_close
aluminum.week_ago_price = data.week_ago_price
} catch (e) {
console.error('加载铝价失败:', e)
}
try {
const history = await apiRequest<HistoryItem[]>('/api/aluminum-price/history?days=30')
aluminum.history = history
await nextTick()
renderChart()
} catch (e) {
console.error('加载铝价历史失败:', e)
}
}
const renderChart = () => {
if (!chartCanvas.value || aluminum.history.length === 0) return
if (chartInstance) chartInstance.destroy()
const isUp = aluminum.change >= 0
const lineColor = isUp ? '#dc2626' : '#16a34a'
const fillColor = isUp ? 'rgba(220, 38, 38, 0.08)' : 'rgba(22, 163, 74, 0.08)'
chartInstance = new Chart(chartCanvas.value, {
type: 'line',
data: {
labels: aluminum.history.map(h => h.date.slice(5)),
datasets: [{
label: '收盘价 (元/吨)',
data: aluminum.history.map(h => h.close),
borderColor: lineColor,
backgroundColor: fillColor,
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `${Math.round(ctx.parsed.y ?? 0).toLocaleString('zh-CN')} 元/吨`,
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { maxTicksLimit: 10, font: { size: 10 }, color: 'var(--text-tertiary)' },
},
y: {
grid: { color: 'var(--border-light)' },
ticks: {
font: { size: 10 },
color: 'var(--text-tertiary)',
callback: (v) => (v as number).toLocaleString('zh-CN'),
},
},
},
},
})
}
onMounted(() => {
Chart.register(...registerables)
loadAluminumPrice()
})
</script>
<style scoped>
.hero-section { text-align: center; padding: var(--space-16) 0; }
.hero-title { font-size: var(--text-4xl); font-weight: var(--font-bold); color: var(--text-primary); margin-bottom: var(--space-4); }
.hero-subtitle { font-size: var(--text-lg); color: var(--text-tertiary); max-width: 600px; margin: 0 auto var(--space-8); }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--space-6); margin-top: var(--space-12); }
.feature-card { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: var(--radius-xl); padding: var(--space-6); text-align: left; }
.feature-card[style*="cursor"]:hover { border-color: var(--primary-200); box-shadow: var(--shadow-md); transform: translateY(-2px); transition: all 0.2s ease; }
.feature-icon { width: 40px; height: 40px; background: var(--primary-50); border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; color: var(--primary-500); font-size: var(--text-lg); margin-bottom: var(--space-4); }
.feature-title { font-size: var(--text-base); font-weight: var(--font-semibold); color: var(--text-primary); margin-bottom: var(--space-2); }
.feature-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.6; }
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { addNotification } from '@/shared/notification'
import { useInventoryStore } from '@/stores/inventory'
import InventorySidebar from './components/InventorySidebar.vue'
const store = useInventoryStore()
const appStore = useAppStore()
const router = useRouter()
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 (!appStore.user) {
router.push('/login')
return
}
store.activeTab = tabFromRoute()
store.checkBackendHealth().then(() => {
if (!store.backendDbReady) {
addNotification(store.backendDbMessage || '业务服务不可用', 'warning')
return
}
store.loadDashboard()
})
})
onUnmounted(() => {
store.destroyPickers()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<h1>进销存管理</h1>
<p>库存、采购、销售管理</p>
</div>
<div class="inventory-layout">
<aside class="inventory-sidebar-wrapper">
<InventorySidebar />
</aside>
<section class="inventory-content">
<div class="inventory-content-header">
<div class="inventory-breadcrumb">
<span>{{ store.activeMenu?.group?.title || '进销存' }}</span>
<span class="sep">/</span>
<span class="current">{{ store.activeMenu?.item?.label || '' }}</span>
</div>
</div>
<t-alert
v-if="!store.backendDbReady"
:title="store.backendDbMessage"
theme="warning"
show-icon
:closable="false"
style="margin-bottom: 16px;"
/>
<t-loading :loading="store.loading">
<router-view />
</t-loading>
</section>
</div>
</div>
</template>
<style scoped>
.page-container { padding: 0; }
.page-header { margin-bottom: var(--space-6); }
.page-header h1 { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--text-primary); margin: 0; }
.page-header p { color: var(--text-tertiary); margin: var(--space-1) 0 0; font-size: var(--text-sm); }
.inventory-layout { display: flex; gap: var(--space-6); align-items: flex-start; }
.inventory-sidebar-wrapper { width: 200px; flex-shrink: 0; position: sticky; top: 80px; }
.inventory-content { flex: 1; min-width: 0; }
.inventory-content-header { margin-bottom: var(--space-4); }
.inventory-breadcrumb { font-size: var(--text-sm); color: var(--text-tertiary); }
.inventory-breadcrumb .sep { margin: 0 var(--space-2); }
.inventory-breadcrumb .current { color: var(--text-primary); font-weight: var(--font-medium); }
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
const { state, loadCustomers } = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const saving = ref(false)
const form = reactive({
name: '',
contact_person: '',
phone: '',
email: '',
address: ''
})
function openCreateCustomer() {
editingItem.value = null
form.name = ''
form.contact_person = ''
form.phone = ''
form.email = ''
form.address = ''
showModal.value = true
}
function editCustomer(item: any) {
editingItem.value = item
form.name = item.name ?? ''
form.contact_person = item.contact_person ?? ''
form.phone = item.phone ?? ''
form.email = item.email ?? ''
form.address = item.address ?? ''
showModal.value = true
}
async function save() {
if (saving.value) return
if (!form.name || !form.name.trim()) {
addNotification('请输入客户名称', 'warning')
return
}
saving.value = true
try {
const payload = {
name: form.name,
contact_person: form.contact_person || null,
phone: form.phone || null,
email: form.email || null,
address: form.address || null
}
if (editingItem.value) {
await apiRequest(`/api/customers/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
addNotification('客户更新成功', 'success')
} else {
await apiRequest('/api/customers', {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('客户已创建', 'success')
}
showModal.value = false
loadCustomers()
} catch (e) {
handleApiError(e, '保存客户')
} finally {
saving.value = false
}
}
async function deleteCustomer(id: number) {
if (!confirm('确定要删除这个客户吗?')) return
try {
await apiRequest(`/api/customers/${id}`, { method: 'DELETE' })
addNotification('客户已删除', 'success')
loadCustomers()
} catch (e) {
handleApiError(e, '删除客户')
}
}
onMounted(() => { loadCustomers() })
</script>
<template>
<div class="customers-tab">
<div class="table-header">
<t-button type="primary" @click="openCreateCustomer">+ 新增客户</t-button>
</div>
<t-table :data="state.customers" style="width: 100%" :loading="state.loading" empty-text="暂无客户数据">
<t-table-column prop="code" label="编码" />
<t-table-column prop="name" label="名称" />
<t-table-column prop="contact_person" label="联系人">
<template #default="{ row }">
{{ row.contact_person || '-' }}
</template>
</t-table-column>
<t-table-column prop="phone" label="电话">
<template #default="{ row }">
{{ row.phone || '-' }}
</template>
</t-table-column>
<t-table-column prop="email" label="邮箱">
<template #default="{ row }">
{{ row.email || '-' }}
</template>
</t-table-column>
<t-table-column label="操作" width="180">
<template #default="{ row }">
<t-button size="small" @click="editCustomer(row)">编辑</t-button>
<t-button size="small" type="danger" @click="deleteCustomer(row.id)">删除</t-button>
</template>
</t-table-column>
</t-table>
<t-dialog v-model="showModal" :title="editingItem ? '编辑客户' : '新增客户'" width="520px" :close-on-click-modal="false">
<t-form :model="form" label-width="80px">
<t-form-item label="名称" required>
<t-input v-model="form.name" placeholder="请输入客户名称" />
</t-form-item>
<t-form-item label="联系人">
<t-input v-model="form.contact_person" placeholder="请输入联系人" />
</t-form-item>
<t-form-item label="电话">
<t-input v-model="form.phone" placeholder="请输入电话" />
</t-form-item>
<t-form-item label="邮箱">
<t-input v-model="form.email" type="email" placeholder="请输入邮箱" />
</t-form-item>
<t-form-item label="地址">
<t-input v-model="form.address" placeholder="请输入地址" />
</t-form-item>
</t-form>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="save">保存</t-button>
</template>
</t-dialog>
</div>
</template>
<style scoped>
.customers-tab {
padding: 0;
}
.table-header {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useInventory } from '@/modules/inventory/composables/useInventory'
const { state, formatCurrency } = useInventory()
</script>
<template>
<t-row :gutter="16">
<t-col :span="8" v-for="card in [
{ icon: '📦', value: state.dashboard?.finished_product_count || 0, label: '成品数量' },
{ icon: '📊', value: state.dashboard?.total_stock || 0, label: '物料库存总量' },
{ icon: '💰', value: formatCurrency(state.dashboard?.total_value || 0), label: '库存价值' },
{ icon: '🏭', value: state.dashboard?.supplier_count || 0, label: '供应商' },
{ icon: '👥', value: state.dashboard?.customer_count || 0, label: '客户' },
{ icon: '🏪', value: state.dashboard?.warehouse_count || 0, label: '仓库' }
]" :key="card.label" style="margin-bottom: 16px">
<t-card shadow="hover">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 32px;">{{ card.icon }}</span>
<div>
<div style="font-size: 24px; font-weight: 700;">{{ card.value }}</div>
<div style="color: var(--text-tertiary); font-size: 13px;">{{ card.label }}</div>
</div>
</div>
</t-card>
</t-col>
</t-row>
</template>
<style scoped>
</style>
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useInventory } from '../composables/useInventory'
const {
state,
formatCurrency,
formatDateTime,
formatDate,
loadFinance,
refreshFinanceByPeriod
} = useInventory()
const periodYear = ref(state.financePeriod.year)
const periodQuarter = ref(state.financePeriod.quarter)
const quarterOptions = [
{ label: '全年', value: '' },
{ label: 'Q1', value: '1' },
{ label: 'Q2', value: '2' },
{ label: 'Q3', value: '3' },
{ label: 'Q4', value: '4' }
]
const periodLabel = computed(() => {
if (!periodQuarter.value) return `${periodYear.value}年 全年`
return `${periodYear.value}年 Q${periodQuarter.value}`
})
function refreshFinance() {
state.financePeriod.year = periodYear.value
state.financePeriod.quarter = periodQuarter.value
loadFinance()
}
onMounted(() => {
refreshFinance()
})
// 字段名对齐后端 FinanceSummaryResponse。
// 后端 Decimal 序列化为 JSON 字符串,故用 Number() 转换再填充 0。
const totalReceivable = computed(() => {
return Number(state.financeSummary?.receivable_total) || 0
})
const totalPayable = computed(() => {
return Number(state.financeSummary?.payable_total) || 0
})
const periodReceived = computed(() => {
return Number(state.financeSummary?.period_receipt_total) || 0
})
const periodPaid = computed(() => {
return Number(state.financeSummary?.period_payment_total) || 0
})
function getTransactionTypeLabel(type: string): string {
return type === 'receipt' ? '收款' : '付款'
}
function getTransactionTypeTag(type: string): string {
return type === 'receipt' ? 'success' : 'warning'
}
function getTransactionStatusLabel(status: string): string {
const map: Record<string, string> = {
confirmed: '已确认',
pending: '待确认'
}
return map[status] || status
}
</script>
<template>
<div>
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<span style="font-weight: 600;">统计周期:</span>
<t-input-number
v-model="periodYear"
:min="2020"
:max="2099"
style="width: 120px;"
/>
<span>年</span>
<t-select v-model="periodQuarter" placeholder="选择季度" style="width: 120px;">
<t-option
v-for="q in quarterOptions"
:key="q.value"
:label="q.label"
:value="q.value"
/>
</t-select>
<t-button type="primary" @click="refreshFinance">刷新统计</t-button>
<t-tag type="info" size="large">{{ periodLabel }}</t-tag>
</div>
<t-row :gutter="16" style="margin-bottom: 16px;">
<t-col :span="6">
<t-card shadow="hover">
<div style="text-align: center;">
<div style="color: #909399; font-size: 14px; margin-bottom: 8px;">应收总额</div>
<div style="font-size: 24px; font-weight: 700; color: #409eff;">
{{ formatCurrency(totalReceivable) }}
</div>
</div>
</t-card>
</t-col>
<t-col :span="6">
<t-card shadow="hover">
<div style="text-align: center;">
<div style="color: #909399; font-size: 14px; margin-bottom: 8px;">应付总额</div>
<div style="font-size: 24px; font-weight: 700; color: #e6a23c;">
{{ formatCurrency(totalPayable) }}
</div>
</div>
</t-card>
</t-col>
<t-col :span="6">
<t-card shadow="hover">
<div style="text-align: center;">
<div style="color: #909399; font-size: 14px; margin-bottom: 8px;">周期收款</div>
<div style="font-size: 24px; font-weight: 700; color: #67c23a;">
{{ formatCurrency(periodReceived) }}
</div>
</div>
</t-card>
</t-col>
<t-col :span="6">
<t-card shadow="hover">
<div style="text-align: center;">
<div style="color: #909399; font-size: 14px; margin-bottom: 8px;">周期付款</div>
<div style="font-size: 24px; font-weight: 700; color: #f56c6c;">
{{ formatCurrency(periodPaid) }}
</div>
</div>
</t-card>
</t-col>
</t-row>
<!-- 字段对齐 PartnerStatementItemResponse:order_total / settled_total / transaction_total / outstanding_total -->
<t-card style="margin-bottom: 16px;">
<template #header>
<span style="font-weight: 600;">客户账款(周期)</span>
</template>
<t-table :data="state.customerFinanceStatement" :loading="state.loading" stripe size="small">
<t-table-column prop="partner_name" label="客户" />
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="transaction_count" label="流水数" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_total) }}</template>
</t-table-column>
<t-table-column label="订单已收">
<template #default="{ row }">{{ formatCurrency(row.settled_total) }}</template>
</t-table-column>
<t-table-column label="实收流水">
<template #default="{ row }">{{ formatCurrency(row.transaction_total) }}</template>
</t-table-column>
<t-table-column label="应收余额">
<template #default="{ row }">{{ formatCurrency(row.outstanding_total) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.customerFinanceStatement.length === 0" description="暂无数据" />
</t-card>
<t-card style="margin-bottom: 16px;">
<template #header>
<span style="font-weight: 600;">供应商账款(周期)</span>
</template>
<t-table :data="state.supplierFinanceStatement" :loading="state.loading" stripe size="small">
<t-table-column prop="partner_name" label="供应商" />
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="transaction_count" label="流水数" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_total) }}</template>
</t-table-column>
<t-table-column label="订单已付">
<template #default="{ row }">{{ formatCurrency(row.settled_total) }}</template>
</t-table-column>
<t-table-column label="实付流水">
<template #default="{ row }">{{ formatCurrency(row.transaction_total) }}</template>
</t-table-column>
<t-table-column label="应付余额">
<template #default="{ row }">{{ formatCurrency(row.outstanding_total) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.supplierFinanceStatement.length === 0" description="暂无数据" />
</t-card>
<!-- 字段对齐 PartnerProductStatementItemResponse:order_quantity / order_amount / settled_amount / outstanding_amount -->
<t-card style="margin-bottom: 16px;">
<template #header>
<span style="font-weight: 600;">客户-商品追溯(周期)</span>
</template>
<t-table :data="state.customerProductStatement" :loading="state.loading" stripe size="small">
<t-table-column prop="partner_name" label="客户" />
<t-table-column prop="product_sku" label="SKU" />
<t-table-column prop="product_name" label="商品" />
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="order_quantity" label="数量" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_amount) }}</template>
</t-table-column>
<t-table-column label="已结款">
<template #default="{ row }">{{ formatCurrency(row.settled_amount) }}</template>
</t-table-column>
<t-table-column label="未结款">
<template #default="{ row }">{{ formatCurrency(row.outstanding_amount) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.customerProductStatement.length === 0" description="暂无数据" />
</t-card>
<t-card style="margin-bottom: 16px;">
<template #header>
<span style="font-weight: 600;">供应商-商品追溯(周期)</span>
</template>
<t-table :data="state.supplierProductStatement" :loading="state.loading" stripe size="small">
<t-table-column prop="partner_name" label="供应商" />
<t-table-column prop="product_sku" label="SKU" />
<t-table-column prop="product_name" label="商品" />
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="order_quantity" label="数量" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_amount) }}</template>
</t-table-column>
<t-table-column label="已结款">
<template #default="{ row }">{{ formatCurrency(row.settled_amount) }}</template>
</t-table-column>
<t-table-column label="未结款">
<template #default="{ row }">{{ formatCurrency(row.outstanding_amount) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.supplierProductStatement.length === 0" description="暂无数据" />
</t-card>
<!-- 字段对齐 FinanceTransactionResponse:txn_no / txn_type / txn_date
TODO: 往来方列依赖 partner_name,后端 FinanceTransactionResponse 暂未返回,需补 -->
<t-card>
<template #header>
<span style="font-weight: 600;">最近财务流水</span>
</template>
<t-table :data="state.financeTransactions" :loading="state.loading" stripe size="small">
<t-table-column prop="txn_no" label="单号" />
<t-table-column label="类型">
<template #default="{ row }">
<t-tag :type="getTransactionTypeTag(row.txn_type)" size="small">
{{ getTransactionTypeLabel(row.txn_type) }}
</t-tag>
</template>
</t-table-column>
<t-table-column prop="partner_name" label="往来方" />
<t-table-column label="金额">
<template #default="{ row }">{{ formatCurrency(row.amount) }}</template>
</t-table-column>
<t-table-column label="状态">
<template #default="{ row }">
<t-tag :type="row.status === 'confirmed' ? 'success' : 'warning'" size="small">
{{ getTransactionStatusLabel(row.status) }}
</t-tag>
</template>
</t-table-column>
<t-table-column label="日期">
<template #default="{ row }">{{ formatDate(row.txn_date) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.financeTransactions.length === 0" description="暂无数据" />
</t-card>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useInventoryStore } from '@/stores/inventory'
const router = useRouter()
const store = useInventoryStore()
const { menuGroups, openGroups, toggleGroup } = store
const navigateTo = (itemKey: string) => {
store.activeTab = itemKey
router.push(`/inventory/${itemKey}`)
}
</script>
<template>
<nav class="sidebar-nav">
<div v-for="group in menuGroups" :key="group.key" class="sidebar-group">
<div class="sidebar-group-title" @click="toggleGroup(group.key)">
<span>{{ group.title }}</span>
<span class="group-arrow">{{ openGroups[group.key] ? '▾' : '▸' }}</span>
</div>
<template v-if="openGroups[group.key]">
<div
v-for="item in group.items"
:key="item.key"
:class="['sidebar-item', { active: store.activeTab === item.key }]"
@click="navigateTo(item.key)"
>
{{ item.label }}
</div>
</template>
</div>
</nav>
</template>
<style scoped>
.sidebar-nav {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: var(--space-2);
}
.sidebar-group {
margin-bottom: var(--space-2);
}
.sidebar-group:last-child {
margin-bottom: 0;
}
.sidebar-group-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: var(--font-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
cursor: pointer;
user-select: none;
border-radius: var(--radius-sm);
transition: background 0.15s;
}
.sidebar-group-title:hover {
background: var(--bg-tertiary);
}
.group-arrow {
font-size: 10px;
transition: transform 0.2s;
}
.sidebar-item {
display: flex;
align-items: center;
height: 36px;
padding: 0 var(--space-3) 0 var(--space-6);
margin: 1px var(--space-1);
font-size: var(--text-sm);
color: var(--text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
transition: all 0.15s;
}
.sidebar-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sidebar-item.active {
background: var(--primary-50);
color: var(--primary-600);
font-weight: var(--font-medium);
}
</style>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
const { state, loadInventory, ensureStockBaseData } = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const saving = ref(false)
const form = reactive({
product_id: null as number | null,
warehouse_id: null as number | null,
quantity: 0,
locked_quantity: 0,
batch_no: '',
location: ''
})
function openCreate() {
ensureStockBaseData()
editingItem.value = null
form.product_id = null
form.warehouse_id = null
form.quantity = 0
form.locked_quantity = 0
form.batch_no = ''
form.location = ''
showModal.value = true
}
function openEdit(item: any) {
ensureStockBaseData()
editingItem.value = item
form.product_id = item.product_id ?? null
form.warehouse_id = item.warehouse_id ?? null
form.quantity = item.quantity ?? 0
form.locked_quantity = item.locked_quantity ?? 0
form.batch_no = item.batch_no ?? ''
form.location = item.location ?? ''
showModal.value = true
}
async function save() {
if (saving.value) return
if (!form.product_id) {
addNotification('请选择物料', 'warning')
return
}
if (!form.warehouse_id) {
addNotification('请选择仓库', 'warning')
return
}
if (form.quantity < 0) {
addNotification('数量不能为负数', 'warning')
return
}
if (form.locked_quantity > form.quantity) {
addNotification('锁定数量不能大于库存数量', 'warning')
return
}
saving.value = true
try {
const payload = {
product_id: form.product_id,
warehouse_id: form.warehouse_id,
quantity: form.quantity,
locked_quantity: form.locked_quantity,
batch_no: form.batch_no || null,
location: form.location || null
}
if (editingItem.value) {
await apiRequest(`/api/inventory/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
addNotification('物料库存更新成功', 'success')
} else {
await apiRequest('/api/inventory', {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('物料库存已创建', 'success')
}
showModal.value = false
loadInventory()
} catch (e) {
handleApiError(e, '保存物料库存')
} finally {
saving.value = false
}
}
async function deleteItem(id: number) {
if (!confirm('确定要删除这个物料库存记录吗?')) return
try {
await apiRequest(`/api/inventory/${id}`, { method: 'DELETE' })
addNotification('物料库存已删除', 'success')
loadInventory()
} catch (e) {
handleApiError(e, '删除物料库存')
}
}
onMounted(() => { loadInventory() })
</script>
<template>
<div class="inventory-tab">
<div class="table-header">
<t-button type="primary" @click="openCreate">+ 新增物料库存</t-button>
</div>
<t-table :data="state.inventory" style="width: 100%" :loading="state.loading" empty-text="暂无库存数据">
<t-table-column prop="product_sku" label="SKU" />
<t-table-column prop="product_name" label="物料" />
<t-table-column prop="warehouse_name" label="仓库" />
<t-table-column prop="quantity" label="数量" />
<t-table-column prop="available_quantity" label="可用" />
<t-table-column label="操作" width="180">
<template #default="{ row }">
<t-button size="small" @click="openEdit(row)">编辑</t-button>
<t-button size="small" type="danger" @click="deleteItem(row.id)">删除</t-button>
</template>
</t-table-column>
</t-table>
<t-dialog v-model="showModal" :title="editingItem ? '编辑物料库存' : '新增物料库存'" width="520px" :close-on-click-modal="false">
<t-form :model="form" label-width="80px">
<t-form-item label="物料" required>
<t-select v-model="form.product_id" placeholder="请选择物料" style="width: 100%">
<t-option
v-for="m in state.materials"
:key="m.id"
:label="m.name + (m.sku ? ' (' + m.sku + ')' : '')"
:value="m.id"
/>
</t-select>
</t-form-item>
<t-form-item label="仓库" required>
<t-select v-model="form.warehouse_id" placeholder="请选择仓库" style="width: 100%">
<t-option
v-for="w in state.warehouses"
:key="w.id"
:label="w.name"
:value="w.id"
/>
</t-select>
</t-form-item>
<t-form-item label="数量" required>
<t-input-number v-model="form.quantity" :min="0" style="width: 100%" />
</t-form-item>
<t-form-item label="锁定数量">
<t-input-number v-model="form.locked_quantity" :min="0" style="width: 100%" />
</t-form-item>
<t-form-item label="批次号">
<t-input v-model="form.batch_no" placeholder="请输入批次号" />
</t-form-item>
<t-form-item label="库位">
<t-input v-model="form.location" placeholder="请输入库位" />
</t-form-item>
</t-form>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="save">保存</t-button>
</template>
</t-dialog>
</div>
</template>
<style scoped>
.inventory-tab {
padding: 0;
}
.table-header {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,326 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useInventory } from '../composables/useInventory'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
const {
state,
formatCurrency,
loadMaterials,
loadSuppliers,
loadPurchaseOrders
} = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const saving = ref(false)
const form = ref<any>({})
const showRestockModal = ref(false)
const restockItems = ref<any[]>([])
const restockSupplierId = ref<number | null>(null)
const restocking = ref(false)
function openCreateMaterial() {
editingItem.value = null
form.value = {
item_type: 'material',
unit: 'kg',
min_stock: 0,
max_stock: 1000,
cost_price: 0,
sale_price: 0,
sku: '',
name: '',
category: ''
}
showModal.value = true
}
async function editMaterial(product: any) {
editingItem.value = product
form.value = { ...product }
showModal.value = true
}
async function saveProduct() {
if (saving.value) return
if (!form.value.sku) {
addNotification('请输入SKU', 'warning')
return
}
if (!form.value.name) {
addNotification('请输入名称', 'warning')
return
}
saving.value = true
try {
if (editingItem.value) {
await apiRequest(`/api/products/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(form.value)
})
addNotification('物料更新成功', 'success')
} else {
await apiRequest('/api/products', {
method: 'POST',
body: JSON.stringify(form.value)
})
addNotification('物料创建成功', 'success')
}
showModal.value = false
editingItem.value = null
form.value = {}
loadMaterials()
} catch (e) {
handleApiError(e, '保存物料')
} finally {
saving.value = false
}
}
async function deleteMaterial(id: number) {
if (!confirm('确定要删除这个物料吗?')) return
try {
await apiRequest(`/api/products/${id}`, { method: 'DELETE' })
addNotification('物料已删除', 'success')
loadMaterials()
} catch (e) {
handleApiError(e, '删除物料')
}
}
async function openRestockModal() {
await loadMaterials()
await loadSuppliers()
restockItems.value = []
showRestockModal.value = true
}
function addRestockItem() {
restockItems.value = [...restockItems.value, {
material_id: state.materials[0]?.id || null,
quantity: 1,
unit_price: 0,
remark: ''
}]
}
function removeRestockItem(index: number) {
restockItems.value = restockItems.value.filter((_, i) => i !== index)
}
const restockTotalAmount = computed(() => {
return restockItems.value.reduce((sum: number, item: any) => {
return sum + (item.unit_price || 0) * (item.quantity || 0)
}, 0)
})
async function saveRestock() {
if (restocking.value) return
if (!restockSupplierId.value) {
addNotification('请选择供应商', 'warning')
return
}
if (!restockItems.value || restockItems.value.length === 0) {
addNotification('请至少添加一个补货物料', 'warning')
return
}
for (const [i, item] of restockItems.value.entries()) {
const idx = i + 1
if (!item.material_id) {
addNotification(`第 ${idx} 行:请选择物料`, 'warning')
return
}
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning')
return
}
if (!Number.isFinite(item.unit_price) || item.unit_price < 0) {
addNotification(`第 ${idx} 行:单价必须大于等于 0`, 'warning')
return
}
}
restocking.value = true
try {
const payload = {
supplier_id: restockSupplierId.value,
expected_date: new Date().toISOString().split('T')[0],
remark: '物料补货',
items: restockItems.value.map((item: any) => ({
product_id: item.material_id,
quantity: item.quantity,
unit_price: item.unit_price,
remark: item.remark
}))
}
await apiRequest('/api/purchase-orders', {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('采购订单创建成功', 'success')
showRestockModal.value = false
restockItems.value = []
restockSupplierId.value = null
loadPurchaseOrders()
} catch (e) {
handleApiError(e, '保存补货订单')
} finally {
restocking.value = false
}
}
function closeModal() {
showModal.value = false
editingItem.value = null
form.value = {}
}
function closeRestockModal() {
showRestockModal.value = false
restockItems.value = []
}
onMounted(() => { loadMaterials() })
</script>
<template>
<div>
<div style="margin-bottom: 12px; display: flex; gap: 8px;">
<t-button type="primary" @click="openCreateMaterial">新增物料</t-button>
<t-button type="success" @click="openRestockModal">物料补货</t-button>
</div>
<t-table :data="state.materials" :loading="state.loading" stripe>
<t-table-column prop="sku" label="SKU" />
<t-table-column prop="name" label="名称" />
<t-table-column prop="category" label="分类">
<template #default="{ row }">{{ row.category || '-' }}</template>
</t-table-column>
<t-table-column prop="unit" label="单位" />
<t-table-column label="成本价">
<template #default="{ row }">{{ formatCurrency(row.cost_price) }}</template>
</t-table-column>
<t-table-column prop="min_stock" label="最低库存">
<template #default="{ row }">{{ row.min_stock ?? '-' }}</template>
</t-table-column>
<t-table-column label="操作" width="180">
<template #default="{ row }">
<t-button type="primary" size="small" @click="editMaterial(row)">编辑</t-button>
<t-button type="danger" size="small" @click="deleteMaterial(row.id)">删除</t-button>
</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.materials.length === 0" description="暂无物料数据" />
<t-dialog
v-model:visible="showModal"
:title="(editingItem ? '编辑' : '新增') + '物料'"
width="560px"
@closed="closeModal"
>
<t-form label-width="100px">
<t-form-item label="类型">
<t-input model-value="物料(纳入库存)" disabled />
</t-form-item>
<t-form-item label="SKU" required>
<t-input v-model="form.sku" placeholder="产品编码" />
</t-form-item>
<t-form-item label="名称" required>
<t-input v-model="form.name" placeholder="产品名称" />
</t-form-item>
<t-form-item label="分类">
<t-input v-model="form.category" placeholder="产品分类" />
</t-form-item>
<t-form-item label="单位">
<t-input v-model="form.unit" placeholder="kg" />
</t-form-item>
<t-form-item label="成本价">
<t-input-number v-model="form.cost_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
</t-form-item>
<t-form-item label="最低库存">
<t-input-number v-model="form.min_stock" :min="0" style="width:100%" />
</t-form-item>
<t-form-item label="最高库存">
<t-input-number v-model="form.max_stock" :min="0" style="width:100%" />
</t-form-item>
</t-form>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="saveProduct">保存</t-button>
</template>
</t-dialog>
<t-dialog
v-model:visible="showRestockModal"
title="物料补货"
width="900px"
@closed="closeRestockModal"
>
<t-form label-width="100px" style="margin-bottom: 16px;">
<t-form-item label="供应商" required>
<t-select v-model="restockSupplierId" placeholder="请选择供应商" style="width:100%">
<t-option
v-for="supplier in state.suppliers"
:key="supplier.id"
:label="supplier.name"
:value="supplier.id"
/>
</t-select>
</t-form-item>
</t-form>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: 600;">补货物料明细</span>
<t-button type="primary" size="small" @click="addRestockItem">+ 添加物料</t-button>
</div>
<t-table :data="restockItems" border size="small">
<t-table-column label="物料" min-width="200">
<template #default="{ row: line }">
<t-select v-model="line.material_id" placeholder="请选择物料" style="width:100%">
<t-option
v-for="material in state.materials"
:key="material.id"
:label="`${material.sku} - ${material.name}`"
:value="material.id"
/>
</t-select>
</template>
</t-table-column>
<t-table-column label="数量" width="120">
<template #default="{ row: line }">
<t-input-number v-model="line.quantity" :min="1" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="单价" width="120">
<template #default="{ row: line }">
<t-input-number v-model="line.unit_price" :min="0" :precision="2" :step="0.01" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="总价" width="120">
<template #default="{ row: line }">
{{ formatCurrency((line.unit_price || 0) * (line.quantity || 0)) }}
</template>
</t-table-column>
<t-table-column label="备注" width="150">
<template #default="{ row: line }">
<t-input v-model="line.remark" size="small" placeholder="备注" />
</template>
</t-table-column>
<t-table-column label="操作" width="80">
<template #default="{ $index }">
<t-button type="danger" size="small" @click="removeRestockItem($index)">删除</t-button>
</template>
</t-table-column>
</t-table>
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
补货总金额:{{ formatCurrency(restockTotalAmount) }}
</div>
<template #footer>
<t-button @click="showRestockModal = false" :disabled="restocking">取消</t-button>
<t-button type="primary" :loading="restocking" :disabled="restocking" @click="saveRestock">创建采购订单</t-button>
</template>
</t-dialog>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useInventory } from '@/modules/inventory/composables/useInventory'
const { state, loadMovements, getMovementTypeLabel, getMovementBadgeClass, formatDateTime } = useInventory()
const getMovementTagType = (movementType: string): 'success' | 'danger' | 'warning' | 'info' | '' => {
const badgeClass = getMovementBadgeClass(movementType)
if (badgeClass === 'badge-success') return 'success'
if (badgeClass === 'badge-error') return 'danger'
if (badgeClass === 'badge-warning') return 'warning'
return 'info'
}
onMounted(() => { loadMovements() })
</script>
<template>
<div class="movements-tab">
<t-table :data="state.movements" style="width: 100%" :loading="state.loading" empty-text="暂无库存变动记录">
<t-table-column label="物料">
<template #default="{ row }">
{{ row.product_name }}{{ row.product_sku ? ' (' + row.product_sku + ')' : '' }}
</template>
</t-table-column>
<t-table-column label="类型" width="140">
<template #default="{ row }">
<t-tag :type="getMovementTagType(row.movement_type)" size="small">
{{ getMovementTypeLabel(row.movement_type) }}
</t-tag>
</template>
</t-table-column>
<t-table-column prop="quantity" label="数量" width="80" />
<t-table-column prop="before_quantity" label="变动前" width="80" />
<t-table-column prop="after_quantity" label="变动后" width="80" />
<t-table-column label="时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</t-table-column>
</t-table>
</div>
</template>
<style scoped>
.movements-tab {
padding: 0;
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useInventory } from '../composables/useInventory'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
const {
state,
formatCurrency,
loadFinishedProducts,
loadMaterials
} = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const saving = ref(false)
const form = ref<any>({})
const bomItems = ref<any[]>([])
function openCreateProduct() {
editingItem.value = null
form.value = {
item_type: 'finished',
unit: '件',
min_stock: 0,
max_stock: 1000,
cost_price: 0,
sale_price: 0,
sku: '',
name: '',
category: ''
}
bomItems.value = []
showModal.value = true
}
async function editProduct(product: any) {
editingItem.value = product
form.value = { ...product }
await loadMaterials()
try {
const bom = await apiRequest(`/api/products/${product.id}/materials`)
bomItems.value = (bom.items || []).map((bomItem: any) => ({
material_id: bomItem.material_id,
quantity: bomItem.quantity
}))
} catch (e) {
handleApiError(e, '加载BOM')
}
showModal.value = true
}
function addBomItem() {
bomItems.value = [...bomItems.value, {
material_id: state.materials[0]?.id || null,
quantity: 1
}]
}
function removeBomItem(index: number) {
bomItems.value = bomItems.value.filter((_, i) => i !== index)
}
async function saveProduct() {
if (saving.value) return
if (!form.value.sku) {
addNotification('请输入SKU', 'warning')
return
}
if (!form.value.name) {
addNotification('请输入名称', 'warning')
return
}
saving.value = true
try {
if (editingItem.value) {
await apiRequest(`/api/products/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(form.value)
})
if (form.value.item_type === 'finished' && bomItems.value.length > 0) {
await apiRequest(`/api/products/${editingItem.value.id}/materials`, {
method: 'PUT',
body: JSON.stringify({ items: bomItems.value })
})
}
addNotification('成品更新成功', 'success')
} else {
await apiRequest('/api/products', {
method: 'POST',
body: JSON.stringify(form.value)
})
addNotification('成品创建成功', 'success')
}
showModal.value = false
editingItem.value = null
form.value = {}
bomItems.value = []
loadFinishedProducts()
loadMaterials()
} catch (e) {
handleApiError(e, '保存成品')
} finally {
saving.value = false
}
}
async function deleteProduct(id: number) {
if (!confirm('确定要删除这个成品吗?')) return
try {
await apiRequest(`/api/products/${id}`, { method: 'DELETE' })
addNotification('成品已删除', 'success')
loadFinishedProducts()
loadMaterials()
} catch (e) {
handleApiError(e, '删除成品')
}
}
function closeModal() {
showModal.value = false
editingItem.value = null
form.value = {}
bomItems.value = []
}
onMounted(() => { loadFinishedProducts() })
</script>
<template>
<div>
<div style="margin-bottom: 12px;">
<t-button type="primary" @click="openCreateProduct">新增成品</t-button>
</div>
<t-table :data="state.finishedProducts" :loading="state.loading" stripe>
<t-table-column prop="sku" label="SKU" />
<t-table-column prop="name" label="名称" />
<t-table-column prop="category" label="分类">
<template #default="{ row }">{{ row.category || '-' }}</template>
</t-table-column>
<t-table-column prop="unit" label="单位" />
<t-table-column label="成本价">
<template #default="{ row }">{{ formatCurrency(row.cost_price) }}</template>
</t-table-column>
<t-table-column label="销售价">
<template #default="{ row }">{{ formatCurrency(row.sale_price) }}</template>
</t-table-column>
<t-table-column label="基础物料成本">
<template #default="{ row }">{{ formatCurrency(row.material_cost || 0) }}</template>
</t-table-column>
<t-table-column label="操作" width="180">
<template #default="{ row }">
<t-button type="primary" size="small" @click="editProduct(row)">编辑</t-button>
<t-button type="danger" size="small" @click="deleteProduct(row.id)">删除</t-button>
</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.finishedProducts.length === 0" description="暂无成品数据" />
<t-dialog
v-model:visible="showModal"
:title="(editingItem ? '编辑' : '新增') + '成品'"
width="560px"
@closed="closeModal"
>
<t-form label-width="100px">
<t-form-item label="类型">
<t-input model-value="成品(按单生产,不做库存)" disabled />
</t-form-item>
<t-form-item label="SKU" required>
<t-input v-model="form.sku" placeholder="产品编码" />
</t-form-item>
<t-form-item label="名称" required>
<t-input v-model="form.name" placeholder="产品名称" />
</t-form-item>
<t-form-item label="分类">
<t-input v-model="form.category" placeholder="产品分类" />
</t-form-item>
<t-form-item label="单位">
<t-input v-model="form.unit" placeholder="件" />
</t-form-item>
<t-form-item label="成本价">
<t-input-number v-model="form.cost_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
</t-form-item>
<t-form-item label="销售价">
<t-input-number v-model="form.sale_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
</t-form-item>
<t-form-item label="说明">
<t-input model-value="成品不做库存,成本由下方BOM定义物料构成后自动计算" disabled />
</t-form-item>
<template v-if="editingItem">
<t-divider content-position="left">BOM物料配置</t-divider>
<div style="padding: 0 12px 12px 12px;">
<t-button type="primary" size="small" @click="addBomItem" style="margin-bottom:8px">+ 添加物料</t-button>
<t-table :data="bomItems" size="small" border>
<t-table-column label="物料">
<template #default="{ row: bomItem }">
<t-select v-model="bomItem.material_id" placeholder="选择物料" style="width:100%">
<t-option
v-for="material in state.materials"
:key="material.id"
:label="`${material.sku} - ${material.name}`"
:value="material.id"
/>
</t-select>
</template>
</t-table-column>
<t-table-column label="数量" width="120">
<template #default="{ row: bomItem }">
<t-input-number v-model="bomItem.quantity" :min="0.0001" :step="0.0001" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="操作" width="80">
<template #default="{ $index }">
<t-button type="danger" size="small" @click="removeBomItem($index)">删除</t-button>
</template>
</t-table-column>
</t-table>
</div>
</template>
</t-form>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="saveProduct">保存</t-button>
</template>
</t-dialog>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,646 @@
<script setup lang="ts">
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) => {
const dlg = DialogPlugin.confirm({
header, body, theme: theme as any,
confirmBtn: confirmText, cancelBtn: cancelText,
onConfirm: () => { dlg.hide(); resolve(true) },
onCancel: () => { dlg.hide(); resolve(false) },
onClose: () => { resolve(false) }
})
})
}
const {
state,
formatCurrency,
formatDateTime,
formatDate,
getPurchaseOrderStatusLabel,
getReceiptStatusLabel,
getPaymentStatusLabel,
isPurchaseOrderLocked,
loadPurchaseOrders,
loadSuppliers,
loadMaterials,
loadInventory,
loadMovements
} = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const form = ref<any>({})
const orderItems = ref<any[]>([])
const purchaseWarehouseId = ref<number | null>(null)
const saving = ref(false)
const showReceiveModal = ref(false)
const receivingOrder = ref<any>(null)
const receiveItems = ref<any[]>([])
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 = {
supplier_id: null,
expected_date: '',
remark: ''
}
orderItems.value = []
showModal.value = true
}
function addOrderItem() {
orderItems.value = [...orderItems.value, {
product_id: state.materials[0]?.id || null,
quantity: 1,
remark: ''
}]
}
function removeOrderItem(index: number) {
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 = Number(material?.cost_price || 0)
return sum + unitPrice * Number(item.quantity || 0)
}, 0)
})
async function editOrder(order: any) {
await loadMaterials()
await loadSuppliers()
editingItem.value = order
form.value = {
supplier_id: order.supplier_id || null,
expected_date: order.expected_date || '',
remark: order.remark || ''
}
try {
const detail = await apiRequest(`/api/purchase-orders/${order.id}`)
orderItems.value = (detail.items || []).map((item: any) => ({
product_id: item.product_id,
quantity: item.quantity,
remark: item.remark || ''
}))
} catch (e) {
handleApiError(e, '加载采购订单详情')
}
showModal.value = true
}
async function savePurchaseOrder() {
if (saving.value) return
if (!form.value.supplier_id) {
addNotification('请选择供应商', 'warning')
return
}
if (!orderItems.value || orderItems.value.length === 0) {
addNotification('请至少添加一个物料', 'warning')
return
}
for (const [i, item] of orderItems.value.entries()) {
if (!item.product_id) {
addNotification(`第 ${i + 1} 行:请选择物料`, 'warning')
return
}
if (!item.quantity || item.quantity <= 0) {
addNotification(`第 ${i + 1} 行:数量必须大于 0`, 'warning')
return
}
}
saving.value = true
try {
const payload = {
supplier_id: form.value.supplier_id,
expected_date: form.value.expected_date || null,
remark: form.value.remark || '',
items: orderItems.value.map((item: any) => ({
product_id: item.product_id,
quantity: item.quantity,
remark: item.remark
}))
}
if (editingItem.value) {
await apiRequest(`/api/purchase-orders/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
addNotification('采购订单更新成功', 'success')
} else {
await apiRequest('/api/purchase-orders', {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('采购订单创建成功', 'success')
}
showModal.value = false
editingItem.value = null
form.value = {}
orderItems.value = []
loadPurchaseOrders()
} catch (e) {
handleApiError(e, '保存采购订单')
} finally {
saving.value = false
}
}
async function deleteOrder(id: number) {
if (!await confirmDialog('删除确认', '确定要删除这个采购订单吗?', 'warning', '删除', '取消')) return
try {
await apiRequest(`/api/purchase-orders/${id}`, { method: 'DELETE' })
addNotification('采购订单已删除', 'success')
loadPurchaseOrders()
} catch (e) {
handleApiError(e, '删除采购订单')
}
}
async function openReceiveDialog(order: any) {
try {
const detail = await apiRequest(`/api/purchase-orders/${order.id}`)
receivingOrder.value = order
receiveItems.value = (detail.items || []).map((item: any) => ({
id: item.id,
material_label: item.product_name || item.product_sku || '',
quantity: item.quantity || 0,
received_quantity: item.received_quantity || 0,
remaining: (item.quantity || 0) - (item.received_quantity || 0),
receive_quantity: 0
}))
receiveWarehouseId.value = purchaseWarehouseId.value || state.warehouses[0]?.id || null
receiveRemark.value = ''
showReceiveModal.value = true
} catch (e) {
handleApiError(e, '加载采购订单详情')
}
}
async function receivePurchaseOrder() {
if (receiving.value) return
if (!receiveWarehouseId.value) {
addNotification('请选择入库仓库', 'warning')
return
}
const items = receiveItems.value
.filter((item: any) => item.receive_quantity > 0)
.map((item: any) => ({
item_id: item.id,
receive_quantity: item.receive_quantity
}))
if (items.length === 0) {
addNotification('请输入本次入库数量', 'warning')
return
}
receiving.value = true
try {
await apiRequest(`/api/purchase-orders/${receivingOrder.value.id}/receive`, {
method: 'POST',
body: JSON.stringify({
warehouse_id: receiveWarehouseId.value,
remark: receiveRemark.value,
items
})
})
addNotification('入库完成', 'success')
showReceiveModal.value = false
receivingOrder.value = null
receiveItems.value = []
loadPurchaseOrders()
loadInventory()
loadMovements()
} catch (e) {
handleApiError(e, '采购入库')
} finally {
receiving.value = false
}
}
async function markAsPaid(orderId: number, receiptStatus: string) {
if (receiptStatus !== 'received') {
if (!await confirmDialog('非标准流程提醒', '该订单尚未全部收货,确定要提前付款吗?建议先完成收货后再付款。', 'warning', '仍要付款', '取消')) return
} else {
if (!await confirmDialog('付款确认', '确认已向供应商支付该订单款项?', 'success', '确认付款', '取消')) return
}
await doUpdateStatus(orderId, 'paid', '已付款')
}
async function cancelOrder(orderId: number) {
if (!await confirmDialog('作废确认', '确定要作废该订单吗?此操作不可撤销。', 'error', '确认作废', '取消')) return
await doUpdateStatus(orderId, 'cancelled', '已作废')
}
async function doUpdateStatus(orderId: number, status: string, label: string) {
try {
await apiRequest(`/api/purchase-orders/${orderId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
})
addNotification(`订单已${label}`, 'success')
loadPurchaseOrders()
loadMovements()
loadInventory()
} catch (e) {
handleApiError(e, '更新状态')
}
}
function closeModal() {
showModal.value = false
editingItem.value = null
form.value = {}
orderItems.value = []
}
function closeReceiveModal() {
showReceiveModal.value = false
receivingOrder.value = null
receiveItems.value = []
}
async function initData() {
await loadMaterials()
await loadSuppliers()
await loadPurchaseOrders()
}
onMounted(() => {
initData()
})
</script>
<template>
<div>
<div style="margin-bottom: 12px; display: flex; gap: 12px; align-items: center;">
<t-select
v-model="purchaseWarehouseId"
placeholder="选择仓库"
style="width: 200px;"
clearable
>
<t-option
v-for="warehouse in state.warehouses"
:key="warehouse.id"
:label="warehouse.name"
:value="warehouse.id"
/>
</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>
<t-table-column prop="order_no" label="采购单" />
<t-table-column prop="supplier_name" label="供应商" />
<t-table-column label="状态">
<template #default="{ row }">
<div style="display: flex; gap: 4px;">
<t-tag
:type="row.receipt_status === 'received' ? 'success' : row.receipt_status === 'partial_received' ? 'warning' : row.receipt_status === 'cancelled' ? 'danger' : 'info'"
size="small"
>{{ getReceiptStatusLabel(row.receipt_status) }}</t-tag>
<t-tag
:type="row.payment_status === 'paid' ? 'success' : 'info'"
size="small"
>{{ getPaymentStatusLabel(row.payment_status) }}</t-tag>
</div>
</template>
</t-table-column>
<t-table-column label="订单创建">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</t-table-column>
<t-table-column label="预计到货">
<template #default="{ row }">{{ formatDate(row.expected_date) }}</template>
</t-table-column>
<t-table-column label="实际到货">
<template #default="{ row }">{{ row.received_date ? formatDateTime(row.received_date) : '-' }}</template>
</t-table-column>
<t-table-column label="实际付款">
<template #default="{ row }">{{ row.paid_date ? formatDateTime(row.paid_date) : '-' }}</template>
</t-table-column>
<t-table-column label="总金额">
<template #default="{ row }">{{ formatCurrency(row.total_amount) }}</template>
</t-table-column>
<t-table-column label="已付款">
<template #default="{ row }">{{ formatCurrency(row.paid_amount || 0) }}</template>
</t-table-column>
<t-table-column label="操作" width="280">
<template #default="{ row }">
<t-button
type="primary"
size="small"
:disabled="isPurchaseOrderLocked(row.status)"
@click="editOrder(row)"
>
编辑
</t-button>
<t-button
type="danger"
size="small"
:disabled="isPurchaseOrderLocked(row.status)"
@click="deleteOrder(row.id)"
>
删除
</t-button>
<t-button
v-if="row.receipt_status === 'pending' || row.receipt_status === 'partial_received'"
type="success"
size="small"
@click="openReceiveDialog(row)"
>
到货入库
</t-button>
<t-button
v-if="row.payment_status !== 'paid' && row.receipt_status !== 'cancelled'"
type="warning"
size="small"
@click="markAsPaid(row.id, row.receipt_status)"
>
标记已付款
</t-button>
<t-button
v-if="row.payment_status !== 'paid' && row.receipt_status !== 'cancelled'"
type="info"
size="small"
@click="cancelOrder(row.id)"
>
作废
</t-button>
</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.purchaseOrders.length === 0" description="暂无采购订单" />
<t-dialog
v-model:visible="showModal"
:title="(editingItem ? '编辑' : '新增') + '采购订单'"
width="800px"
@closed="closeModal"
>
<t-form label-width="100px">
<t-form-item label="供应商" required>
<t-select v-model="form.supplier_id" placeholder="请选择供应商" style="width:100%">
<t-option
v-for="supplier in state.suppliers"
:key="supplier.id"
:label="supplier.name"
:value="supplier.id"
/>
</t-select>
</t-form-item>
<t-form-item label="预计到货">
<t-date-picker
v-model="form.expected_date"
type="date"
placeholder="选择日期"
style="width:100%"
value-format="YYYY-MM-DD"
/>
</t-form-item>
<t-form-item label="备注">
<t-input v-model="form.remark" placeholder="备注信息" />
</t-form-item>
</t-form>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: 600;">物料明细</span>
<t-button type="primary" size="small" @click="addOrderItem">+ 添加物料</t-button>
</div>
<t-table :data="orderItems" border size="small">
<t-table-column label="物料" min-width="200">
<template #default="{ row: line }">
<t-select v-model="line.product_id" placeholder="请选择物料" style="width:100%">
<t-option
v-for="material in state.materials"
:key="material.id"
:label="`${material.sku} - ${material.name}`"
:value="material.id"
/>
</t-select>
</template>
</t-table-column>
<t-table-column label="数量" width="120">
<template #default="{ row: line }">
<t-input-number v-model="line.quantity" :min="0.0001" :step="0.0001" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="单价" width="120">
<template #default="{ row: line }">
<t-input-number
:model-value="(state.materials.find((m: any) => m.id === line.product_id)?.cost_price || 0)"
disabled
size="small"
style="width:100%"
/>
</template>
</t-table-column>
<t-table-column label="总价" width="120">
<template #default="{ row: line }">
{{ 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">
<template #default="{ row: line }">
<t-input v-model="line.remark" size="small" placeholder="备注" />
</template>
</t-table-column>
<t-table-column label="操作" width="80">
<template #default="{ $index }">
<t-button type="danger" size="small" @click="removeOrderItem($index)">删除</t-button>
</template>
</t-table-column>
</t-table>
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
订单总金额:{{ formatCurrency(orderTotalAmount) }}
</div>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="savePurchaseOrder">保存</t-button>
</template>
</t-dialog>
<t-dialog
v-model:visible="showReceiveModal"
title="采购到货入库"
width="700px"
@closed="closeReceiveModal"
>
<t-form label-width="100px">
<t-form-item label="入库仓库" required>
<t-select v-model="receiveWarehouseId" placeholder="请选择仓库" style="width:100%">
<t-option
v-for="warehouse in state.warehouses"
:key="warehouse.id"
:label="warehouse.name"
:value="warehouse.id"
/>
</t-select>
</t-form-item>
<t-form-item label="备注">
<t-input v-model="receiveRemark" placeholder="入库备注" />
</t-form-item>
</t-form>
<t-table :data="receiveItems" border size="small">
<t-table-column prop="material_label" label="物料" />
<t-table-column prop="id" label="明细ID" width="80" />
<t-table-column label="剩余待入库" width="100">
<template #default="{ row: item }">{{ item.remaining }}</template>
</t-table-column>
<t-table-column label="本次入库" width="140">
<template #default="{ row: item }">
<t-input-number
v-model="item.receive_quantity"
:min="0"
:max="item.remaining"
size="small"
style="width:100%"
/>
</template>
</t-table-column>
</t-table>
<template #footer>
<t-button @click="showReceiveModal = false" :disabled="receiving">取消</t-button>
<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>
<style scoped>
</style>
@@ -0,0 +1,633 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
import { useInventory } from '../composables/useInventory'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
function confirmDialog(header: string, body: string, theme: string, confirmText: string, cancelText: string): Promise<boolean> {
return new Promise((resolve) => {
const dlg = DialogPlugin.confirm({
header, body, theme: theme as any,
confirmBtn: confirmText, cancelBtn: cancelText,
onConfirm: () => { dlg.hide(); resolve(true) },
onCancel: () => { dlg.hide(); resolve(false) },
onClose: () => { resolve(false) }
})
})
}
const {
state,
formatCurrency,
formatDateTime,
formatDate,
getSalesOrderStatusLabel,
getDeliveryStatusLabel,
getPaymentStatusLabel,
loadProductionOrders,
loadFinishedProducts,
loadCustomers,
loadMaterials,
loadInventory,
loadMovements
} = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const form = ref<any>({})
const moldItems = ref<any[]>([])
const productionWarehouseId = ref<number | null>(null)
const saving = ref(false)
const showConsumptionModal = ref(false)
const consumptionItems = ref<any[]>([])
const consumedMaterials = ref<any[]>([])
const consuming = ref(false)
// 删除守卫:已收款/已交付/已作废/有收款记录 的订单禁止删除(与后端一致)
function isSalesOrderDeleteLocked(row: any): boolean {
return row.payment_status === 'paid'
|| row.delivery_status === 'delivered'
|| row.delivery_status === 'cancelled'
|| (Number(row.received_amount || 0) > 0)
}
function openCreateOrder() {
editingItem.value = null
form.value = {
customer_id: null,
delivery_date: '',
remark: ''
}
moldItems.value = []
consumedMaterials.value = []
showModal.value = true
}
function addMoldItem() {
moldItems.value = [...moldItems.value, {
mold_mode: 'new',
mold_sku: '',
mold_name: '',
mold_id: null,
quantity: 1,
unit_price: 0,
remark: ''
}]
}
function removeMoldItem(index: number) {
moldItems.value = moldItems.value.filter((_, i) => i !== index)
}
const orderTotalAmount = computed(() => {
return moldItems.value.reduce((sum: number, item: any) => {
return sum + (item.unit_price || 0) * (item.quantity || 0)
}, 0)
})
async function editOrder(order: any) {
await loadCustomers()
await loadFinishedProducts()
await loadMaterials()
try {
const detail = await apiRequest(`/api/sales-orders/${order.id}`)
editingItem.value = order
form.value = {
customer_id: detail.customer_id || order.customer_id || null,
delivery_date: detail.delivery_date || order.delivery_date || '',
remark: detail.remark || order.remark || ''
}
moldItems.value = (detail.items || []).map((item: any) => ({
mold_mode: item.mold_id ? 'existing' : 'new',
mold_sku: item.sku || '',
mold_name: item.name || '',
mold_id: item.mold_id || null,
quantity: item.quantity || 1,
unit_price: item.unit_price || 0,
remark: item.remark || ''
}))
try {
const consumptionData = await apiRequest(`/api/sales-orders/${order.id}/consume-materials`)
consumedMaterials.value = (consumptionData.items || []).map((item: any) => ({
material_id: item.material_id,
material_name: item.material_name || item.material_sku || '',
quantity: item.quantity,
unit_price: item.unit_price || 0,
remark: item.remark || ''
}))
} catch {
consumedMaterials.value = []
}
} catch (e) {
handleApiError(e, '加载销售订单详情')
}
showModal.value = true
}
async function saveSalesOrder() {
if (saving.value) return
if (!form.value.customer_id) {
addNotification('请选择客户', 'warning')
return
}
if (!moldItems.value || moldItems.value.length === 0) {
addNotification('请至少添加一个模具', 'warning')
return
}
for (const [i, item] of moldItems.value.entries()) {
const idx = i + 1
if (item.mold_mode === 'new') {
if (!item.mold_sku || !String(item.mold_sku).trim()) {
addNotification(`第 ${idx} 行:新模请填写模具SKU`, 'warning')
return
}
if (!item.mold_name || !String(item.mold_name).trim()) {
addNotification(`第 ${idx} 行:新模请填写模具名称`, 'warning')
return
}
} else {
if (!item.mold_id) {
addNotification(`第 ${idx} 行:改模请选择已有模具`, 'warning')
return
}
}
if (!item.quantity || item.quantity <= 0) {
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning')
return
}
if (!item.unit_price || item.unit_price < 0) {
addNotification(`第 ${idx} 行:单价不能为负`, 'warning')
return
}
}
saving.value = true
try {
const payload = {
customer_id: form.value.customer_id,
delivery_date: form.value.delivery_date || null,
remark: form.value.remark || '',
items: moldItems.value.map((item: any) => ({
mold_mode: item.mold_mode,
mold_sku: item.mold_sku || '',
mold_name: item.mold_name || '',
mold_id: item.mold_id || null,
quantity: item.quantity,
unit_price: item.unit_price || 0,
remark: item.remark || ''
}))
}
if (editingItem.value) {
await apiRequest(`/api/sales-orders/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
addNotification('销售订单更新成功', 'success')
} else {
await apiRequest('/api/sales-orders', {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('销售订单创建成功', 'success')
}
showModal.value = false
editingItem.value = null
form.value = {}
moldItems.value = []
consumedMaterials.value = []
loadProductionOrders()
loadFinishedProducts()
loadInventory()
loadMovements()
} catch (e) {
handleApiError(e, '保存销售订单')
} finally {
saving.value = false
}
}
async function deleteOrder(row: any) {
if (isSalesOrderDeleteLocked(row)) {
addNotification('已收款/已交付/已作废或有收款记录的订单不可删除,请先作废相关收款单', 'warning')
return
}
if (!await confirmDialog('删除确认', '确定要删除这个销售订单吗?删除将自动回补已扣减的物料。', 'warning', '删除', '取消')) return
try {
await apiRequest(`/api/sales-orders/${row.id}`, { method: 'DELETE' })
addNotification('销售订单已删除', 'success')
loadProductionOrders()
loadInventory()
loadMovements()
} catch (e) {
handleApiError(e, '删除销售订单')
}
}
async function markDelivered(orderId: number) {
if (!await confirmDialog('交付确认', '确认该订单已交付?', 'success', '确认交付', '取消')) return
await doUpdateStatus(orderId, 'delivered', '已交付')
}
async function markPaid(orderId: number, deliveryStatus: string) {
if (deliveryStatus !== 'delivered') {
if (!await confirmDialog('非标准流程提醒', '当前订单尚未交付,确定要提前收款吗?建议先确认交付后再收款。', 'warning', '仍要收款', '取消')) return
} else {
if (!await confirmDialog('收款确认', '确认已收到该订单款项?', 'success', '确认收款', '取消')) return
}
await doUpdateStatus(orderId, 'paid', '已收款')
}
async function cancelOrder(orderId: number) {
if (!await confirmDialog('作废确认', '确定要作废该订单吗?此操作不可撤销。', 'error', '确认作废', '取消')) return
await doUpdateStatus(orderId, 'cancelled', '已作废')
}
async function doUpdateStatus(orderId: number, status: string, label: string) {
try {
await apiRequest(`/api/sales-orders/${orderId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
})
addNotification(`订单已${label}`, 'success')
loadProductionOrders()
loadMovements()
} catch (e) {
handleApiError(e, '更新状态')
}
}
function openConsumptionModal() {
consumptionItems.value = []
showConsumptionModal.value = true
}
function addConsumptionItem() {
consumptionItems.value = [...consumptionItems.value, {
material_id: state.materials[0]?.id || null,
quantity: 1,
remark: ''
}]
}
function removeConsumptionItem(index: number) {
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 = Number(material?.cost_price || 0)
return sum + unitPrice * Number(item.quantity || 0)
}, 0)
})
async function saveConsumption() {
if (consuming.value) return
if (!editingItem.value) return
if (!consumptionItems.value || consumptionItems.value.length === 0) {
addNotification('请至少添加一个消耗物料', 'warning')
return
}
for (const [i, item] of consumptionItems.value.entries()) {
const idx = i + 1
if (!item.material_id) {
addNotification(`第 ${idx} 行:请选择物料`, 'warning')
return
}
if (!item.quantity || item.quantity <= 0) {
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning')
return
}
}
consuming.value = true
try {
const payload = {
items: consumptionItems.value.map((item: any) => ({
material_id: item.material_id,
quantity: item.quantity,
unit_price: state.materials.find((m: any) => m.id === item.material_id)?.cost_price || 0,
remark: item.remark || ''
}))
}
await apiRequest(`/api/sales-orders/${editingItem.value.id}/consume-materials`, {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('物料消耗记录保存成功', 'success')
showConsumptionModal.value = false
try {
const consumptionData = await apiRequest(`/api/sales-orders/${editingItem.value.id}/consume-materials`)
consumedMaterials.value = (consumptionData.items || []).map((item: any) => ({
material_id: item.material_id,
material_name: item.material_name || item.material_sku || '',
quantity: item.quantity,
unit_price: item.unit_price || 0,
remark: item.remark || ''
}))
} catch {
consumedMaterials.value = []
}
loadInventory()
loadMovements()
} catch (e) {
handleApiError(e, '保存物料消耗')
} finally {
consuming.value = false
}
}
function closeModal() {
showModal.value = false
editingItem.value = null
form.value = {}
moldItems.value = []
consumedMaterials.value = []
}
function closeConsumptionModal() {
showConsumptionModal.value = false
consumptionItems.value = []
}
async function initData() {
await loadCustomers()
await loadMaterials()
await loadFinishedProducts()
await loadProductionOrders()
}
onMounted(() => {
initData()
})
</script>
<template>
<div>
<div style="margin-bottom: 12px; display: flex; gap: 12px; align-items: center;">
<t-select
v-model="productionWarehouseId"
placeholder="选择仓库"
style="width: 200px;"
clearable
>
<t-option
v-for="warehouse in state.warehouses"
:key="warehouse.id"
:label="warehouse.name"
:value="warehouse.id"
/>
</t-select>
<t-button @click="loadProductionOrders">刷新</t-button>
<t-button type="primary" @click="openCreateOrder">新增销售订单</t-button>
</div>
<t-table :data="state.productionOrders" :loading="state.loading" stripe>
<t-table-column prop="order_no" label="销售单" />
<t-table-column prop="customer_name" label="客户" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.total_amount) }}</template>
</t-table-column>
<t-table-column label="交付日期">
<template #default="{ row }">{{ formatDate(row.delivery_date) }}</template>
</t-table-column>
<t-table-column label="订单创建">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</t-table-column>
<t-table-column label="实际交付">
<template #default="{ row }">{{ row.delivered_at ? formatDateTime(row.delivered_at) : '-' }}</template>
</t-table-column>
<t-table-column label="实际收款">
<template #default="{ row }">{{ row.paid_at ? formatDateTime(row.paid_at) : '-' }}</template>
</t-table-column>
<t-table-column label="订单状态" width="160">
<template #default="{ row }">
<div style="display: flex; gap: 4px;">
<t-tag
:type="row.delivery_status === 'delivered' ? 'warning' : row.delivery_status === 'cancelled' ? 'danger' : 'info'"
size="small"
>{{ getDeliveryStatusLabel(row.delivery_status) }}</t-tag>
<t-tag
:type="row.payment_status === 'paid' ? 'success' : 'info'"
size="small"
>{{ getPaymentStatusLabel(row.payment_status) }}</t-tag>
</div>
</template>
</t-table-column>
<t-table-column label="操作" width="280">
<template #default="{ row }">
<t-button
v-if="row.delivery_status === 'manufacturing'"
type="success" size="small"
@click="markDelivered(row.id)"
>已交付</t-button>
<t-button
v-if="row.payment_status !== 'paid' && row.delivery_status !== 'cancelled'"
type="warning" size="small"
@click="markPaid(row.id, row.delivery_status)"
>已收款</t-button>
<t-button
type="primary" size="small"
:disabled="row.payment_status === 'paid' || row.delivery_status === 'cancelled'"
@click="editOrder(row)"
>编辑</t-button>
<t-button type="danger" size="small" :disabled="isSalesOrderDeleteLocked(row)" @click="deleteOrder(row)">删除</t-button>
<t-button
v-if="row.payment_status !== 'paid' && row.delivery_status !== 'cancelled'"
type="info" size="small"
@click="cancelOrder(row.id)"
>作废</t-button>
</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.productionOrders.length === 0" description="暂无销售订单" />
<t-dialog
v-model:visible="showModal"
:title="(editingItem ? '编辑' : '新增') + '销售订单'"
width="900px"
@closed="closeModal"
>
<t-form label-width="100px">
<t-form-item label="客户" required>
<t-select v-model="form.customer_id" placeholder="请选择客户" style="width:100%">
<t-option
v-for="customer in state.customers"
:key="customer.id"
:label="customer.name"
:value="customer.id"
/>
</t-select>
</t-form-item>
<t-form-item label="交付日期">
<t-date-picker
v-model="form.delivery_date"
type="date"
placeholder="选择日期"
style="width:100%"
value-format="YYYY-MM-DD"
/>
</t-form-item>
<t-form-item label="备注">
<t-input v-model="form.remark" placeholder="备注信息" />
</t-form-item>
</t-form>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: 600;">模具明细</span>
<div style="display: flex; gap: 8px;">
<t-button v-if="editingItem" type="warning" size="small" @click="openConsumptionModal">+ 消耗物料</t-button>
<t-button type="primary" size="small" @click="addMoldItem">+ 添加模具</t-button>
</div>
</div>
<t-table :data="moldItems" border size="small">
<t-table-column label="模式" width="80">
<template #default="{ row: line }">
<t-select v-model="line.mold_mode" size="small" style="width:100%">
<t-option label="新模" value="new" />
<t-option label="改模" value="existing" />
</t-select>
</template>
</t-table-column>
<t-table-column label="模具SKU" min-width="120">
<template #default="{ row: line }">
<t-input v-if="line.mold_mode === 'new'" v-model="line.mold_sku" size="small" placeholder="模具SKU" />
<t-select v-else v-model="line.mold_id" placeholder="选择模具" size="small" style="width:100%">
<t-option
v-for="product in state.finishedProducts"
:key="product.id"
:label="`${product.sku} - ${product.name}`"
:value="product.id"
/>
</t-select>
</template>
</t-table-column>
<t-table-column label="模具名称" min-width="120">
<template #default="{ row: line }">
<t-input
v-if="line.mold_mode === 'new'"
v-model="line.mold_name"
size="small"
placeholder="模具名称"
/>
<span v-else style="padding-left: 8px;">
{{ state.finishedProducts.find((p: any) => p.id === line.mold_id)?.name || '-' }}
</span>
</template>
</t-table-column>
<t-table-column label="数量" width="100">
<template #default="{ row: line }">
<t-input-number v-model="line.quantity" :min="1" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="单价" width="120">
<template #default="{ row: line }">
<t-input-number v-model="line.unit_price" :min="0" :precision="2" :step="0.01" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="备注" width="120">
<template #default="{ row: line }">
<t-input v-model="line.remark" size="small" placeholder="备注" />
</template>
</t-table-column>
<t-table-column label="操作" width="80">
<template #default="{ $index }">
<t-button type="danger" size="small" @click="removeMoldItem($index)">删除</t-button>
</template>
</t-table-column>
</t-table>
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
订单总金额:{{ formatCurrency(orderTotalAmount) }}
</div>
<template v-if="editingItem && consumedMaterials.length > 0">
<t-divider content-position="left">已消耗物料</t-divider>
<t-table :data="consumedMaterials" border size="small">
<t-table-column label="物料">
<template #default="{ row: item }">{{ item.material_name }}</template>
</t-table-column>
<t-table-column label="数量">
<template #default="{ row: item }">{{ item.quantity }}</template>
</t-table-column>
<t-table-column label="单价">
<template #default="{ row: item }">{{ formatCurrency(item.unit_price) }}</template>
</t-table-column>
<t-table-column label="备注">
<template #default="{ row: item }">{{ item.remark || '-' }}</template>
</t-table-column>
</t-table>
</template>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="saveSalesOrder">保存</t-button>
</template>
</t-dialog>
<t-dialog
v-model:visible="showConsumptionModal"
title="物料消耗"
width="800px"
@closed="closeConsumptionModal"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: 600;">消耗物料明细</span>
<t-button type="primary" size="small" @click="addConsumptionItem">+ 添加物料</t-button>
</div>
<t-table :data="consumptionItems" border size="small">
<t-table-column label="物料" min-width="200">
<template #default="{ row: line }">
<t-select v-model="line.material_id" placeholder="请选择物料" style="width:100%">
<t-option
v-for="material in state.materials"
:key="material.id"
:label="`${material.sku} - ${material.name}`"
:value="material.id"
/>
</t-select>
</template>
</t-table-column>
<t-table-column label="数量" width="120">
<template #default="{ row: line }">
<t-input-number v-model="line.quantity" :min="0.0001" :step="0.0001" size="small" style="width:100%" />
</template>
</t-table-column>
<t-table-column label="单价" width="120">
<template #default="{ row: line }">
<t-input-number
:model-value="state.materials.find((m: any) => m.id === line.material_id)?.cost_price || 0"
disabled
size="small"
style="width:100%"
/>
</template>
</t-table-column>
<t-table-column label="总价" width="120">
<template #default="{ row: line }">
{{ 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">
<template #default="{ row: line }">
<t-input v-model="line.remark" size="small" placeholder="备注" />
</template>
</t-table-column>
<t-table-column label="操作" width="80">
<template #default="{ $index }">
<t-button type="danger" size="small" @click="removeConsumptionItem($index)">删除</t-button>
</template>
</t-table-column>
</t-table>
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
消耗总金额:{{ formatCurrency(consumptionTotalAmount) }}
</div>
<template #footer>
<t-button @click="showConsumptionModal = false" :disabled="consuming">取消</t-button>
<t-button type="primary" :loading="consuming" :disabled="consuming" @click="saveConsumption">保存消耗</t-button>
</template>
</t-dialog>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
const { state, loadSuppliers } = useInventory()
const showModal = ref(false)
const editingItem = ref<any>(null)
const saving = ref(false)
const form = reactive({
name: '',
contact_person: '',
phone: '',
email: '',
address: ''
})
function openCreateSupplier() {
editingItem.value = null
form.name = ''
form.contact_person = ''
form.phone = ''
form.email = ''
form.address = ''
showModal.value = true
}
function editSupplier(item: any) {
editingItem.value = item
form.name = item.name ?? ''
form.contact_person = item.contact_person ?? ''
form.phone = item.phone ?? ''
form.email = item.email ?? ''
form.address = item.address ?? ''
showModal.value = true
}
async function save() {
if (saving.value) return
if (!form.name || !form.name.trim()) {
addNotification('请输入供应商名称', 'warning')
return
}
saving.value = true
try {
const payload = {
name: form.name,
contact_person: form.contact_person || null,
phone: form.phone || null,
email: form.email || null,
address: form.address || null
}
if (editingItem.value) {
await apiRequest(`/api/suppliers/${editingItem.value.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
addNotification('供应商更新成功', 'success')
} else {
await apiRequest('/api/suppliers', {
method: 'POST',
body: JSON.stringify(payload)
})
addNotification('供应商已创建', 'success')
}
showModal.value = false
loadSuppliers()
} catch (e) {
handleApiError(e, '保存供应商')
} finally {
saving.value = false
}
}
async function deleteSupplier(id: number) {
if (!confirm('确定要删除这个供应商吗?')) return
try {
await apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' })
addNotification('供应商已删除', 'success')
loadSuppliers()
} catch (e) {
handleApiError(e, '删除供应商')
}
}
onMounted(() => { loadSuppliers() })
</script>
<template>
<div class="suppliers-tab">
<div class="table-header">
<t-button type="primary" @click="openCreateSupplier">+ 新增供应商</t-button>
</div>
<t-table :data="state.suppliers" style="width: 100%" :loading="state.loading" empty-text="暂无供应商数据">
<t-table-column prop="code" label="编码" />
<t-table-column prop="name" label="名称" />
<t-table-column prop="contact_person" label="联系人">
<template #default="{ row }">
{{ row.contact_person || '-' }}
</template>
</t-table-column>
<t-table-column prop="phone" label="电话">
<template #default="{ row }">
{{ row.phone || '-' }}
</template>
</t-table-column>
<t-table-column prop="email" label="邮箱">
<template #default="{ row }">
{{ row.email || '-' }}
</template>
</t-table-column>
<t-table-column label="操作" width="180">
<template #default="{ row }">
<t-button size="small" @click="editSupplier(row)">编辑</t-button>
<t-button size="small" type="danger" @click="deleteSupplier(row.id)">删除</t-button>
</template>
</t-table-column>
</t-table>
<t-dialog v-model="showModal" :title="editingItem ? '编辑供应商' : '新增供应商'" width="520px" :close-on-click-modal="false">
<t-form :model="form" label-width="80px">
<t-form-item label="名称" required>
<t-input v-model="form.name" placeholder="请输入供应商名称" />
</t-form-item>
<t-form-item label="联系人">
<t-input v-model="form.contact_person" placeholder="请输入联系人" />
</t-form-item>
<t-form-item label="电话">
<t-input v-model="form.phone" placeholder="请输入电话" />
</t-form-item>
<t-form-item label="邮箱">
<t-input v-model="form.email" type="email" placeholder="请输入邮箱" />
</t-form-item>
<t-form-item label="地址">
<t-input v-model="form.address" placeholder="请输入地址" />
</t-form-item>
</t-form>
<template #footer>
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
<t-button type="primary" :loading="saving" :disabled="saving" @click="save">保存</t-button>
</template>
</t-dialog>
</div>
</template>
<style scoped>
.suppliers-tab {
padding: 0;
}
.table-header {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,10 @@
/**
* 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() {
return useInventoryStore()
}
+67
View File
@@ -0,0 +1,67 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">G</div>
<h1 class="login-title">Gemold</h1>
<p class="login-subtitle">模具制造管理系统</p>
</div>
<t-form @submit="handleLogin">
<t-form-item label="用户名">
<t-input v-model="state.username" placeholder="请输入用户名" clearable />
</t-form-item>
<t-form-item label="密码">
<t-input v-model="state.password" type="password" placeholder="请输入密码" clearable />
</t-form-item>
<t-alert v-if="state.error" theme="error" :message="state.error" style="margin-bottom:16px" />
<t-button theme="primary" block type="submit" :loading="state.loading">
{{ state.loading ? '登录中...' : '登录' }}
</t-button>
</t-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { apiRequest } from '@/shared/api'
import { saveAuth } from '@/shared/auth'
import { addNotification } from '@/shared/notification'
const router = useRouter()
const state = reactive({
username: '',
password: '',
error: '',
loading: false,
})
const handleLogin = async () => {
state.error = ''
state.loading = true
try {
const data = await apiRequest<{ access_token: string; token_type: string; user: Record<string, unknown> }>('/api/auth/login/json', {
method: 'POST',
body: JSON.stringify({ username: state.username, password: state.password }),
})
saveAuth(data.access_token, data.user)
addNotification('登录成功', 'success')
router.push('/')
} catch (err: any) {
state.error = err.message || '登录失败'
} finally {
state.loading = false
}
}
</script>
<style scoped>
.login-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-secondary); padding: var(--space-6); }
.login-card { width: 100%; max-width: 400px; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: var(--radius-xl); padding: var(--space-8); }
.login-header { text-align: center; margin-bottom: var(--space-8); }
.login-logo { width: 48px; height: 48px; background: var(--primary-500); border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; color: white; font-size: var(--text-xl); font-weight: var(--font-bold); margin: 0 auto var(--space-4); }
.login-title { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--text-primary); margin-bottom: var(--space-2); }
.login-subtitle { font-size: var(--text-sm); color: var(--text-tertiary); }
</style>
@@ -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>
@@ -0,0 +1,488 @@
<template>
<div class="page-container">
<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">
<div class="intro-card">
<div class="intro-card-title">输入</div>
<div class="intro-card-text">STEP/STP 产品件,面向注塑模主流程</div>
</div>
<div class="intro-card">
<div class="intro-card-title">输出</div>
<div class="intro-card-text">分模方案、DFM 风险、注塑模系统摘要与 CAD 导出</div>
</div>
<div class="intro-card">
<div class="intro-card-title">目标</div>
<div class="intro-card-text">先确认推荐方案,再进入导出与 CAM 准备</div>
</div>
</div>
<div class="upload-layout">
<div class="upload-main-card">
<h2 class="section-title">1. 上传产品件</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"
@change="handleFileChange"
hidden
/>
<div class="upload-icon">📁</div>
<div class="upload-text">
<span class="upload-title">点击选择或拖拽 STP/STEP 文件</span>
<span class="upload-hint">支持注塑模产品件分析,最大 100MB</span>
</div>
</div>
<div v-if="state.selectedFile" class="file-info">
<span class="file-name">{{ state.selectedFile.name }}</span>
<span class="file-size">{{ formatFileSize(state.selectedFile.size) }}</span>
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button>
</div>
<div v-if="state.error" class="error-message">{{ state.error }}</div>
<div v-if="state.polling" class="progress-bar">
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
</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>
<details class="advanced-params">
<summary>高级工艺参数</summary>
<div class="advanced-params-body">
<div class="inline-note">默认值适用于多数注塑件;仅在已知工艺约束时再调整。</div>
<div class="advanced-param-grid">
<div class="param-input-card" v-for="field in moldParamFields" :key="field.key">
<t-form-item :label="field.label">
<t-input-number
v-model.number="state.moldParams[field.key as keyof typeof state.moldParams]"
:min="field.min"
:max="field.max"
:step="field.step"
:suffix="field.unit"
/>
<div class="param-meta-row">
<span class="range-value">默认 {{ field.defaultValue }}{{ field.unit }}</span>
<span class="range-value">范围 {{ field.min }} - {{ field.max }}{{ field.unit }}</span>
</div>
<div class="param-hint">{{ field.hint }}</div>
</t-form-item>
</div>
</div>
</div>
</details>
</div>
<t-button
v-if="state.selectedFile"
theme="primary"
class="upload-submit-btn"
@click="uploadFile"
:disabled="state.uploading || state.polling"
>
{{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '3. 开始注塑模分析' }}
</t-button>
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始分析。</div>
</div>
</div>
<details v-if="state.history?.files?.length" class="history-collapsible section">
<summary class="history-summary">分析历史({{ state.history.files.length }} 个文件)</summary>
<div class="table-container">
<t-table
row-key="filename"
:data="state.history.files"
:expanded-row-keys="historyExpandedKeys"
@expand-change="onHistoryExpand"
>
<t-table-column key="filename" title="文件名">
<template #cell="{ row }">{{ row.filename }}</template>
</t-table-column>
<t-table-column key="upload_count" title="上传次数">
<template #cell="{ row }">
<t-tag theme="primary" variant="light">{{ row.upload_count }} 次</t-tag>
</template>
</t-table-column>
<t-table-column key="file_size" title="文件大小">
<template #cell="{ row }">{{ formatFileSize(row.file_size) }}</template>
</t-table-column>
<t-table-column key="latest_status" title="最新状态">
<template #cell="{ row }">
<t-tag :theme="statusTagTheme(row.latest_status)" variant="light">{{ row.latest_status }}</t-tag>
</template>
</t-table-column>
<t-table-column key="latest_upload_time" title="最新分析时间">
<template #cell="{ row }">{{ formatDateTime(row.latest_upload_time) }}</template>
</t-table-column>
<t-table-column key="actions" title="操作">
<template #cell="{ row }">
<div class="action-buttons">
<t-button v-if="row.latest_status === 'completed'" theme="primary" size="small" @click="viewResult({ task_id: row.latest_task_id })">
查看最新
</t-button>
<t-button variant="text" size="small" @click="toggleFileHistory(row.filename)" :title="state.expandedFiles[row.filename] ? '收起' : '展开历史记录'">
<template #icon><span class="dropdown-icon" :class="{ expanded: state.expandedFiles[row.filename] }">▼</span></template>
</t-button>
</div>
</template>
</t-table-column>
<template #expandedRow="{ row }">
<div class="history-dropdown">
<t-table
v-if="state.expandedFiles[row.filename]"
row-key="id"
:data="state.expandedFiles[row.filename]"
>
<t-table-column key="upload_time" title="上传时间">
<template #cell="{ row: r }">{{ formatDateTime(r.upload_time) }}</template>
</t-table-column>
<t-table-column key="file_size" title="文件大小">
<template #cell="{ row: r }">{{ formatFileSize(r.file_size) }}</template>
</t-table-column>
<t-table-column key="status" title="状态">
<template #cell="{ row: r }">
<t-tag :theme="statusTagTheme(r.status)" variant="light">{{ r.status }}</t-tag>
</template>
</t-table-column>
<t-table-column key="volume" title="体积 (mm³)">
<template #cell="{ row: r }">{{ r.volume ? formatNumber(r.volume) : '-' }}</template>
</t-table-column>
<t-table-column key="surface_area" title="表面积 (mm²)">
<template #cell="{ row: r }">{{ r.surface_area ? formatNumber(r.surface_area) : '-' }}</template>
</t-table-column>
<t-table-column key="product_weight" title="重量 (g)">
<template #cell="{ row: r }">{{ r.product_weight ? r.product_weight.toFixed(2) : '-' }}</template>
</t-table-column>
<t-table-column key="detail_actions" title="操作">
<template #cell="{ row: r }">
<t-button v-if="r.has_analysis" theme="primary" size="small" @click="viewResult(r)">
查看详情
</t-button>
</template>
</t-table-column>
</t-table>
<div v-else class="inline-note">加载中...</div>
</div>
</template>
</t-table>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } 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, formatDateTime, formatNumber } from '@/shared/utils'
interface HistoryRecord {
id: number
upload_time: string
file_size: number
status: string
volume?: number
surface_area?: number
product_weight?: number
has_analysis?: boolean
task_id?: string
}
interface HistoryFile {
filename: string
upload_count: number
file_size: number
latest_status: string
latest_upload_time: string
latest_task_id?: string
}
interface HistoryData {
files: HistoryFile[]
}
interface MoldParams {
draftAngle: number
shrinkageRate: number
partingPrecision: number
cavityMatch: number
}
interface TaskInfo {
task_id: string
status: string
filename?: string
error?: string
}
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
const state = reactive({
selectedFile: null as File | null,
selectedMaterial: 'ABS',
moldParams: {
draftAngle: 2.0,
shrinkageRate: 0.5,
partingPrecision: 0.1,
cavityMatch: 95,
} as MoldParams,
uploading: false,
error: '',
currentTask: null as TaskInfo | null,
task: null as TaskInfo | null,
polling: false,
dragOver: false,
progress: 0,
history: null as HistoryData | null,
expandedFiles: {} as Record<string, HistoryRecord[] | null>,
})
const historyExpandedKeys = computed(() => {
return Object.keys(state.expandedFiles).filter(k => state.expandedFiles[k] !== null)
})
const statusTagTheme = (status: string): 'success' | 'warning' | 'danger' => {
if (status === 'completed') return 'success'
if (status === 'failed') return 'danger'
return 'warning'
}
const onHistoryExpand = async (expandedKeys: (string | number)[]) => {
// TDesign expand-change fires when keys change; we rely on toggleFileHistory instead
}
const loadHistory = async () => {
try {
state.history = await apiRequest<HistoryData>('/api/history')
} catch (e) {
console.error('加载历史记录失败:', e)
}
}
const toggleFileHistory = async (filename: string) => {
if (state.expandedFiles[filename]) {
state.expandedFiles[filename] = null
} else {
try {
const records = await apiRequest<HistoryRecord[]>(`/api/history/${encodeURIComponent(filename)}`)
state.expandedFiles[filename] = records
} catch (e) {
handleApiError(e, '加载文件历史')
}
}
}
const viewResult = (record: { task_id?: string }) => {
router.push(`/moldinsight/result/${record.task_id}`)
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
validateAndSelectFile(file)
}
const validateAndSelectFile = (file: File) => {
const lowerName = file.name.toLowerCase()
if (!lowerName.endsWith('.stp') && !lowerName.endsWith('.step')) {
state.error = '请选择 STP 或 STEP 格式文件'
state.selectedFile = null
return
}
if (file.size > 100 * 1024 * 1024) {
state.error = '文件大小不能超过 100MB'
state.selectedFile = null
return
}
state.error = ''
state.selectedFile = file
addNotification(`已选择文件: ${file.name}`, 'success')
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
state.dragOver = false
const files = event.dataTransfer?.files
if (files && files.length > 0) validateAndSelectFile(files[0])
}
const uploadFile = async () => {
if (!state.selectedFile) return
if (!appStore.token) {
state.error = '请先登录后再上传文件'
addNotification('请先登录', 'warning')
router.push('/login')
return
}
if (appStore.token === 'demo') {
state.error = '演示模式不支持文件上传,请使用完整账户登录'
addNotification('演示模式不支持上传', 'warning')
return
}
state.uploading = true
state.error = ''
state.progress = 0
const formData = new FormData()
formData.append('file', state.selectedFile)
formData.append('material', state.selectedMaterial)
formData.append('draft_angle', String(state.moldParams.draftAngle))
formData.append('shrinkage_rate', String(state.moldParams.shrinkageRate))
formData.append('parting_precision', String(state.moldParams.partingPrecision))
formData.append('cavity_match', String(state.moldParams.cavityMatch))
try {
const res = await fetch('/api/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${appStore.token}` },
body: formData,
})
if (res.status === 401) {
clearAuth()
state.error = '登录已过期,请重新登录'
addNotification('登录已过期,请重新登录', 'warning')
router.push('/login')
return
}
if (!res.ok) throw new Error(`上传失败: ${res.status}`)
const data = await res.json()
state.currentTask = { task_id: data.task_id, status: 'processing', filename: data.file_info?.filename }
addNotification('文件上传成功,开始分析...', 'success')
startPolling(data.task_id)
} catch (e) {
state.error = handleApiError(e, '文件上传')
} finally {
state.uploading = false
}
}
const startPolling = async (taskId: string) => {
state.polling = true
state.progress = 10
let pollCount = 0
const poll = async () => {
try {
pollCount++
state.progress = Math.min(90, 10 + pollCount * 0.5)
const task = await apiRequest<TaskInfo>(`/api/status/${taskId}`, { method: 'POST' })
state.currentTask = task
state.task = task
if (task.status === 'completed') {
state.polling = false
state.progress = 100
addNotification('分析完成', 'success')
loadHistory()
router.push(`/moldinsight/result/${taskId}`)
return
}
if (task.status === 'failed') {
state.polling = false
state.error = task.error || '分析失败'
addNotification('分析失败', 'error')
return
}
if (pollCount < 300) setTimeout(poll, 2000)
} catch (e) {
state.polling = false
state.error = handleApiError(e, '轮询状态')
}
}
poll()
}
const moldParamFields = [
{
key: 'draftAngle',
label: '拔模角',
unit: '°',
min: 1,
max: 10,
step: 0.5,
defaultValue: 2.0,
hint: '常规注塑件建议从 1.5° 到 3° 起步',
},
{
key: 'shrinkageRate',
label: '收缩率',
unit: '%',
min: 0.5,
max: 3.0,
step: 0.1,
defaultValue: 0.5,
hint: '按材料牌号校核,默认值用于首轮方案评估',
},
{
key: 'partingPrecision',
label: '分型精度',
unit: 'mm',
min: 0.01,
max: 1.0,
step: 0.01,
defaultValue: 0.1,
hint: '用于控制分型面拟合与边界容差',
},
{
key: 'cavityMatch',
label: '型腔匹配度',
unit: '%',
min: 80,
max: 100,
step: 1,
defaultValue: 95,
hint: '数值越高越偏向紧配合与严格封合',
},
]
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
loadHistory()
})
</script>
File diff suppressed because it is too large Load Diff
@@ -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>
+260
View File
@@ -0,0 +1,260 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1>用户管理</h1>
<p>管理系统用户和权限</p>
</div>
<t-button theme="primary" @click="openUserModal()">+ 添加用户</t-button>
</div>
<t-loading :loading="state.loading" size="large">
<t-table :data="state.users" row-key="id" stripe>
<t-table-column colKey="username" title="用户名" />
<t-table-column colKey="email" title="邮箱" />
<t-table-column colKey="full_name" title="姓名">
<template #cell="{ row }">{{ row.full_name || '-' }}</template>
</t-table-column>
<t-table-column colKey="is_active" title="状态">
<template #cell="{ row }">
<t-tag :theme="row.is_active ? 'success' : 'danger'">
{{ row.is_active ? '正常' : '禁用' }}
</t-tag>
</template>
</t-table-column>
<t-table-column colKey="roles" title="角色">
<template #cell="{ row }">
<t-tag v-for="role in row.roles" :key="role" theme="primary" variant="light" style="margin-right: 4px;">
{{ role }}
</t-tag>
</template>
</t-table-column>
<t-table-column colKey="created_at" title="注册时间">
<template #cell="{ row }">{{ formatDateTime(row.created_at) }}</template>
</t-table-column>
<t-table-column colKey="actions" title="操作">
<template #cell="{ row }">
<t-space :size="4">
<t-button theme="primary" size="small" @click="openUserModal(row)">编辑</t-button>
<t-button theme="warning" size="small" @click="resetPassword(row)">重置密码</t-button>
<t-button v-if="row.id !== storeUser?.id" theme="danger" size="small" @click="deleteUser(row)">删除</t-button>
</t-space>
</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.users.length === 0" description="暂无用户数据" />
</t-loading>
<t-dialog
v-model:visible="state.showUserModal"
:header="state.editingUser ? '编辑用户' : '添加用户'"
:close-on-overlay-click="true"
width="520px"
>
<t-form label-width="80px">
<t-form-item label="用户名">
<t-input v-model="state.userForm.username" :disabled="!!state.editingUser" />
</t-form-item>
<t-form-item label="邮箱">
<t-input v-model="state.userForm.email" type="email" />
</t-form-item>
<t-form-item v-if="!state.editingUser" label="密码">
<t-input v-model="state.userForm.password" type="password" />
</t-form-item>
<t-form-item label="姓名">
<t-input v-model="state.userForm.full_name" />
</t-form-item>
<t-form-item label="角色">
<div style="display: flex; flex-direction: column; gap: 8px;">
<label v-for="role in state.roles" :key="role.id" style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" :value="role.id" v-model="state.userForm.role_ids" />
{{ role.name }}
</label>
</div>
</t-form-item>
</t-form>
<template #footer>
<t-button theme="default" @click="state.showUserModal = false">取消</t-button>
<t-button theme="primary" @click="saveUser">保存</t-button>
</template>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { DialogPlugin } from 'tdesign-vue-next'
import { apiRequest } from '@/shared/api'
import { useAppStore } from '@/stores/app'
import { addNotification } from '@/shared/notification'
import { formatDateTime } from '@/shared/utils'
const router = useRouter()
const store = useAppStore()
const storeUser = store.user
interface UserItem {
id: number
username: string
email: string
full_name: string | null
is_active: boolean
roles: string[]
created_at: string
}
interface RoleItem {
id: number
code: string
name: string
}
const state = reactive({
users: [] as UserItem[],
roles: [] as RoleItem[],
loading: true,
showUserModal: false,
editingUser: null as UserItem | null,
userForm: {
username: '',
email: '',
password: '',
full_name: '',
role_ids: [] as number[],
},
})
function confirmDialog(header: string, body: string, theme: string, confirmText: string, cancelText: string): Promise<boolean> {
return new Promise((resolve) => {
const dlg = DialogPlugin.confirm({
header,
body,
theme: theme as any,
confirmBtn: confirmText,
cancelBtn: cancelText,
onConfirm: () => { dlg.hide(); resolve(true) },
onCancel: () => { dlg.hide(); resolve(false) },
onClose: () => { resolve(false) },
})
})
}
const loadUsers = async () => {
try {
state.users = await apiRequest<UserItem[]>('/api/auth/users')
} catch (e) {
const err = e as Error
addNotification(err.message || '加载用户列表失败', 'error')
} finally {
state.loading = false
}
}
const loadRoles = async () => {
try {
state.roles = await apiRequest<RoleItem[]>('/api/auth/roles')
} catch (e) {
const err = e as Error
addNotification(err.message || '加载角色列表失败', 'error')
}
}
const openUserModal = (user: UserItem | null = null) => {
state.editingUser = user
if (user) {
state.userForm = {
username: user.username,
email: user.email,
password: '',
full_name: user.full_name || '',
role_ids: user.roles
.map(r => {
const role = state.roles.find(role => role.code === r)
return role ? role.id : null
})
.filter(id => id !== null) as number[],
}
} else {
state.userForm = { username: '', email: '', password: '', full_name: '', role_ids: [] }
}
state.showUserModal = true
}
const saveUser = async () => {
if (!state.userForm.username || !state.userForm.email) {
addNotification('请填写用户名和邮箱', 'error')
return
}
if (!state.editingUser && !state.userForm.password) {
addNotification('请填写密码', 'error')
return
}
try {
if (state.editingUser) {
await apiRequest(`/api/auth/users/${(state.editingUser as UserItem).id}`, {
method: 'PUT',
body: JSON.stringify({
email: state.userForm.email,
full_name: state.userForm.full_name || null,
role_ids: state.userForm.role_ids,
}),
})
addNotification('用户更新成功', 'success')
} else {
await apiRequest('/api/auth/users', {
method: 'POST',
body: JSON.stringify(state.userForm),
})
addNotification('用户创建成功', 'success')
}
state.showUserModal = false
loadUsers()
} catch (e) {
const err = e as Error
addNotification(err.message || '保存用户失败', 'error')
}
}
const deleteUser = async (user: UserItem) => {
if (!await confirmDialog('删除确认', `确定要删除用户 ${user.username} 吗?`, 'warning', '删除', '取消')) return
try {
await apiRequest(`/api/auth/users/${user.id}`, { method: 'DELETE' })
addNotification('用户已删除', 'success')
loadUsers()
} catch (e) {
const err = e as Error
addNotification(err.message || '删除用户失败', 'error')
}
}
const resetPassword = async (user: UserItem) => {
const newPassword = prompt(`请输入 ${user.username} 的新密码:`)
if (!newPassword || newPassword.length < 6) {
addNotification('密码长度至少6位', 'error')
return
}
try {
await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
method: 'PUT',
body: JSON.stringify({ new_password: newPassword }),
})
addNotification('密码已重置', 'success')
} catch (e) {
const err = e as Error
addNotification(err.message || '重置密码失败', 'error')
}
}
onMounted(async () => {
if (!store.user?.is_superuser) {
router.push('/')
return
}
await loadRoles()
loadUsers()
})
</script>
+51
View File
@@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAppStore } from '@/stores/app'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/modules/home/HomeView.vue') },
{ 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'),
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') },
],
})
router.beforeEach((to, _from, next) => {
const publicPages = ['/login', '/_design-system', '/_release']
const authRequired = !publicPages.includes(to.path)
const store = useAppStore()
if (authRequired && !store.user) {
return next('/login')
}
if (to.path === '/login' && store.user) {
return next('/')
}
next()
})
export default router
+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),
})
},
}
+66
View File
@@ -0,0 +1,66 @@
import { useAppStore } from '@/stores/app'
async function parseErrorMessage(response: Response): Promise<string> {
try {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const body = await response.json().catch(() => null)
if (body?.detail) {
if (Array.isArray(body.detail)) {
const lines = body.detail
.map((e: { loc?: string[]; msg?: string }) => {
const loc = Array.isArray(e?.loc) ? e.loc.join('.') : ''
const msg = e?.msg ? String(e.msg) : '校验失败'
return loc ? `${loc}: ${msg}` : msg
})
.filter(Boolean)
return lines.length ? lines.join('\n') : '请求校验失败'
}
if (typeof body.detail === 'object') return JSON.stringify(body.detail)
return String(body.detail)
}
if (body?.message) return String(body.message)
return '请求失败'
}
const text = await response.text().catch(() => '')
const normalized = (text || '').trim()
if (!normalized) return '请求失败'
return normalized.length > 200 ? normalized.slice(0, 200) + '...' : normalized
} catch {
return '请求失败'
}
}
export async function apiRequest<T = any>(url: string, options: RequestInit = {}): Promise<T> {
const store = useAppStore()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
}
if (store.token) {
headers['Authorization'] = `Bearer ${store.token}`
}
const response = await fetch(url, { ...options, headers })
if (response.status === 401) {
// 登录接口返回 401 表示用户名/密码错误,不是 token 过期
const isLoginRequest = url.includes('/api/auth/login')
if (!isLoginRequest) {
store.user = null
store.token = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
const message = await parseErrorMessage(response)
throw new Error(isLoginRequest ? message : '登录已过期,请重新登录')
}
if (!response.ok) {
const message = await parseErrorMessage(response)
throw new Error(message)
}
return response.json()
}
+33
View File
@@ -0,0 +1,33 @@
import { useAppStore } from '@/stores/app'
export function saveAuth(token: string, user: Record<string, unknown>): void {
const store = useAppStore()
store.token = token
store.user = user as any
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
}
export function clearAuth(): void {
const store = useAppStore()
store.token = null
store.user = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
export function initAuth(): void {
const store = useAppStore()
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
if (token && userStr) {
try {
store.token = token
store.user = JSON.parse(userStr)
} catch {
clearAuth()
}
}
store.initialized = true
}

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