Compare commits
37 Commits
4537faf2c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf65ceed1c | |||
| f87cd8a68e | |||
| eb0a4005e1 | |||
| 73de137779 | |||
| aaaac95b53 | |||
| 4a5dc61a02 | |||
| 021bf311c1 | |||
| f1d6a78f8a | |||
| 9e52c95400 | |||
| 0c69d4c347 | |||
| 62fd22cb31 | |||
| d8d4deffd1 | |||
| 65b33609d6 | |||
| 3615902e8d | |||
| f0110df164 | |||
| d7f92f1816 | |||
| a889671bb5 | |||
| aaf887507f | |||
| b934b737e8 | |||
| cf465d28e2 | |||
| e65dcc2d39 | |||
| 0dfb3c63b1 | |||
| 8f8a7ea00a | |||
| b03431b511 | |||
| 7717c9fa14 | |||
| 2fd1b3da21 | |||
| 4373fafd55 | |||
| a548623ea5 | |||
| 6baa6b0d0a | |||
| 483f158424 | |||
| 505f3591ab | |||
| 2c9ba9d6b3 | |||
| 79441a8a87 | |||
| 64dc85bd14 | |||
| c51e6b793a | |||
| e728dcd226 | |||
| 0e6b3b1811 |
+2
-1
@@ -53,7 +53,8 @@ tmp/
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
deploy/
|
||||
# 注意:deploy/ 不能排除——Dockerfile.moldinsight COPY deploy/requirements-*.txt、
|
||||
# Dockerfile.frontend COPY deploy/nginx/frontend.conf,排除会让干净机器首次构建必挂
|
||||
|
||||
# 其他
|
||||
.trae/
|
||||
|
||||
@@ -7,7 +7,13 @@ HOST=0.0.0.0
|
||||
MOLDINSIGHT_PORT=10003
|
||||
# inventory API 对外端口
|
||||
INVENTORY_PORT=10004
|
||||
# 应用内部监听端口(通常无需修改)
|
||||
# unified 模式下前端 Nginx 对外端口(浏览器入口)
|
||||
FRONTEND_PORT=10003
|
||||
# unified 模式 backend 是否暴露宿主端口:留空 = 不暴露(仅经前端 /api 反代)
|
||||
BACKEND_PORT=
|
||||
# 应用内部监听端口(通常无需修改;uvicorn 命令硬编码 8000/8001)
|
||||
# PORT 与 HOST 仅在直跑 uvicorn 时生效,compose 容器内 uvicorn 不读这两个 env
|
||||
HOST=0.0.0.0
|
||||
PORT=10003
|
||||
# ================================
|
||||
|
||||
@@ -25,15 +31,16 @@ POINTCLOUD_SAMPLE_COUNT=10000
|
||||
MESH_QUALITY=high
|
||||
PARALLEL_PROCESSING=true
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=szcjw
|
||||
# 数据库配置——以部署机实测可达 IP 为准(之前 192.168.3.10 → 192.168.0.11),
|
||||
# 改 IP 后必须重启 backend 才生效
|
||||
DB_HOST=192.168.0.11
|
||||
DB_PORT=5432
|
||||
DB_NAME=moldinsight
|
||||
DB_USER=moldinsight
|
||||
DB_PASSWORD=Qqs1996
|
||||
|
||||
# RustFS 对象存储配置 (S3v4 API)
|
||||
RUSTFS_ENDPOINT=http://szcjw:8010
|
||||
RUSTFS_ENDPOINT=http://192.168.0.11:8010
|
||||
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
|
||||
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
|
||||
RUSTFS_TIMEOUT=30
|
||||
@@ -44,8 +51,8 @@ SECRET_KEY=vGLxbDGj4I3LnWZYQqRrchlVBSWpM73IZ8fT7ldwpsXpDYB82ghkr7sRkO7D-BiR
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=szcjw
|
||||
# Redis配置(与 DB 同 IP)
|
||||
REDIS_HOST=192.168.0.11
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Qqs1996
|
||||
REDIS_DB=0
|
||||
|
||||
+15
-15
@@ -1,22 +1,17 @@
|
||||
# 服务配置
|
||||
HOST=0.0.0.0
|
||||
|
||||
# ================================
|
||||
# 模块 API 对外端口
|
||||
# 端口配置 — DEPLOYMENT.md §1.2 选择 A 为默认(前端独占宿主端口,backend 不暴露)
|
||||
# ================================
|
||||
# 前端 Nginx 对外端口
|
||||
FRONTEND_PORT=80
|
||||
# unified backend 对外端口
|
||||
BACKEND_PORT=8000
|
||||
# gemold(moldinsight)API 对外端口(独立部署时使用)
|
||||
MOLDINSIGHT_PORT=8000
|
||||
# inventory API 对外端口(独立部署时使用)
|
||||
INVENTORY_PORT=8001
|
||||
# 容器内部端口统一 8000(frontend=8000 / backend=8000 / inventory=8001);
|
||||
# 服务间通过 docker 网络 gemold_network 上的服务名(如 backend:8000)互通。
|
||||
# 宿主机端口由本文件强制配置,compose 无默认值兜底:
|
||||
FRONTEND_PORT=10003 # 浏览器入口(必填,缺则 compose 启动期 fail-fast)
|
||||
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署(必填)
|
||||
INVENTORY_PORT=10004 # inventory-only 独立部署(必填)
|
||||
# unified 模式 backend 是否暴露宿主端口:留空 = 不暴露(仅经前端 /api 反代),
|
||||
# 设值(如 10005)= 直接暴露(调试 / 压测用,注意 10003 已被 frontend 占用)
|
||||
BACKEND_PORT=
|
||||
# ================================
|
||||
|
||||
# 应用内部监听端口(通常无需修改;compose 内已固定为 8000/8001)
|
||||
PORT=8000
|
||||
|
||||
DEBUG=false
|
||||
|
||||
# 日志配置
|
||||
@@ -88,3 +83,8 @@ LLM_API_KEY=sk-your-api-key
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
LLM_TIMEOUT=60
|
||||
LLM_MAX_TOKENS=2000
|
||||
|
||||
# Celery/OCC 吞吐调优(可选,默认值在 compose 的 ${CELERY_CONCURRENCY:-2};
|
||||
# concurrency 即 OCC 并行分析数,见 docs/topics/performance/OCC_THROUGHPUT.md)
|
||||
# CELERY_CONCURRENCY=2
|
||||
# CELERY_MAX_TASKS_PER_CHILD=50
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -28,27 +28,34 @@
|
||||
- **单数据库是刻意设计**:moldinsight 与 inventory 共享同一 PostgreSQL(如 `STPFile.product_id -> Product.id` 桥接),不拆库。
|
||||
- **接口变更三件套**:优先用 Pydantic 请求模型(少用手写 `request.json()` 解析)→ 重新导出根目录 `openapi.json` → 前端 `npm run gen:api` 重新生成类型。三步缺一即契约漂移。
|
||||
- **历史材料统一进 [docs/archive/](docs/archive/README.md)**,不与当前权威文档混放。
|
||||
- **历史批次详细流水账 / 早段 STATUS**:见 [docs/archive/2026-09_governance_batches.md](docs/archive/2026-09_governance_batches.md) 与 [docs/archive/2026-09_status_history.md](docs/archive/2026-09_status_history.md);主骨架权威文档(TECH_DEBT §2 / STATUS 顶部)只保留摘要。
|
||||
- **配置只走 `.env`**(参照 [.env.example](.env.example) 全键说明):`DB_*`、`SECRET_KEY` 等关键项不设代码兜底(惰性校验,缺失即报),不在代码里给 localhost/弱口令默认值。
|
||||
|
||||
## 3. 代码地图
|
||||
|
||||
```
|
||||
src/
|
||||
entrypoints/ # 独立部署入口(均为 create_app 组装,含 sys.path 修正)
|
||||
entrypoints/ # 独立部署入口(纯组装:sys.path 修正 + create_app + startup_hooks/register_routers)
|
||||
moldinsight.py # moldinsight-only 入口:/api 前缀挂 moldinsight router,端口 8000
|
||||
inventory.py # inventory-only 入口:inventory_router,端口 8001
|
||||
unified.py # 双模块统一入口:/api 挂 moldinsight + inventory,当前推荐后端
|
||||
moldinsight/ # 【模具分析模块】
|
||||
api/
|
||||
__init__.py # router 聚合:_safe_include 按序挂子 router,失败仅 WARNING 跳过;debug_router 仅 settings.DEBUG 挂载
|
||||
health_router.py # /api/health 模块健康检查
|
||||
__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 加工方案
|
||||
aluminum_price_routes.py # /api/aluminum-price/* 铝价(当前为模拟/参考数据,见 TECH_DEBT D2)
|
||||
advanced_router.py # 导出 / 成本估算 / 设计分析等高级接口(技术债 D1:待拆分 + 请求模型化)
|
||||
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 解析
|
||||
@@ -66,48 +73,55 @@ src/
|
||||
cavity_layout_optimizer.py # 型腔布局优化
|
||||
mold_machining.py / mold_cam.py # 加工与 CAM
|
||||
mold_quality_inspector.py # 质量检查
|
||||
cad_exporter.py # CAD 导出
|
||||
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 # 分析处理编排
|
||||
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 验证(可选)
|
||||
shape_loader.py # shape 加载(含 OCC 超时后 executor 重建逻辑)
|
||||
stp_materializer.py # 按 task_id 把 STP 原件落盘临时文件(OCC 解析在子进程内,形状不跨进程)
|
||||
material_service.py # 物料价格服务
|
||||
aluminum_price_service.py # 铝价服务(模拟数据)
|
||||
llm_service.py # LLM 增强分析(可选,OpenAI 兼容)
|
||||
storage_integration_rustfs.py # RustFS 存储集成
|
||||
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 # 存储初始化
|
||||
init_storage.py # RustFS 连接初始化 + rustfs_startup_hook(入口经 startup_hooks 注入)
|
||||
inventory/ # 【进销存模块】
|
||||
api/ # 每域一个 routes 文件:product / supplier / customer / warehouse / inventory / stock_movement / purchase_order / sales_order / purchase_demand / finance / dashboard / material
|
||||
schemas/ # 每域一个 Pydantic schema 文件(与 api 一一对应)
|
||||
services/ # 领域服务:inventory / purchase_order / sales_order / finance / purchase_demand / stock_movement
|
||||
services/ # 领域服务:inventory / master_data / material / product / purchase_order / sales_order / finance / purchase_demand / stock_movement / dashboard
|
||||
models/ # inventory 域 ORM(catalog / warehouse / trading / finance 四文件,共 15 表)
|
||||
utils.py
|
||||
shared/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】
|
||||
app_factory.py # create_app:request_id 日志中间件 / auth_router / /health / SPA fallback / /html mount
|
||||
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/database.py # 全量 ORM(identity + moldinsight + inventory 三类同居一处——当前最强耦合点,见 ARCHITECTURE §6)
|
||||
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 静态分析报告生成
|
||||
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 脚本
|
||||
deploy/ # Dockerfile.* / nginx / build 脚本 / generate_lockfiles.{sh,bat}(D13 锁文件生成入口)
|
||||
docs/ # 权威文档(本文件 §5 导航)
|
||||
```
|
||||
|
||||
|
||||
@@ -63,12 +63,21 @@ npm install
|
||||
推荐先查看部署入口:
|
||||
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
|
||||
|
||||
本地常见方式:
|
||||
本地常见方式(按模式对应不同 compose 文件):
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
# 默认:unified(前端 + 后端 + Celery)
|
||||
docker compose up -d
|
||||
|
||||
# 仅模具分析
|
||||
docker compose -f docker-compose.moldinsight.yml up -d
|
||||
|
||||
# 仅进销存
|
||||
docker compose -f docker-compose.inventory.yml up -d
|
||||
```
|
||||
|
||||
> 镜像首次构建:`bash deploy/build.sh`(build base → backend → frontend 3 个 tag,celery 复用 backend);更新代码后用 `docker compose up -d --build` 重建(裸 `up -d` 不会重建已有镜像)。
|
||||
|
||||
如需直接运行:
|
||||
|
||||
```bash
|
||||
@@ -170,9 +179,15 @@ geMoldInsight/
|
||||
|
||||
## 当前代码入口
|
||||
|
||||
- unified: [src/entrypoints/unified.py](src/entrypoints/unified.py)
|
||||
- moldinsight-only: [src/entrypoints/moldinsight.py](src/entrypoints/moldinsight.py)
|
||||
- inventory-only: [src/entrypoints/inventory.py](src/entrypoints/inventory.py)
|
||||
- 当前 Compose 入口: [docker-compose.yml](docker-compose.yml)
|
||||
|
||||
当前 Compose 入口(一键命令对应文件名):
|
||||
|
||||
- unified: [docker-compose.yml](docker-compose.yml) → `docker compose up -d`
|
||||
- moldinsight-only: [docker-compose.moldinsight.yml](docker-compose.moldinsight.yml) → `docker compose -f docker-compose.moldinsight.yml up -d`
|
||||
- inventory-only: [docker-compose.inventory.yml](docker-compose.inventory.yml) → `docker compose -f docker-compose.inventory.yml up -d`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM gemold-moldinsight:latest
|
||||
|
||||
CMD ["celery", "-A", "celery_app", "worker", "--workdir=/app/src", "--concurrency=2", "--loglevel=info"]
|
||||
@@ -7,7 +7,8 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
# 注意:容器内 nginx listen 改为 8000(避免占用宿主 80;1024+ 无 root 限制)
|
||||
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
# 旧方式(conda 环境装好后把 site-packages 拷入 python:slim 系统 python)依赖
|
||||
# 两侧 Python ABI 恰好兼容,属脆弱做法(TECH_DEBT D13);现改为直接以同一
|
||||
# conda 运行时作为最终镜像的执行环境,自带全部动态库。
|
||||
FROM continuumio/miniconda3:24.7.1-0
|
||||
# Base 用 Miniforge:conda-forge 默认且唯一渠道(无 defaults 渠道,无 Anaconda
|
||||
# ToS 顾虑),与 CI 的 Miniforge 安装(.gitea/workflows/ci.yml)同源;tag 锁定。
|
||||
FROM condaforge/miniforge3:24.7.1-2
|
||||
|
||||
# 锁定几何栈核心版本;pip 侧全量版本锁待首次镜像构建成功后由
|
||||
# `pip freeze > deploy/requirements-moldinsight.lock.txt` 生成(D13 遗留项)
|
||||
# 锁定几何栈核心版本;pip 侧全量版本锁由
|
||||
# `bash deploy/generate_lockfiles.sh` 在 moldinsight conda 环境内执行后生成
|
||||
# (D13,参见 deploy/generate_lockfiles.sh / .bat 与 docs/OPERATIONS.md §2)
|
||||
# 落盘产物为 deploy/requirements-{base,moldinsight}.lock.txt,CI / 离线构建可直接锁定安装
|
||||
RUN conda create -n moldinsight -c conda-forge -y \
|
||||
python=3.12 \
|
||||
pythonocc-core=7.9.0 \
|
||||
@@ -34,7 +38,6 @@ COPY migrations/ /app/migrations/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
|
||||
COPY uploads/ /app/uploads/
|
||||
COPY html_output/ /app/html_output/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
|
||||
+5
-18
@@ -11,11 +11,6 @@ echo.
|
||||
echo === 构建统一后端镜像 ===
|
||||
docker build -t gemold-backend:latest -f deploy\Dockerfile.moldinsight .
|
||||
|
||||
echo.
|
||||
echo === 构建 Celery Worker 镜像 ===
|
||||
docker build -t gemold-celery:latest -f deploy\Dockerfile.celery .
|
||||
|
||||
echo.
|
||||
echo.
|
||||
echo === 构建前端镜像 (Nginx 静态站点) ===
|
||||
docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
|
||||
@@ -23,17 +18,9 @@ docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
|
||||
echo.
|
||||
echo === 全部构建完成 ===
|
||||
echo.
|
||||
echo 启动完整系统(前端 + unified backend + celery):
|
||||
echo docker compose --profile full up -d
|
||||
echo 启动 unified 默认栈(前端 + backend + celery):
|
||||
echo docker compose up -d
|
||||
echo.
|
||||
echo 仅启动 unified backend:
|
||||
echo docker compose --profile unified up -d
|
||||
echo.
|
||||
echo 仅启动前端入口:
|
||||
echo docker compose --profile frontend up -d
|
||||
echo.
|
||||
echo 仅启动进销存:
|
||||
echo docker compose --profile inventory up -d
|
||||
echo.
|
||||
echo 仅启动模具分析:
|
||||
echo docker compose --profile moldinsight up -d
|
||||
echo 按文件名切换模式(旧 --profile 写法已失效):
|
||||
echo docker compose -f docker-compose.moldinsight.yml up -d
|
||||
echo docker compose -f docker-compose.inventory.yml up -d
|
||||
|
||||
+5
-17
@@ -13,10 +13,6 @@ echo ""
|
||||
echo "=== 构建统一后端镜像 ==="
|
||||
docker build -t gemold-backend:latest -f deploy/Dockerfile.moldinsight .
|
||||
|
||||
echo ""
|
||||
echo "=== 构建 Celery Worker 镜像 ==="
|
||||
docker build -t gemold-celery:latest -f deploy/Dockerfile.celery .
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "=== 构建前端镜像 (Nginx 静态站点) ==="
|
||||
@@ -25,17 +21,9 @@ docker build -t gemold-frontend:latest -f deploy/Dockerfile.frontend .
|
||||
echo ""
|
||||
echo "=== 全部构建完成 ==="
|
||||
echo ""
|
||||
echo "启动完整系统(前端 + unified backend + celery):"
|
||||
echo " docker compose --profile full up -d"
|
||||
echo "启动 unified 默认栈(前端 + backend + celery):"
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo "仅启动 unified backend:"
|
||||
echo " docker compose --profile unified up -d"
|
||||
echo ""
|
||||
echo "仅启动前端入口:"
|
||||
echo " docker compose --profile frontend up -d"
|
||||
echo ""
|
||||
echo "仅启动进销存:"
|
||||
echo " docker compose --profile inventory up -d"
|
||||
echo ""
|
||||
echo "仅启动模具分析:"
|
||||
echo " docker compose --profile moldinsight up -d"
|
||||
echo "按文件名切换模式(旧 --profile 写法已失效):"
|
||||
echo " docker compose -f docker-compose.moldinsight.yml up -d"
|
||||
echo " docker compose -f docker-compose.inventory.yml up -d"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13,Windows 版)
|
||||
REM ============================================================
|
||||
REM
|
||||
REM 用法:在 gemold conda 环境内执行 deploy\generate_lockfiles.bat
|
||||
REM
|
||||
REM 产物:
|
||||
REM deploy\requirements-base.lock.txt
|
||||
REM deploy\requirements-moldinsight.lock.txt
|
||||
REM ============================================================
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
where conda >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] conda 未安装或不在 PATH,请先激活 conda 环境 ^(推荐 moldinsight^) 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not "%CONDA_DEFAULT_ENV%"=="moldinsight" if not "%CONDA_DEFAULT_ENV%"=="gemold" (
|
||||
echo [WARN] 当前 conda 环境为 '%CONDA_DEFAULT_ENV%',推荐在 'moldinsight' 内执行
|
||||
)
|
||||
|
||||
echo ==^> 生成 base 锁文件
|
||||
pip freeze --exclude pythonocc-core > deploy\requirements-base.lock.txt
|
||||
|
||||
echo ==^> 生成 moldinsight 锁文件
|
||||
pip freeze --exclude pythonocc-core > deploy\requirements-moldinsight.lock.txt
|
||||
|
||||
echo.
|
||||
echo 已生成:
|
||||
echo deploy\requirements-base.lock.txt
|
||||
echo deploy\requirements-moldinsight.lock.txt
|
||||
echo.
|
||||
echo 下一步:提交两个 lock.txt,并按团队策略同步更新 requirements-*.txt 下限。
|
||||
endlocal
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# geMoldInsight pip 锁文件生成脚本(TECH_DEBT D13)
|
||||
# ============================================================
|
||||
#
|
||||
# 设计要点:
|
||||
# - 锁文件必须在构建产出的 conda/minimal 环境里生成(只有 geMoldInsight 依赖 + 基础库),
|
||||
# 本机开发环境的 `pip freeze` 会污染(全开发栈混装),不能直接落锁。
|
||||
# - 仅在带 moldinsight 的 conda 环境内运行 `pip freeze > deploy/requirements-<x>.lock.txt`
|
||||
# 才有意义。
|
||||
# - 锁文件落盘后即可被 CI / 离线构建 / 复现部署直接 `pip install -r` 锁定版本,
|
||||
# 而不再依赖 >= 下限解析。
|
||||
#
|
||||
# 用法(必须在 `gemold` conda 环境内执行):
|
||||
# bash deploy/generate_lockfiles.sh
|
||||
#
|
||||
# 产物:
|
||||
# deploy/requirements-base.lock.txt
|
||||
# deploy/requirements-moldinsight.lock.txt
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v conda >/dev/null 2>&1; then
|
||||
echo "[ERROR] conda 未安装或不在 PATH,请先激活 conda 环境(推荐环境名 moldinsight)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${CONDA_DEFAULT_ENV:-}" != "moldinsight" && "${CONDA_DEFAULT_ENV:-}" != "gemold" ]]; then
|
||||
echo "[WARN] 当前 conda 环境为 '${CONDA_DEFAULT_ENV:-<未激活>}'," \
|
||||
"推荐在 'moldinsight' conda 环境内运行(否则锁文件将含宿主污染)" >&2
|
||||
fi
|
||||
|
||||
echo "==> 生成 base 锁文件"
|
||||
pip freeze --exclude pythonocc-core > deploy/requirements-base.lock.txt
|
||||
|
||||
echo "==> 生成 moldinsight 锁文件"
|
||||
pip freeze --exclude pythonocc-core > deploy/requirements-moldinsight.lock.txt
|
||||
|
||||
echo
|
||||
echo "已生成:"
|
||||
echo " deploy/requirements-base.lock.txt ($(wc -l < deploy/requirements-base.lock.txt) 行)"
|
||||
echo " deploy/requirements-moldinsight.lock.txt ($(wc -l < deploy/requirements-moldinsight.lock.txt) 行)"
|
||||
echo
|
||||
echo "下一步:"
|
||||
echo " 1. 提交这两个 lock.txt(仅含项目直接依赖 + conda-minimal 环境产出)"
|
||||
echo " 2. 同步更新 deploy/requirements-base.txt / requirements-moldinsight.txt 的版本下限" \
|
||||
"为 lock 中的实际版本(或保留 >=,按团队策略)"
|
||||
@@ -3,9 +3,16 @@ upstream gemold_backend_upstream {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 8000;
|
||||
server_name _;
|
||||
|
||||
# 与 .env MAX_FILE_SIZE=104857600(100MB)对齐;nginx 默认 1m 会直接 413
|
||||
client_max_body_size 100M;
|
||||
|
||||
# 大文件上传给后端足够时间(默认 60s,100MB 可能不够)
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# geMoldInsight — inventory-only 模式
|
||||
#
|
||||
# 一键启动:
|
||||
# docker compose -f docker-compose.inventory.yml up -d
|
||||
#
|
||||
# 服务清单:仅 inventory 后端
|
||||
# 不含前端、不含 moldinsight、不含 Celery worker。
|
||||
#
|
||||
# 基础设施:仅依赖 PostgreSQL + Redis;不依赖 RustFS / MinIO 对象存储,
|
||||
# 不挂 uploads/html 命名卷(inventory 无文件分析链路)。
|
||||
# 注意:服务未声明 profiles(避免裸 up 报 "no service selected"),
|
||||
# 模式切换唯一入口是 -f 文件名。
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 公共环境变量(inventory 子集:DB / Redis / 认证 / 启动参数)
|
||||
# ---------------------------------------------------------------
|
||||
x-inventory-env: &inventory_env
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
|
||||
services:
|
||||
inventory:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.inventory
|
||||
image: gemold-inventory:latest
|
||||
container_name: gemold_inventory
|
||||
command: ["python", "-m", "uvicorn", "entrypoints.inventory:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
ports:
|
||||
- "${INVENTORY_PORT}:8001"
|
||||
environment:
|
||||
<<: *inventory_env
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8001"
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
networks:
|
||||
gemold_network:
|
||||
name: gemold_network
|
||||
driver: bridge
|
||||
@@ -0,0 +1,115 @@
|
||||
# geMoldInsight — moldinsight-only 模式
|
||||
#
|
||||
# 一键启动:
|
||||
# docker compose -f docker-compose.moldinsight.yml up -d
|
||||
#
|
||||
# 服务清单:moldinsight(独立 API)+ moldinsight-celery(异步分析 worker)
|
||||
# 不含前端、不含 inventory。
|
||||
# 镜像:两服务共用 gemold-backend:latest(同一 build 声明,compose 只构建一次;
|
||||
# celery 仅以 command 覆盖启动 worker),无跨镜像构建依赖,干净机器裸 up 一把过。
|
||||
# 注意:服务未声明 profiles(避免裸 up 报 "no service selected"),
|
||||
# 模式切换唯一入口是 -f 文件名。
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 公共环境变量(moldinsight 家族共用)
|
||||
# ---------------------------------------------------------------
|
||||
x-base-env: &base_env
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
|
||||
services:
|
||||
moldinsight:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
# 统一用 gemold-backend tag(历史 gemold-moldinsight:latest 双 tag 已废弃)
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_moldinsight
|
||||
command: ["python", "-m", "uvicorn", "entrypoints.moldinsight:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- "${MOLDINSIGHT_PORT}:8000"
|
||||
environment:
|
||||
<<: *base_env
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# uploads_data:D6 过渡兜底,RustFS 异常时本地路径回退
|
||||
# html_data:D11 后仅作 /html 报告代理的存量兜底读(新产物不落本地)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
moldinsight-celery:
|
||||
# 与 moldinsight 共用同一镜像,仅 command 覆盖启动 worker
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_celery
|
||||
# --pool=solo:任务在 celery 主进程内执行。prefork 的 worker 子进程是
|
||||
# daemonic,multiprocessing 禁止守护进程再生子进程,OCC 常驻进程池
|
||||
# (OccProcessPool spawn 拉起)在 prefork 下必报
|
||||
# "daemonic processes are not allowed to have children"(2026-09-26)。
|
||||
# solo 天然单任务串行,与模块级单例(asyncio.Lock 不可跨循环并发)匹配;
|
||||
# 需要吞吐时横向加容器副本,而不是调并发。
|
||||
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --pool=solo --loglevel=info"]
|
||||
environment:
|
||||
<<: *base_env
|
||||
# 旧 CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 已随 --pool=solo 移除:
|
||||
# solo 单进程串行,二者不适用(.env 里残留定义会被 compose 静默忽略)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
# 注:本文件无 backend service,celery 必须等 moldinsight API 就绪后再启动
|
||||
depends_on:
|
||||
- moldinsight
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
networks:
|
||||
gemold_network:
|
||||
name: gemold_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
name: gemold_uploads_data
|
||||
html_data:
|
||||
name: gemold_html_data
|
||||
+91
-177
@@ -1,3 +1,58 @@
|
||||
# geMoldInsight — unified 模式(默认入口)
|
||||
#
|
||||
# 一键启动:
|
||||
# docker compose up -d
|
||||
#
|
||||
# 其他模式(换文件名即可,无需 --profile):
|
||||
# docker compose -f docker-compose.moldinsight.yml up -d
|
||||
# docker compose -f docker-compose.inventory.yml up -d
|
||||
#
|
||||
# 注意:服务均未声明 profiles(compose 规则:声明了 profiles 的服务在
|
||||
# 不带 --profile 时不会被选中,裸 up 会报 "no service selected"),
|
||||
# 旧 --profile 写法不再是模式开关,模式切换唯一入口是 -f 文件名。
|
||||
#
|
||||
# 服务清单:frontend + backend(unified 入口) + moldinsight-celery
|
||||
# 镜像:gemold-backend / gemold-frontend(celery 复用 gemold-backend,仅 command 不同)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 公共环境变量(moldinsight 家族 backend/celery/moldinsight 共用)
|
||||
# 通过 anchor 在本文件内复用,避免 environment 块重复
|
||||
# ---------------------------------------------------------------
|
||||
x-base-env: &base_env
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
# SECRET_KEY 必须在 .env 中显式配置,否则 compose 直接失败
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
@@ -6,13 +61,11 @@ services:
|
||||
image: gemold-frontend:latest
|
||||
container_name: gemold_frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-80}:80"
|
||||
# 浏览器入口端口(无默认值:必须由 .env 中 FRONTEND_PORT 显式配置)
|
||||
- "${FRONTEND_PORT}:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- full
|
||||
- frontend
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
@@ -23,208 +76,69 @@ services:
|
||||
image: gemold-backend:latest
|
||||
container_name: gemold_backend
|
||||
command: ["python", "-m", "uvicorn", "entrypoints.unified:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 宿主机端口由 .env 的 BACKEND_PORT 决定:空/不设则不暴露宿主机端口
|
||||
# (仅经前端 /api 反代同域访问,避免与 frontend 抢占宿主 10003)。
|
||||
# 调试时设 BACKEND_PORT=10003 即可独立访问。
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
- "${BACKEND_PORT:-}:8000"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
<<: *base_env
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# 共享卷过渡兜底(D6/D11):主链路已改走 RustFS,本地卷仅为
|
||||
# RustFS 异常时的本地路径回退与 HTML 产物互通保留,后续批次移除
|
||||
# 共享卷过渡兜底(D6/D11):主链路已改走 RustFS。uploads 供 RustFS 异常时
|
||||
# 本地路径回退;html_output 仅作 /html 报告代理的存量兜底读(新产物不落本地)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- full
|
||||
- unified
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
moldinsight-celery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.celery
|
||||
container_name: gemold_celery
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
# 与 backend 共享本地卷(过渡兜底,见 D6/D11):worker 下载回退与 HTML 产物写读
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- full
|
||||
- unified
|
||||
- moldinsight
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
moldinsight:
|
||||
# 与 backend 共用同一镜像(同一 build 声明 + 同一 tag,compose 只构建一次),
|
||||
# 仅以 command 覆盖启动 worker——消除旧 Dockerfile.celery(已删)FROM
|
||||
# gemold-backend 在并行构建下的"镜像尚不存在"陷阱,干净机器裸 up 一把过
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.moldinsight
|
||||
image: gemold-moldinsight:latest
|
||||
container_name: gemold_moldinsight
|
||||
ports:
|
||||
- "${MOLDINSIGHT_PORT:-8000}:8000"
|
||||
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:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
|
||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
|
||||
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
|
||||
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
|
||||
MESH_QUALITY: ${MESH_QUALITY:-high}
|
||||
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
|
||||
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
|
||||
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
|
||||
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
|
||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
<<: *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
|
||||
- html_data:/app/html_output
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- moldinsight
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
inventory:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.inventory
|
||||
image: gemold-inventory:latest
|
||||
container_name: gemold_inventory
|
||||
ports:
|
||||
- "${INVENTORY_PORT:-8001}:8001"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8001"
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:-moldinsight}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY 未配置:请在 .env 中设置}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
|
||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- inventory
|
||||
networks:
|
||||
- gemold_network
|
||||
|
||||
networks:
|
||||
gemold_network:
|
||||
# 固定 name,便于跨 compose 文件调试时容器互通(如 inventory-only 与 unified 临时联调)
|
||||
name: gemold_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
name: gemold_uploads_data
|
||||
html_data:
|
||||
name: gemold_html_data
|
||||
+13
-8
@@ -15,7 +15,7 @@
|
||||
| moldinsight-only | `/api/*`(moldinsight)+ `/api/auth/*` + `/health` |
|
||||
| inventory-only | `/api/*`(inventory)+ `/api/auth/*` + `/health` |
|
||||
|
||||
- moldinsight 路由在 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 经 `_safe_include` 聚合(子 router 加载失败仅 WARNING 跳过;`debug_router` 仅 `DEBUG=true` 注册)。
|
||||
- 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) 挂载,三种形态共用。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- **鉴权**: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`(探活)、`/login`、`/users`(历史遗留入口,前端主链路用 `/api/auth/*`)。
|
||||
- **业务路由前缀**:全部业务端点在 `/api` 下;顶层仅 `/health`(探活)、`/html/{filename}`(可视化报告代理)、`/login`、`/users`(历史遗留入口,前端主链路用 `/api/auth/*`)。
|
||||
|
||||
## 3. 端点总览(按域分组)
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|---|---|---|
|
||||
| 登录 | `/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` | 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(模具分析)
|
||||
@@ -47,11 +47,16 @@
|
||||
| 批量分析 | `/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 |
|
||||
| CAM | `/api/cam/plan` | cam_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 |
|
||||
| 健康检查 | `/api/health` | health_router.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 |
|
||||
| 导出/估算/设计等高级接口 | 见 `openapi.json` 对应路径 | advanced_router.py(技术债 D1:待拆分) |
|
||||
|
||||
### 3.3 inventory(进销存)
|
||||
|
||||
@@ -93,12 +98,12 @@
|
||||
|
||||
- `frontend/src/types/api.ts` 是**生成物,禁止手改**;前端代码类型引用它。
|
||||
- 三步缺一即前后端契约漂移(硬约束,见 [AGENTS.md](../AGENTS.md) §2)。
|
||||
- **当前已知滞后**:checked-in `openapi.json`(2026-07-27)落后当前代码(实际 76 paths vs 文件内 70),下次接口变更时按上述流程重导出。
|
||||
- 当前 `openapi.json` 于 2026-09-17 随批次 3 重导出(76 paths),前端 `src/types/api.ts` 同步再生。
|
||||
|
||||
## 5. 契约变更规则
|
||||
|
||||
- 新增接口先定模块归属(moldinsight / inventory / shared auth),再写路由;返回结构、路径、鉴权发生变化时,同步更新本文相应表格。
|
||||
- 路由文件过大按职责拆分(现状债务:`advanced_router` 待拆分,见 [TECH_DEBT.md](TECH_DEBT.md) D1)。
|
||||
- 路由文件过大按职责拆分(参照批次 3 的 design / cost / machining / export 拆分先例;新增路由须登记 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 的 `ROUTE_MODULES`)。
|
||||
- 破坏性变更(删字段 / 改语义)需在 [STATUS.md](STATUS.md) 日志条目中记录,并确认前端同仓同步修改。
|
||||
|
||||
## 6. 前端消费约定
|
||||
|
||||
+102
-7
@@ -75,8 +75,8 @@ geMoldInsight 的目标架构不是微服务,也不是继续维持历史单体
|
||||
- 应用工厂与共用中间件
|
||||
|
||||
说明:
|
||||
- `shared` 当前仍是“共享平台层 + 历史耦合区”的混合体
|
||||
- 后续会继续向更清晰的 platform 语义收敛,但当前仓库结构仍以 `shared` 为事实名称
|
||||
- `shared` 的定位已明确为**平台层(跨模块基础能力)**;模块专属接线(RustFS 启动 / HTML 报告挂载 / 路由聚合)已收敛回模块层(见 §6.2),当前仓库结构仍以 `shared` 为事实名称
|
||||
- 剩余语义收敛(identity 平台表 vs 模块表的命名与注释口径)随实际重构继续推进
|
||||
|
||||
---
|
||||
|
||||
@@ -159,6 +159,8 @@ geMoldInsight/
|
||||
典型桥接关系示例:
|
||||
- `STPFile.product_id -> Product.id`
|
||||
|
||||
桥接只允许**裸 FK 列**(字符串表名),**不允许跨模块 ORM relationship**——单模块部署下另一模块的模型类可能未注册,跨模块 relationship 会让 mapper 配置直接失败(2026-09-17 批次 4 起为硬规则,原三条跨模块 relationship 均无使用方,已删除;对象化查询由使用方显式 select)。
|
||||
|
||||
### 5.2 模块边界优先于“临时方便”
|
||||
|
||||
新增逻辑时,应优先放入对应业务模块,而不是继续堆进 `shared`。
|
||||
@@ -178,18 +180,109 @@ geMoldInsight/
|
||||
|
||||
虽然模块化已经成型,但仍有几个关键耦合点需要持续关注:
|
||||
|
||||
### 6.1 共享 ORM 模型
|
||||
### 6.1 共享 ORM 模型 —— 已按模块拆分(2026-09-17,批次 4)
|
||||
|
||||
当前 [src/shared/models/database.py](../src/shared/models/database.py) 同时承载 identity、moldinsight、inventory 三类模型,是当前最强耦合点之一。
|
||||
历史上的 `shared/models/database.py`(31 个模型类三类同居)已拆除,现为按归属分置:
|
||||
|
||||
### 6.2 app factory 组合职责偏重
|
||||
- [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 四域文件)
|
||||
|
||||
当前 [src/shared/app_factory.py](../src/shared/app_factory.py) 仍承担较多平台与模块组合职责,是后续平台层收敛的重点。
|
||||
跨模块只允许裸 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. 专题文档与主骨架的关系
|
||||
@@ -199,8 +292,10 @@ geMoldInsight/
|
||||
- 存储方向:
|
||||
- [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)。
|
||||
AI、铝泡沫等更偏历史设计/规划性质的专题材料已迁入 [archive/README.md](archive/README.md)。
|
||||
|
||||
阶段性任务清单、迁移计划、历史总结等文档会逐步迁入 [archive/README.md](archive/README.md)。
|
||||
|
||||
|
||||
+78
-2
@@ -16,8 +16,54 @@
|
||||
- 前端同域反代可以面对单一 backend
|
||||
- 比按路径把前端网关分流到两套后端更易维护
|
||||
|
||||
当前 Compose 入口:
|
||||
- [docker-compose.yml](../docker-compose.yml)
|
||||
### 1.1 一键 Compose 部署(按文件名切换模式)
|
||||
|
||||
项目按"模式 ↔ Compose 文件"一一对应的方式支持一键部署。换文件名即可换模式:
|
||||
|
||||
| 模式 | Compose 文件 | 一键命令 |
|
||||
|---|---|---|
|
||||
| **unified**(默认) | [docker-compose.yml](../docker-compose.yml) | `docker compose up -d` |
|
||||
| moldinsight-only | [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) | `docker compose -f docker-compose.moldinsight.yml up -d` |
|
||||
| inventory-only | [docker-compose.inventory.yml](../docker-compose.inventory.yml) | `docker compose -f docker-compose.inventory.yml up -d` |
|
||||
|
||||
> **模式切换唯一入口是 `-f` 文件名**。各 service 均未声明 `profiles`(compose 规则:声明了 profiles 的服务在不带 `--profile` 时不会被选中,裸 `up` 会报 `no service selected`);历史 `--profile full/moldinsight/inventory` 写法随本次拆分失效,请统一改用上表命令。
|
||||
|
||||
### 1.2 宿主机端口约定(默认 = 选择 A)
|
||||
|
||||
部署约定:**unified 模式下前端独占宿主端口,backend 不暴露宿主端口**——浏览器始终只面对一个源,由前端 Nginx 同域反代到 backend,彻底消除 CORS。
|
||||
|
||||
```env
|
||||
# .env(unified 模式最小集)
|
||||
FRONTEND_PORT=10003 # 浏览器入口;前端 Nginx 容器监听 8000,反代 /api 到 backend:8000
|
||||
# BACKEND_PORT 留空或不设 → backend 仅在 docker 网络 gemold_network 内被前端反代访问
|
||||
```
|
||||
|
||||
端口链路:
|
||||
|
||||
```
|
||||
浏览器 → http://宿主机:10003 → frontend容器:8000 → /api/* → backend容器:8000
|
||||
(宿主机 10003) (docker 网络内)
|
||||
```
|
||||
|
||||
何时选 B(前后端都暴露宿主端口):临时直连后端调试、压测、k8s 健康检查等特殊场景。设 `BACKEND_PORT=10005`(避开 10003)后重启 compose 即可——**不建议在常规生产部署中使用**,会引入 CORS 与攻击面问题。
|
||||
|
||||
宿主机端口映射由 `.env` 强制配置,compose 无默认值兜底(缺配置时启动期 fail-fast)。详见 [.env.example §端口配置](../.env.example)、[docs/deployment/PORT_CONFIG.md](deployment/PORT_CONFIG.md)。
|
||||
|
||||
### 1.3 镜像构建
|
||||
|
||||
首次部署或更新代码后先构建,再 `up`:
|
||||
|
||||
```bash
|
||||
bash deploy/build.sh # 显式构建 base / backend / frontend 3 个镜像(celery 复用 backend)
|
||||
```
|
||||
|
||||
或让 compose 构建:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
> **注意**:`docker compose up -d` 在本地已有同名镜像(如 `gemold-backend:latest`)时**不会自动重建**,会直接复用旧镜像启动。更新代码或 Dockerfile 后,必须 `docker compose build` 或 `docker compose up -d --build` 才会生效。
|
||||
|
||||
详细 Linux 部署步骤:
|
||||
- [deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md)
|
||||
@@ -35,6 +81,16 @@
|
||||
- 测试/集成环境
|
||||
- 小团队统一部署
|
||||
|
||||
**Compose 文件**:[docker-compose.yml](../docker-compose.yml)(**默认入口**)
|
||||
|
||||
**一键命令**:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
包含服务:`frontend` + `backend`(unified 入口)+ `moldinsight-celery`。
|
||||
|
||||
### 2.2 moldinsight-only
|
||||
|
||||
只部署模具分析后端。
|
||||
@@ -43,6 +99,16 @@
|
||||
- 独立开放分析能力
|
||||
- 异步任务与文件处理独立扩容
|
||||
|
||||
**Compose 文件**:[docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml)
|
||||
|
||||
**一键命令**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.moldinsight.yml up -d
|
||||
```
|
||||
|
||||
包含服务:`moldinsight`(独立 API)+ `moldinsight-celery`(异步 worker)。
|
||||
|
||||
### 2.3 inventory-only
|
||||
|
||||
只部署进销存后端。
|
||||
@@ -51,6 +117,16 @@
|
||||
- 独立部署 ERP / 库存能力
|
||||
- 与 moldinsight 分开发布节奏
|
||||
|
||||
**Compose 文件**:[docker-compose.inventory.yml](../docker-compose.inventory.yml)
|
||||
|
||||
**一键命令**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.inventory.yml up -d
|
||||
```
|
||||
|
||||
包含服务:仅 `inventory`。不挂任何命名卷(inventory 无文件分析链路),不依赖 RustFS / MinIO。
|
||||
|
||||
部署模式的结构含义见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
---
|
||||
|
||||
+36
-9
@@ -9,14 +9,15 @@
|
||||
|
||||
- 配置统一走**环境变量**,代码侧由 [src/shared/config/settings.py](../src/shared/config/settings.py) 的 `Settings` 单例经 `dotenv` + `os.getenv` 读取。
|
||||
- **本地运行**:仓库根 `.env`(`load_dotenv()` 自动加载;不在仓库内,参照 [.env.example](../.env.example) 复制编辑)。
|
||||
- **Compose 运行**:compose 文件用 `${VAR}` 从同目录 `.env` 注入容器环境变量(见 [docker-compose.yml](../docker-compose.yml))。
|
||||
- **Compose 运行**:compose 文件用 `${VAR}` 从同目录 `.env` 注入容器环境变量;按模式对应不同文件名(见 [DEPLOYMENT.md §1.1](DEPLOYMENT.md))。
|
||||
- **键值约定**:
|
||||
- `DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD`:**惰性校验、无代码默认**——缺失时 import 不报错(便于测试/静态分析),真正连库时才失败。生产必须显式配置。
|
||||
- `AUTO_MIGRATE`:应用启动时是否自动执行 alembic 迁移,默认 `true`(单机开发语义);**多副本 / 容器编排部署应设 `false`**,改由部署流程单点执行 `alembic upgrade head` 或 `python -m shared.database.init_db`(迁移脚本已随镜像分发于 `/app/migrations/`)。
|
||||
- `SECRET_KEY`:JWT 签名密钥,**无默认**;生产必须 ≥32 字符强随机。
|
||||
- `ADMIN_PASSWORD`:初始管理员密码,**无默认**;首次建库前必须设置。
|
||||
- `RUSTFS_*`:对象存储(兼容 `MINIO_*` 别名写法);本地开发缺省值仅为占位,连不上会在用到存储的链路报错。
|
||||
- `REDIS_*`:默认 `localhost:6379` 无密码(本地开发语义),生产必须显式覆盖。
|
||||
- `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**。
|
||||
@@ -25,10 +26,24 @@
|
||||
## 2. 安装与环境
|
||||
|
||||
- 后端依赖:`pip install -r requirements.txt`。
|
||||
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
|
||||
- **OCC 几何能力**:PythonOCC 不走 pip 主路径,通过 conda 环境提供(本项目实践环境名 `gemold` 或 `moldinsight`)。无 OCC 环境时项目可启动,但几何分析契约测试自动 skip。
|
||||
- 前端:`cd frontend && npm install`。
|
||||
- 数据库迁移:`migrations/`(`alembic.ini` 在仓库根;2026-09-16 由 `alembic/` 改名——原目录名与 alembic 包重名,应用内 import 会被遮蔽导致启动期迁移静默失败);数据修复类一次性脚本在 `scripts/migrations/` 与 `scripts/db/`,**不是运行时代码**,勿在服务内引用。
|
||||
|
||||
### 2.1 pip 锁文件生成(D13 流程)
|
||||
|
||||
`deploy/requirements-{base,moldinsight}.lock.txt` 是项目依赖的**版本锁**,由 conda 环境首次构建成功后一次性落盘:
|
||||
|
||||
- **生成时机**:在 `moldinsight` / `gemold` conda 环境(仅含项目依赖 + conda 基础库,**不能**在混装全开发栈的本机 pip 环境跑)执行 `pip freeze`
|
||||
- **生成命令**:
|
||||
- Linux / macOS:`bash deploy/generate_lockfiles.sh`
|
||||
- Windows:`deploy\generate_lockfiles.bat`
|
||||
- **产物**:
|
||||
- `deploy/requirements-base.lock.txt`
|
||||
- `deploy/requirements-moldinsight.lock.txt`
|
||||
- **消费方**:CI、离线构建、生产复现部署;`pip install -r deploy/requirements-base.lock.txt` 可直接锁定安装而不依赖 `>=` 解析
|
||||
- **提交策略**:两个 lock.txt 提交到仓库;版本下限(`requirements-{base,moldinsight}.txt`)按团队策略同步或保留 `>=` 灵活解析
|
||||
|
||||
## 3. 本地启动
|
||||
|
||||
后端三入口(均含 sys.path 修正,可从仓库根直接跑):
|
||||
@@ -42,12 +57,15 @@ uvicorn src.entrypoints.moldinsight:app --reload --host 0.0.0.0 --port 8000
|
||||
uvicorn src.entrypoints.inventory:app --reload --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与 [deploy/Dockerfile.celery](../deploy/Dockerfile.celery) CMD 同参):
|
||||
Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与 compose 中 `moldinsight-celery` 的 `command:` 覆盖同参——worker 与后端共用 `gemold-backend` 镜像,无独立 Dockerfile):
|
||||
|
||||
```bash
|
||||
cd src && celery -A celery_app worker --concurrency=2 --loglevel=info
|
||||
```
|
||||
|
||||
- `--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
|
||||
@@ -60,13 +78,22 @@ npm run gen:api # 从根目录 openapi.json 重新生成 src/types/api.ts(
|
||||
|
||||
## 4. Docker Compose
|
||||
|
||||
按"模式 ↔ 文件名"一一对应:
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d # frontend + unified backend + moldinsight-celery(推荐)
|
||||
docker compose --profile moldinsight up -d # moldinsight 单模块栈
|
||||
docker compose --profile inventory up -d # inventory 单模块栈
|
||||
# unified(默认;frontend + backend + moldinsight-celery)
|
||||
docker compose up -d
|
||||
|
||||
# moldinsight-only(moldinsight + moldinsight-celery)
|
||||
docker compose -f docker-compose.moldinsight.yml up -d
|
||||
|
||||
# inventory-only(仅 inventory)
|
||||
docker compose -f docker-compose.inventory.yml up -d
|
||||
```
|
||||
|
||||
- 镜像构建:`deploy/build.bat` / `deploy/build.sh`(base → 各服务镜像,见 `deploy/Dockerfile.*`)。
|
||||
> 旧 `--profile` 写法已失效(服务不再声明 profiles);模式切换唯一入口是 `-f` 文件名。
|
||||
|
||||
- 镜像构建:`bash deploy/build.sh`(base → backend → frontend 3 个 tag,celery 复用 backend 镜像);首次部署或更新代码后必须先 build(或 `docker compose up -d --build`)——裸 `up` 对本地已有同名镜像**不会自动重建**。
|
||||
- PostgreSQL / Redis / RustFS 通常**复用服务器已有服务**,不由项目 compose 自带;容器只注入连接配置。
|
||||
|
||||
## 5. 运行时硬性要求
|
||||
@@ -74,7 +101,7 @@ docker compose --profile inventory up -d # inventory 单模块栈
|
||||
- **生产环境必须显式设置**:`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/ 为运行时产物目录**,不提交、不作为配置源头。
|
||||
- **uploads/ 与 html_output/ 为运行时产物目录**,不提交、不作为配置源头。`html_output/` 自 D11 起仅作 `/html` 报告代理的**存量兜底读**(新产物直传 RustFS 报告键 `html/reports/`,worker 不再写本地卷)。
|
||||
- `scripts/` 下的一次性脚本执行前先确认目标环境(多为不可逆数据迁移)。
|
||||
|
||||
## 6. 排障指针
|
||||
|
||||
+13
-8
@@ -45,7 +45,8 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
|
||||
重点方向:
|
||||
|
||||
- `advanced_router` 拆分与请求模型规范化
|
||||
- ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成)
|
||||
- ~~D17 Human-in-Loop 老师傅经验反馈~~(2026-09-23~24 完成,3 个 commit:数据 + 权限 + 写入 API / 算法接缝 + OCC payload / 前端按钮 + Dialog + 经验角标;写入即消费闭环通;详见 [TECH_DEBT.md](TECH_DEBT.md) D17)
|
||||
- 模具分析链路的结构继续收口
|
||||
- OCC 依赖场景下的契约测试/集成测试继续补齐
|
||||
|
||||
@@ -62,6 +63,10 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
- 业务 service 复用强化
|
||||
- 数据模型归属进一步清晰化
|
||||
- 前后端契约持续减少手写漂移
|
||||
- 已完成第一批主数据收口(2026-09-21):`customer / supplier / warehouse` 路由改为薄路由,CRUD 编排下沉至 `master_data_service`
|
||||
- 已完成物料域第二批收口(2026-09-21):`material_routes` 的价格历史、价格趋势、供应商关联查询/删除编排下沉至 `material_service`
|
||||
- 已完成产品域第三批收口(2026-09-21):`product_routes` 的常规 CRUD、BOM 与跨模块 `from-task` 编排均已下沉至 `product_service`
|
||||
- 已完成 dashboard 聚合收口(2026-09-21):`dashboard_routes` 的首页统计/低库存预警编排下沉至 `dashboard_service`
|
||||
|
||||
### 2.4 主线四:部署与运维一致性
|
||||
|
||||
@@ -88,19 +93,19 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
|
||||
### P1:moldinsight API 结构整理
|
||||
|
||||
- 拆分 `advanced_router`
|
||||
- 为高频接口引入 Pydantic 请求模型
|
||||
- 继续减少 `request.json()` 风格手动解析
|
||||
- ~~拆分 `advanced_router`~~(2026-09-17 批次 3 完成)
|
||||
- ~~为高频接口引入 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
|
||||
- 继续减少 `request.json()` 风格手动解析(存量端点已清零,新增接口守此约定)
|
||||
|
||||
### P2:shared/platform 边界继续收敛
|
||||
|
||||
- 梳理共享 ORM 与业务模型的归属
|
||||
- ~~梳理共享 ORM 与业务模型的归属~~(2026-09-17 批次 4 完成:ORM 已按模块拆分,跨模块只许裸 FK)
|
||||
- 继续减少 shared 直接承担业务组合逻辑
|
||||
- 为后续平台层命名与目录调整准备条件
|
||||
|
||||
### P3:专项能力继续规范化
|
||||
|
||||
- 铝价模拟数据增加显式 `source: "simulated"`
|
||||
- ~~铝价模拟数据增加显式 `source: "simulated"`~~(2026-09-18 完成:后端响应带 `source` 字段,前端按来源渲染标注,不再硬编码交易所名)
|
||||
- 补专题文档的定位/边界说明
|
||||
- 清理历史 checklist / tasks / report 文档的展示层级
|
||||
|
||||
@@ -117,11 +122,11 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
| 批次 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 吞吐方案设计先行;文档 / 契约同步 | D3、D10 |
|
||||
| 批次 4 | 架构演进(5 天+) | ~~共享 ORM 按模块拆分;OCC 吞吐方案设计先行;文档 / 契约同步~~(2026-09-17 完成) | D3、D10 |
|
||||
|
||||
**执行顺序建议**:批次 0 与批次 1 的 D6(RustFS 主链路)先行——前者是确认的安全漏洞,后者是部署根本性缺陷,两者互不依赖、改动可控。其余按批次顺序推进,每批完成同步 STATUS / TECH_DEBT / API_CONTRACT。
|
||||
|
||||
> 进度:批次 0 / 1 / 2 已于 2026-09-16 完成(D13 的 pip 全量锁文件为批次 1 遗留项,随下次镜像构建补齐;D11 留待后续批次,正确性已由批次 1 共享卷兜底);完成明细见 [STATUS.md](STATUS.md) 与 [TECH_DEBT.md](TECH_DEBT.md) §2.5–2.6。
|
||||
> 进度:批次 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 与主线方向。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,43 @@
|
||||
|
||||
> 文档定位:**唯一的「现在到哪了」**。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)。)
|
||||
|
||||
|
||||
+133
-115
@@ -8,122 +8,87 @@
|
||||
|
||||
## 1. 当前技术债概览
|
||||
|
||||
当前最主要的技术债集中在两个区域:
|
||||
当前最主要的技术债集中在三个区域:
|
||||
|
||||
- **moldinsight API 与处理链路的结构收口**
|
||||
- **inventory 复杂业务域的 service 继续下沉**
|
||||
- **文档 / 部署 / 历史语义与当前代码现状未完全一致**
|
||||
|
||||
已经完成的高优先级治理不再作为持续待办反复展开,当前重点聚焦在“还没完成、且值得继续推进”的部分。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已完成的重要治理(摘要)
|
||||
## 2. 已完成的重要治理(主题摘要)
|
||||
|
||||
以下高价值治理已完成:
|
||||
按主题归类的高价值治理已完成项。每项的具体修复清单 / 迁移号 / 回归测试 / 测试基线见归档:
|
||||
|
||||
- [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md):批次 0–4 + 后续专项 + 2026-09-21 inventory 服务下沉 + 2026-09-22 schema/datetime 弃用清零 的完整流水账
|
||||
- [archive/MOLDINSIGHT_TECH_DEBT_PLAN.md](archive/MOLDINSIGHT_TECH_DEBT_PLAN.md):更早的设计审查原始计划
|
||||
|
||||
### 2.1 安全与权限
|
||||
- debug/history 路由补鉴权
|
||||
- 任务访问控制收紧
|
||||
- 无主数据不再默认放行
|
||||
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5,2026-09-16 清偿,见 D5 条目)
|
||||
- bcrypt 创建口令超 72 字节显式拒绝、验证侧截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失时明确报错,代码侧弱默认移除(D14 部分,2026-09-16)
|
||||
- debug / history 路由补鉴权;任务访问控制收紧;无主数据不再默认放行
|
||||
- `/api/status/{task_id}` 补 JWT 鉴权与归属校验(原 D5 → §3 D5)
|
||||
- bcrypt 超 72 字节显式拒绝 + 截断比较;`SECRET_KEY` / `RUSTFS_*` 缺失明确报错
|
||||
|
||||
### 2.2 静默失败与可用性
|
||||
- `detect-undercuts` 改为基于真实 shape 分析
|
||||
- OCC 超时后重建 executor,避免全队列永久堵死
|
||||
- OCC 超时后重建 executor(短期)→ D10 方案 B 进程化彻底替换
|
||||
- 后台任务统一分派,补强引用与并发控制
|
||||
|
||||
### 2.3 状态存储与缓存
|
||||
- Redis 任务状态改为 Hash 字段级更新,兼容旧格式
|
||||
- 完成态任务视图增加缓存
|
||||
- 导出缓存与持久化链路收口,支持重启后再导出
|
||||
- 内存回退彻底删除,PG 为任务状态单一事实源(原 D7)
|
||||
- 完成态任务视图缓存;导出缓存与持久化链路收口
|
||||
|
||||
### 2.4 架构与代码清理
|
||||
- 删除旧单体入口与死代码
|
||||
- 设置惰性配置校验,提升可测试性
|
||||
- Generator 公共接口提取完成,补充契约测试
|
||||
|
||||
### 2.5 部署正确性(2026-09-16,批次 0/1)
|
||||
- `/api/status/{task_id}` 补鉴权与归属校验(原 D5)
|
||||
- 主处理链路改走 RustFS:分派入参 `stp_file_id` 化,源文件按 object_key 下载;compose 共享卷过渡兜底(原 D6)
|
||||
- `AUTO_MIGRATE` 开关 + 迁移脚本随镜像分发 + `alembic/`→`migrations/` 改名修复包遮蔽(原 D12)
|
||||
- OCC 镜像改 conda 运行时原生执行、基础镜像 tag 锁定(D13 主体);compose 关键项去弱默认(D14 部分)
|
||||
|
||||
### 2.6 任务一致性模型(2026-09-16,批次 2)
|
||||
- Redis 内存回退彻底删除,PG 为任务状态单一事实源(原 D7);批量元数据入库(`processing_tasks.batch_id`,迁移 `a3f8c2d91e47`)
|
||||
- 型腔生成失败任务标 failed,不再静默 completed(原 D8)
|
||||
- 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9)
|
||||
- D11(HTML 双写双读)本批未动:正确性已由共享卷兜底,RustFS 单一来源留待后续批次
|
||||
|
||||
详细历史过程保留在原始技术债文档中,后续将转入归档。
|
||||
- 删除旧单体入口与死代码(`db_manager.create_tables` / `log_user_activity` / `CADExporter.export_mold_results` / `getAluminumPrice` 等)
|
||||
- 惰性配置校验,提升可测试性
|
||||
- Generator 公共接口提取 + 契约测试
|
||||
- 共享 ORM 按模块拆分,跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1)
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前活跃技术债
|
||||
|
||||
### D1. `advanced_router` 过大,职责混杂
|
||||
### D1. `advanced_router` 过大,职责混杂 —— 已清偿(2026-09-17,批次 3)
|
||||
|
||||
现状:
|
||||
- 导出、估算、设计/分析相关接口仍混在同一个 router 中
|
||||
- 请求体仍有较多手动解析逻辑
|
||||
修复内容:
|
||||
- 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 可读性差
|
||||
- 接口参数校验不统一
|
||||
- 后续继续扩展时维护成本高
|
||||
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。
|
||||
|
||||
建议:
|
||||
- 拆分为 export / design / cost 等子路由
|
||||
- 高优先级请求体改为 Pydantic 模型
|
||||
### D2. 铝价模拟数据未显式标注来源 —— 已清偿(2026-09-18,批次 4 后续专项)
|
||||
|
||||
优先级:**P1**
|
||||
修复内容(保留编号以维持引用稳定):
|
||||
- [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` 删除(前端此前保留了一份本地硬编码函数,已无调用方)
|
||||
|
||||
### D2. 铝价模拟数据未显式标注来源
|
||||
~~原现状 / 影响~~:铝价接口返回走势数据但无来源声明,前端原硬编码"上海期货交易所"字样,与实际模拟数据不一致,属虚假来源声明。
|
||||
|
||||
现状:
|
||||
- 铝价服务返回的是模拟/参考数据,但接口层未明确表达
|
||||
### 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)
|
||||
|
||||
建议:
|
||||
- 响应增加 `source: "simulated"`
|
||||
- 前端界面同步标注“模拟/参考数据”
|
||||
仍保留的收敛方向(低优先级,随实际重构推进):
|
||||
- identity / platform 的边界语义(ROADMAP §2.1):平台表与模块表的命名/注释口径随实际重构推进
|
||||
|
||||
优先级:**P2**
|
||||
优先级:**P3**(仅剩 identity/platform 语义注释口径)
|
||||
|
||||
### D3. shared/platform 边界仍需继续收敛
|
||||
### D4. 文档现状 / 规划 / 历史混放 —— 已清偿(2026-09-22)
|
||||
|
||||
现状:
|
||||
- `shared` 同时承担平台基础能力与部分历史耦合职责
|
||||
- 共享 ORM 与 app factory 仍是主要耦合点
|
||||
修复内容(保留编号以维持引用稳定):
|
||||
- 已建立 `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 等已清偿项补齐时间戳
|
||||
|
||||
影响:
|
||||
- 模块边界认知成本较高
|
||||
- 新增逻辑容易继续堆入 shared
|
||||
|
||||
建议:
|
||||
- 继续从文档、目录语义、职责边界上推进收敛
|
||||
- 在后续实际重构中优先避免把业务逻辑继续沉入 shared
|
||||
|
||||
优先级:**P2**
|
||||
|
||||
### D4. 文档现状 / 规划 / 历史混放
|
||||
|
||||
现状:
|
||||
- 文档存在部署说明重叠、计划/总结/权威文档混放
|
||||
- README 承担过多职责
|
||||
|
||||
影响:
|
||||
- 新成员难以判断“哪篇才是当前有效说法”
|
||||
- 状态、部署、规划容易发生漂移
|
||||
|
||||
建议:
|
||||
- 建立 `STATUS / ARCHITECTURE / ROADMAP / DEPLOYMENT` 主骨架
|
||||
- 历史材料迁入 `docs/archive/`
|
||||
|
||||
优先级:**P1**
|
||||
~~原现状 / 影响~~:TECH_DEBT §2 与 §3 内容重复膨胀,文档目录结构清晰度受新成员评估影响。
|
||||
|
||||
### D5. `/api/status/{task_id}` 未鉴权(安全缺口)—— 已清偿(2026-09-16,批次 0)
|
||||
|
||||
@@ -171,32 +136,34 @@
|
||||
|
||||
~~原现状 / 影响~~:各存储方法内部自行 commit,型腔保存失败留半成品数据且任务仍 completed。
|
||||
|
||||
### D10. OCC 全局单线程串行 + 超时重建泄漏线程
|
||||
### D10. OCC 全局单线程串行 + 超时重建泄漏线程 —— 已清偿(2026-09-18,方案 B 实施)
|
||||
|
||||
现状:
|
||||
- 所有 OCC 操作经 `max_workers=1` executor 串行([processing_service.py](../src/moldinsight/services/processing_service.py)),celery 并发无法扩展 OCC 吞吐
|
||||
- 超时重建 executor 每次泄漏 1 个线程,长期运行只涨不降
|
||||
**方案 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:` 覆盖与环境变量传递)。
|
||||
|
||||
建议:
|
||||
- 记录吞吐上限为已知约束;线程泄漏治理方案设计先行(见 [ROADMAP.md](ROADMAP.md) §3.1 批次 4)
|
||||
保留为已知约束(非待修缺陷):
|
||||
- 单进程内 OCC 串行是正确性要求(OCC 非线程安全),吞吐扩展走多进程(方案 A/B)
|
||||
- 每个操作从 STP 原件重新加载形状(STEP 重载成本秒级)——进程隔离的设计取舍,见 OCC_THROUGHPUT §1.2/§5
|
||||
|
||||
优先级:**P2**
|
||||
优先级:~~**P3**~~ **已清偿**
|
||||
|
||||
### D11. HTML 报告本地磁盘与 RustFS 双写双读
|
||||
### D11. HTML 报告本地磁盘与 RustFS 双写双读 —— 已清偿(2026-09-18)
|
||||
|
||||
现状:
|
||||
- 可视化 HTML/摘要同时写本地 `html_output/`(/html 静态挂载)与 RustFS
|
||||
修复内容:
|
||||
- **写侧**:可视化产物不再落节点本地 `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 为唯一来源,本地仅作按需缓存
|
||||
|
||||
优先级:**P2**
|
||||
~~原现状 / 影响~~:可视化 HTML/摘要同时写本地与 RustFS,多副本下 /html 命中结果取决于负载均衡,跨副本文件不共享。
|
||||
|
||||
### D12. 应用启动时自动执行 alembic 迁移
|
||||
|
||||
@@ -208,28 +175,78 @@
|
||||
1. 迁移目录 `alembic/` 与 alembic 包重名——应用内 `import alembic` 命中本地目录(namespace package)遮蔽真实包,启动期迁移异常被 `init_database` 吞掉只打日志;已改名 `migrations/`(alembic.ini `script_location` 与 4 处文档引用同步)
|
||||
2. 镜像未打包迁移脚本与 alembic.ini,容器内迁移必然失败——Dockerfile.base / Dockerfile.moldinsight 已补 `COPY migrations/` + `COPY alembic.ini`
|
||||
|
||||
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(主体已清偿,锁文件遗留)
|
||||
### D12. Pydantic v2 弃用项清理 —— 已清偿(2026-09-22)
|
||||
|
||||
修复内容(保留编号以维持引用稳定):
|
||||
- 全仓 14 处 `class Config:` + `from_attributes = True` 统一迁移为 `model_config = ConfigDict(from_attributes=True)`:
|
||||
- [src/inventory/schemas/customer_schemas.py](../src/inventory/schemas/customer_schemas.py) / [supplier_schemas.py](../src/inventory/schemas/supplier_schemas.py) / [warehouse_schemas.py](../src/inventory/schemas/warehouse_schemas.py) / [inventory_schemas.py](../src/inventory/schemas/inventory_schemas.py) / [stock_movement_schemas.py](../src/inventory/schemas/stock_movement_schemas.py) / [product_schemas.py](../src/inventory/schemas/product_schemas.py) / [material_schemas.py](../src/inventory/schemas/material_schemas.py) / [purchase_order_schemas.py](../src/inventory/schemas/purchase_order_schemas.py) / [sales_order_schemas.py](../src/inventory/schemas/sales_order_schemas.py) / [finance_schemas.py](../src/inventory/schemas/finance_schemas.py)
|
||||
- [src/shared/services/auth_routes.py](../src/shared/services/auth_routes.py) UserResponse / RoleResponse / PermissionResponse
|
||||
- 同步收掉 [src/shared/services/auth_service.py](../src/shared/services/auth_service.py) 中 `datetime.utcnow()` 的遗留 deprecation:3 处 token / last_login 写入改用 `datetime.now(timezone.utc)`,与 Pydantic 无关但同属“现代化弃用清理”范畴
|
||||
- 语义保持:仅切换 Pydantic v2 配置语法 + UTC 时区语义,字段 / OpenAPI / JWT 行为零变化
|
||||
- 验证:`pytest tests/ -q` **126 passed, 4 skipped**,deprecation warning 全部清零
|
||||
|
||||
### D13. PythonOCC 镜像引入方式脆弱 + 依赖无版本锁(镜像引入已清偿;锁文件流程已固化,待首次构建落盘)
|
||||
|
||||
现状:
|
||||
- ~~从 conda env 拷贝 site-packages 进 python:3.12-slim~~(2026-09-16 已修正:[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 改为 conda 运行时原生执行,不再跨镜像拷贝;基础镜像 tag 锁定 `continuumio/miniconda3:24.7.1-0`、`python:3.12-slim-bookworm`;tag 可用性随下次镜像构建验证)
|
||||
- [requirements.txt](../requirements.txt) 全部为 `>=` 下限,无锁文件(**遗留**:首次镜像构建成功后 `pip freeze` 生成锁文件,命令已注释在 Dockerfile 内)
|
||||
- ~~从 conda env 拷贝 site-packages 进 python:3.12-slim~~(2026-09-16 已修正:[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 改为 conda 运行时原生执行,不再跨镜像拷贝;基础镜像 tag 锁定 `condaforge/miniforge3:24.7.1-2`——2026-09-24 由 `continuumio/miniconda3:24.7.1-0` 切换,conda-forge 单渠道无 Anaconda ToS 顾虑、与 CI / 开发机 Miniforge 同源;另锁定 `python:3.12-slim-bookworm`;tag 可用性随下次镜像构建验证)
|
||||
- 锁文件流程已固化(2026-09-22):
|
||||
- 新增 [deploy/generate_lockfiles.sh](../deploy/generate_lockfiles.sh) / [generate_lockfiles.bat](../deploy/generate_lockfiles.bat):在 moldinsight conda 环境(仅项目依赖,**不能**在混装开发栈跑)执行 `pip freeze --exclude pythonocc-core`,产出 `deploy/requirements-{base,moldinsight}.lock.txt`
|
||||
- [Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) 注释改为指向生成脚本
|
||||
- [docs/OPERATIONS.md](../docs/OPERATIONS.md) §2.1 增加完整流程说明(生成时机 / 命令 / 产物 / 消费方 / 提交策略)
|
||||
- [tests/test_lockfile_generation.py](../tests/test_lockfile_generation.py) 加锁文件存在性 + 体积契约;默认 skip(仓库单测不阻塞),CI 镜像构建 job 显式 `pytest --run-lockfile-check` 启用
|
||||
- **遗留**:锁文件本身尚未落盘——本机 Miniforge 装的是跨项目开发栈混装环境,污染严重不能直接用 `pip freeze`;须等 CI / 生产机器首次构建 moldinsight 镜像后,按流程跑 `bash deploy/generate_lockfiles.sh` 落锁并提交。已存在护栏:CI 镜像构建 job 跑 `--run-lockfile-check` 后若未落盘会 fail-fast,强制流程走通
|
||||
|
||||
优先级:**P2**(剩余锁文件部分)
|
||||
优先级:**P2**(流程已固化,剩"首次构建后落盘"一次性产物)
|
||||
|
||||
### D14. 配置漂移:弱默认 / 死配置 / 重复解析
|
||||
### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3)
|
||||
|
||||
现状:
|
||||
- ~~RUSTFS_* 弱默认~~(2026-09-16 代码侧已去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(2026-09-16 已去除:改用 `${VAR:?}` 强制显式配置,`create_admin_user` 对空 ADMIN_PASSWORD 显式报错)
|
||||
- MAX_FILE_SIZE 配置项未被使用([file_handler.py](../src/shared/utils/file_handler.py) 硬编码 50MB)
|
||||
- [celery_app.py](../src/celery_app.py) 重新 load_dotenv 并手拼 REDIS URL,与 settings 两份实现
|
||||
修复内容:
|
||||
- ~~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,连接串唯一拼装点)
|
||||
|
||||
影响:
|
||||
- 违背"关键项不兜底"硬约束;配置行为与文档不一致
|
||||
优先级:已清偿
|
||||
|
||||
建议:
|
||||
- 去掉弱默认、对齐或删除死配置、celery_app 复用 settings
|
||||
### D15. 前端 `npm run build` 因既有 TS 错误失败(批次 3 连带发现)—— 已清偿(2026-09-17,批次 4)
|
||||
|
||||
优先级:**P2**
|
||||
修复内容:
|
||||
- [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
|
||||
|
||||
~~原现状 / 影响~~:算法生成的方案与真实工程决策有差距,老师傅每次都要推翻系统建议重来,沉淀经验无结构化路径。
|
||||
|
||||
---
|
||||
|
||||
@@ -238,14 +255,15 @@
|
||||
> 注:2026-09-15 后端设计审查后,治理**执行顺序**以 [ROADMAP.md](ROADMAP.md) §3.1 批次计划为准(批次 0–4);D5–D14 的批次归属见该表。本节保留原有优先项作为补充说明。
|
||||
|
||||
### 第一优先级
|
||||
1. `advanced_router` 拆分
|
||||
2. 高优先级接口补 Pydantic 请求模型
|
||||
1. ~~`advanced_router` 拆分~~(2026-09-17 批次 3 完成,见 D1)
|
||||
2. ~~高优先级接口补 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
|
||||
3. 文档主骨架收口并减少重复说明
|
||||
|
||||
### 第二优先级
|
||||
4. 铝价模拟数据来源显式化
|
||||
5. 部署历史文档归档
|
||||
6. shared/platform 语义继续收敛
|
||||
6. shared/platform 语义继续收敛(共享 ORM 归属已于批次 4 清偿,剩余为 app_factory 组合职责等,见 D3)
|
||||
7. inventory 服务继续下沉(2026-09-21 已完成第一批主数据 CRUD 收口:customer / supplier / warehouse → `master_data_service`;剩余复杂域如 product / material / dashboard)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 2026-09 后端设计治理批次实施流水账(归档)
|
||||
|
||||
> 文档定位:**2026-09 设计审查产出的批次 0–4 与后续专项的完整实施流水账**。
|
||||
> 主骨架权威文档见:
|
||||
> - [../STATUS.md](../STATUS.md)(每批次完成的时间点与产物)
|
||||
> - [../TECH_DEBT.md](../TECH_DEBT.md)(活跃债务 / 已清偿项的当前位置)
|
||||
> - [../ROADMAP.md](../ROADMAP.md)(批次计划与执行顺序)
|
||||
> - [../ARCHITECTURE.md](../ARCHITECTURE.md)(平台 / 模块边界当前定稿)
|
||||
|
||||
## 批次 0(2026-09-16,安全与诚实)
|
||||
|
||||
- `/api/status/{task_id}` 补 JWT 鉴权 + 任务归属校验(无 token 401 / 他人或无主任务 403 / 不存在 404)
|
||||
- 归属校验收敛为 `TaskQueryService.ensure_task_access` 供 task_router 与 advanced_router 共用(原 D5)
|
||||
- 上传预检 `pythonocc_available` 从硬编码 true 改为惰性真实探测
|
||||
- bcrypt 口令治理:创建侧超 72 字节显式拒绝、验证侧截断比较
|
||||
- `SECRET_KEY` 未配置 / `RUSTFS_*` 缺失时惰性校验抛明确错误
|
||||
- 完成态任务未持久化 `analysis_metrics` 时 `/api/status` 组装视图 500 修复
|
||||
- 测试基线:96 passed
|
||||
- 回归测试:`tests/test_status_endpoint_auth.py` 8 项
|
||||
- 接口行为变化已同步 [../API_CONTRACT.md](../API_CONTRACT.md) §3.2
|
||||
|
||||
## 批次 1(2026-09-16,部署正确性)
|
||||
|
||||
- 主处理链路改走 RustFS:分派入参 `file_path` → `stp_file_id`(原 D6)
|
||||
- 处理方按 PG 元数据从 RustFS 下载源文件到任务专属临时目录(保留原始文件名,下游产物命名不变),任务结束即清理
|
||||
- RustFS 不可用时回退 `STPFile.file_path` 节点本地路径;compose 为 backend / celery 增加共享卷 `uploads_data` / `html_data` 作过渡兜底
|
||||
- `AUTO_MIGRATE` 开关(settings / .env.example / compose 透传),默认 `true` 保持单机开发行为(原 D12)
|
||||
- 迁移目录 `alembic/` → `migrations/`(修复 `import alembic` 命中本地目录遮蔽真实包的命名冲突)+ Docker 镜像补 `COPY migrations/` + `COPY alembic.ini`
|
||||
- Dockerfile.moldinsight 改为 conda 运行时原生执行,基础镜像 tag 锁定(原 D13 主体)
|
||||
- compose 关键项去弱默认:`SECRET_KEY` / `ADMIN_PASSWORD` 改 `${VAR:?}` 强制显式配置;`create_admin_user` 对空口令显式报错
|
||||
- 测试基线:98 passed, 1 skipped
|
||||
- 回归测试:`tests/test_deployment_config.py`
|
||||
|
||||
## 批次 2(2026-09-16,任务一致性模型)
|
||||
|
||||
- Redis 进程内存回退彻底删除(写 no-op / 读 None,Redis 仅热缓存),PG 为任务状态单一事实源(原 D7)
|
||||
- 批量元数据入库:`processing_tasks` 新增 `batch_id` 列(迁移 `a3f8c2d91e47`),`GET /api/batch/{batch_id}` 改为 PG 聚合查询 + `STPFile.user_id` 归属校验
|
||||
- 删除 Redis batch key 与进程内 dict 双通道
|
||||
- `TaskQueryService` PG 视图与 batch 聚合响应补 `progress` / `current_step`
|
||||
- 型腔分模失败不再吞异常 → 任务 failed(原 D8)
|
||||
- 持久化事务边界收口:数据本体写方法只 flush,编排层分阶段原子收口(阶段 A 几何+网格、阶段 B 型腔+HTML+特征+指标+验证),失败先 rollback 再置 failed(原 D9)
|
||||
- 进度/状态更新保留即时 commit(长任务进度可见性)
|
||||
- 测试基线:105 passed, 1 skipped
|
||||
- 回归测试:`tests/test_batch_status_pg.py` 4 项 + `tests/test_redis_no_fallback.py` 3 项
|
||||
|
||||
## 批次 3(2026-09-17,API 与代码结构)
|
||||
|
||||
- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变(原 D1)
|
||||
- 全部请求体改 Pydantic 模型(`request.json()` 手动解析退役),校验失败统一 422
|
||||
- `_get_cached_import` 上提为 `core_modules.py` 共用
|
||||
- 路由装载失败显式化:`ROUTE_MODULES` 清单 + `route_registry`,失败经 `/api/health` 呈现 degraded(`pythonocc` 真实探测),DEBUG 下 fail fast
|
||||
- 纯 Python 重计算端点统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环
|
||||
- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务(原 D14 部分)
|
||||
- `MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14 收尾)
|
||||
- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422);Dockerfile.celery 的 FROM tag 与实际构建 tag 对齐
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api`
|
||||
- 测试基线:122 passed, 2 skipped
|
||||
- 回归测试:4 个测试文件共 17 项(`test_advanced_split_contract` / `test_route_load_status` / `test_config_governance` / `test_auth_password_reset`)
|
||||
|
||||
## 批次 4(2026-09-17,架构演进)
|
||||
|
||||
- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表)
|
||||
- 三条跨模块 ORM relationship(`User.stp_files` / `STPFile.user` / `STPFile.product`)经全仓核实均无使用方,直接删除;跨模块桥接收敛为裸 FK 硬规则([../ARCHITECTURE.md](../ARCHITECTURE.md) §5.1)
|
||||
- 约 45 处 import 全量改写,无兼容 facade
|
||||
- 全量注册点收敛为 migrations/env.py 与 tests/conftest.py
|
||||
- 零调用方的死方法 `db_manager.create_tables` 一并删除
|
||||
- `_reset_occ_executor` 补 `cancel_futures=True`(旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**,属数据竞争而非单纯泄漏,原 D10 短期治理)
|
||||
- 吞吐路线定稿于 `topics/performance/OCC_THROUGHPUT.md`(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治)
|
||||
- 顺手清偿 D15:`vite.config.ts` 删除未用的 `mode` 参数,`vue-tsc -b` 恢复通过
|
||||
- 回归测试:`tests/test_model_ownership.py`(31 表全量注册 / 单模块独立 mapper 配置 / 旧模块无 facade)
|
||||
- 测试基线:125 passed, 2 skipped
|
||||
- 接口面零变化(无 openapi 重导出)
|
||||
|
||||
## 批次 4 后续专项(2026-09-18)
|
||||
|
||||
- D11 清偿:可视化报告 RustFS 单源化
|
||||
- 写侧 HTMLGenerator 每任务写临时目录,`.html`/`_summary.json`/`_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`
|
||||
- 读侧 `/html` StaticFiles 本地挂载删除,新增代理路由 `html_report_router.py`(报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404)
|
||||
- 防路径穿越(单段文件名校验),URL 形状 `/html/{filename}` 不变
|
||||
- 部署:celery 服务摘除 `html_data` 卷;Dockerfile.moldinsight 删除 `COPY html_output/`
|
||||
- 已知约束:报告路由不做认证(iframe 无法携带 Authorization 头)
|
||||
- 删除 `get_stp_file_with_data` 的死数据块
|
||||
- OCC 方案 A 部署参数落地:`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 Dockerfile.celery + compose + .env.example
|
||||
- D2 清偿:铝价响应带 `source: "simulated"`;前端按来源渲染"模拟数据 · 参考走势"标注;死代码 `getAluminumPrice` 删除
|
||||
- D10 方案 B 实施:`run_occ(fn, *args)` → `run_occ(op_name, payload)`;常驻 OCC 进程池 + kill-on-timeout 根治残留线程泄漏
|
||||
- CI 门禁:`.gitea/workflows/ci.yml` 三 job(pytest / 前端构建 / openapi 漂移检测)
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(76→77 paths,新增 `/html/{filename}`)+ 前端 `gen:api` 再生
|
||||
- 测试基线:143 passed, 0 skipped
|
||||
|
||||
## 后续小步治理(2026-09-21 ~ 2026-09-22)
|
||||
|
||||
- 2026-09-21 inventory 业务层继续沉淀批次(4 批):
|
||||
- `customer / supplier / warehouse` → `master_data_service`
|
||||
- `material_routes`(价格历史 / 趋势 / 供应商关联)→ `material_service`
|
||||
- `product_routes`(CRUD / BOM / from-task 跨模块桥接)→ `product_service`
|
||||
- `dashboard_routes`(首页统计 / 低库存预警)→ `dashboard_service`
|
||||
- 2026-09-22 schema / datetime 现代化弃用清零(D12):
|
||||
- 全仓 14 处 `class Config:` + auth_routes 三处全部迁移到 `model_config = ConfigDict(from_attributes=True)`
|
||||
- `auth_service.py` 中 `datetime.utcnow()` 改用 `datetime.now(timezone.utc)`
|
||||
- 测试基线最终落点:126 passed, 4 skipped,零 deprecation warning
|
||||
@@ -0,0 +1,10 @@
|
||||
# 2026-09 之前 STATUS 早段历史(归档)
|
||||
|
||||
> 文档定位:**[../STATUS.md](../STATUS.md) 顶部精简后,2026-09-17 之前条目的完整副本**。
|
||||
> 2026-09-17 及之后的批次(批次 0–4 + 后续专项 + 2026-09-21 inventory 服务下沉 + 2026-09-22 schema/datetime 弃用清零)以摘要形式保留在 [../STATUS.md](../STATUS.md) 顶部,详细流水见 [2026-09_governance_batches.md](2026-09_governance_batches.md)。
|
||||
|
||||
---
|
||||
|
||||
> 2026-09-02(**模块化收口 + 文档主骨架建立(基线条目)**:代码侧完成 moldinsight 技术债治理——安全收口(debug/history 权限补齐、任务访问控制收紧)、静默失败修复(`detect-undercuts` 基于真实 shape 重建)、OCC 超时后 executor 重建防毒化全队列、后台任务统一分派、Redis 任务状态改 Hash 原子更新、完成态任务视图缓存、导出缓存与持久化收口、旧入口与死代码删除、Generator 公共接口提取 + 契约测试;详见 [TECH_DEBT.md](../TECH_DEBT.md) §2。结构侧完成 `src/entrypoints/` 三入口拆分(moldinsight / inventory / unified)、`shared` 平台能力集中、前端独立 `frontend/` 工程。文档侧建立 `STATUS / ARCHITECTURE / ROADMAP / TECH_DEBT / DEPLOYMENT` 主骨架,README 收敛为唯一导航入口,历史材料迁入 [archive/](README.md)。**测试基线**:本地 pip 环境 **47 passed, 1 skipped**(pythonocc 缺失自动 skip);moldinsight conda + OCC 环境 **88 passed**。inventory 侧少量既有 deprecation warnings 不影响通过。)
|
||||
|
||||
> 此前:2026-09-01(**文档体系专项整理启动**:明确「README 只做导航、每类信息单一归属、历史材料进 archive」的文档治理原则;建立 deployment/ 主题目录与 archive/ 归档目录;部署文档收口为 DEPLOYMENT(入口)+ deployment/LINUX_SETUP(操作)+ deployment/DEPLOY_PORT / PORT_CONFIG(端口补充)三层。)
|
||||
@@ -25,6 +25,8 @@
|
||||
- [MOLD_ERP_ANALYSIS_REPORT.md](MOLD_ERP_ANALYSIS_REPORT.md)
|
||||
- [ZERO_FINISHED_INVENTORY_CERTIFICATE.md](ZERO_FINISHED_INVENTORY_CERTIFICATE.md)
|
||||
- [CONFLUENCE_ARCHIVE_STRUCTURE.md](CONFLUENCE_ARCHIVE_STRUCTURE.md)
|
||||
- [2026-09_governance_batches.md](2026-09_governance_batches.md):2026-09 设计审查批次 0–4 + 后续专项的完整实施流水账([../TECH_DEBT.md](../TECH_DEBT.md) 与 [../STATUS.md](../STATUS.md) 仅保留摘要)
|
||||
- [2026-09_status_history.md](2026-09_status_history.md):[../STATUS.md](../STATUS.md) 顶部精简后,2026-09-17 之前条目的完整副本
|
||||
- [topics/ai/](topics/ai/):已迁移的 AI 相关专题历史材料
|
||||
- [topics/performance/](topics/performance/):已迁移的性能专题历史材料
|
||||
- [topics/aluminum-foam/](topics/aluminum-foam/):已迁移的铝泡沫专题历史材料
|
||||
|
||||
@@ -64,26 +64,30 @@
|
||||
## 3. Docker Compose 端口来源
|
||||
|
||||
当前主部署文件:
|
||||
- [docker-compose.yml](../../docker-compose.yml)
|
||||
- [docker-compose.yml](../../docker-compose.yml)(unified,默认入口)
|
||||
- [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
|
||||
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)
|
||||
|
||||
关键端口映射:
|
||||
关键端口映射(默认 10003 / 10004,详见 [.env.example](../../.env.example)):
|
||||
|
||||
- `FRONTEND_PORT` → frontend Nginx 外部端口
|
||||
- `BACKEND_PORT` → unified backend 外部端口
|
||||
- `FRONTEND_PORT` → frontend Nginx 外部端口(浏览器入口,推荐直接访问)
|
||||
- unified backend → **不暴露宿主机端口**,经前端 /api 反代同域访问(docker 网络内 `backend:8000` 互通)
|
||||
- `MOLDINSIGHT_PORT` → moldinsight-only 模式宿主机端口
|
||||
- `INVENTORY_PORT` → inventory-only 模式宿主机端口
|
||||
- `MOLDINSIGHT_PORT` → moldinsight-only 独立部署端口
|
||||
- `INVENTORY_PORT` → inventory-only 独立部署端口
|
||||
|
||||
示例:
|
||||
|
||||
```env
|
||||
MOLDINSIGHT_PORT=8000
|
||||
INVENTORY_PORT=8001
|
||||
MOLDINSIGHT_PORT=10003
|
||||
INVENTORY_PORT=10004
|
||||
```
|
||||
|
||||
对应 compose 行为:
|
||||
|
||||
- moldinsight:`${MOLDINSIGHT_PORT:-8000}:8000`
|
||||
- inventory:`${INVENTORY_PORT:-8001}:8001`
|
||||
- moldinsight:`${MOLDINSIGHT_PORT:-10003}:8000`
|
||||
- inventory:`${INVENTORY_PORT:-10004}:8001`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ RUSTFS_SECRET_KEY=minioadmin
|
||||
- `/health` → unified backend
|
||||
- `/html` → unified backend(内部再提供 moldinsight 分析产物)
|
||||
|
||||
如果使用根目录 [docker-compose.yml](../../docker-compose.yml) 的 `frontend` 服务,则该入口已经内置在前端 Nginx 镜像中。
|
||||
如果使用 [docker-compose.yml](../../docker-compose.yml)(unified 模式)的 `frontend` 服务,则该入口已经内置在前端 Nginx 镜像中。
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +199,9 @@ celery -A src.celery_app.celery_app worker --loglevel=info
|
||||
如需按模块独立部署,则使用 `moldinsight-only` 或 `inventory-only` 入口;它们仍共享同一个仓库、同一个数据库与同一套基础设施。
|
||||
|
||||
当前入口与部署编排见:
|
||||
- [../../docker-compose.yml](../../docker-compose.yml)
|
||||
- [../../docker-compose.yml](../../docker-compose.yml)(默认 unified)
|
||||
- [../../docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
|
||||
- [../../docker-compose.inventory.yml](../../docker-compose.inventory.yml)
|
||||
- [../../src/entrypoints/unified.py](../../src/entrypoints/unified.py)
|
||||
|
||||
---
|
||||
@@ -398,25 +400,36 @@ curl http://127.0.0.1:8000/health
|
||||
|
||||
## 11. Docker Compose 说明
|
||||
|
||||
当前 [docker-compose.yml](../../docker-compose.yml) 会启动:
|
||||
仓库根目录维护 3 个独立 compose 文件,按文件名映射部署模式:
|
||||
|
||||
- `frontend`
|
||||
- `backend`
|
||||
- `moldinsight-celery`
|
||||
- 可选:`moldinsight` / `inventory`(独立模块模式)
|
||||
| 模式 | Compose 文件 | 一键命令 |
|
||||
|---|---|---|
|
||||
| unified(默认) | [docker-compose.yml](../../docker-compose.yml) | `docker compose up -d` |
|
||||
| moldinsight-only | [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml) | `docker compose -f docker-compose.moldinsight.yml up -d` |
|
||||
| inventory-only | [docker-compose.inventory.yml](../../docker-compose.inventory.yml) | `docker compose -f docker-compose.inventory.yml up -d` |
|
||||
|
||||
它**不会**再拉起:
|
||||
> 注意:各 service 均未声明 `profiles`,旧 `--profile full/moldinsight/inventory` 写法不再是模式开关(声明了 profiles 的服务在裸 `up` 下不会被选中,会报 `no service selected`);模式切换统一用上表 `-f` 命令。
|
||||
|
||||
不同模式分别包含的服务:
|
||||
|
||||
- **unified**:`frontend` + `backend`(unified 入口)+ `moldinsight-celery`
|
||||
- **moldinsight-only**:`moldinsight`(独立 API)+ `moldinsight-celery`
|
||||
- **inventory-only**:仅 `inventory`
|
||||
|
||||
任一 compose 文件**都不会**再拉起:
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- MinIO
|
||||
- MinIO / RustFS
|
||||
|
||||
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息;前端则由 `frontend` 容器独立提供,并通过同域反代转发到后端。
|
||||
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息;前端则由 `frontend` 容器独立提供(仅 unified 模式包含),通过同域反代转发到后端。
|
||||
|
||||
示例:
|
||||
镜像构建:
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
bash deploy/build.sh # base → backend → frontend(celery 复用 backend 镜像)
|
||||
# 或让 compose 构建:docker compose up -d --build
|
||||
# 注意:docker compose up -d 对本地已有同名镜像不会自动重建,更新代码后需 --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -17,18 +17,20 @@
|
||||
|
||||
对于 Docker Compose 部署,当前主要端口配置来源于:
|
||||
|
||||
- [docker-compose.yml](../../docker-compose.yml)
|
||||
- [docker-compose.yml](../../docker-compose.yml)(unified,默认入口)
|
||||
- [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
|
||||
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)
|
||||
- `.env` / `deploy/.env.example`
|
||||
|
||||
注意:该 compose 文件仅负责项目应用容器,不负责 PostgreSQL / Redis / 对象存储容器。
|
||||
注意:这些 compose 文件仅负责项目应用容器,不负责 PostgreSQL / Redis / 对象存储容器。
|
||||
|
||||
核心环境变量:
|
||||
|
||||
```env
|
||||
FRONTEND_PORT=80
|
||||
BACKEND_PORT=8000
|
||||
MOLDINSIGHT_PORT=8000
|
||||
INVENTORY_PORT=8001
|
||||
FRONTEND_PORT=10003 # unified 模式浏览器入口(前端 Nginx 对外)
|
||||
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署时使用
|
||||
INVENTORY_PORT=10004 # inventory-only 独立部署时使用
|
||||
# 注意:unified backend 不暴露宿主机端口,仅经前端 /api 反代(docker 网络内部 backend:8000 互通)
|
||||
```
|
||||
|
||||
其余基础设施通常为:
|
||||
@@ -50,10 +52,10 @@ RUSTFS_ENDPOINT=http://localhost:9000
|
||||
|
||||
| 变量 / 端口 | 用途 |
|
||||
|---|---|
|
||||
| `FRONTEND_PORT` | 前端 Nginx 宿主机暴露端口 |
|
||||
| `BACKEND_PORT` | unified backend 宿主机暴露端口 |
|
||||
| `MOLDINSIGHT_PORT` | moldinsight-only 独立部署端口 |
|
||||
| `INVENTORY_PORT` | inventory-only 独立部署端口 |
|
||||
| `FRONTEND_PORT`(默认 10003) | 前端 Nginx 宿主机暴露端口(浏览器入口) |
|
||||
| unified backend | **不暴露宿主机端口**——经前端 /api 反代,docker 网络内 `backend:8000` 互通 |
|
||||
| `MOLDINSIGHT_PORT`(默认 10003) | moldinsight-only 独立部署端口 |
|
||||
| `INVENTORY_PORT`(默认 10004) | inventory-only 独立部署端口 |
|
||||
| `DB_PORT` | PostgreSQL 端口 |
|
||||
| `REDIS_PORT` | Redis 端口 |
|
||||
| `9000` | MinIO/RustFS S3 兼容 API |
|
||||
@@ -116,7 +118,8 @@ Compose 通过端口映射暴露服务:
|
||||
- inventory → `${INVENTORY_PORT}:8001`
|
||||
|
||||
当前实际定义见:
|
||||
- [docker-compose.yml](../../docker-compose.yml)
|
||||
- [docker-compose.yml](../../docker-compose.yml)(unified / moldinsight-only)
|
||||
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)(inventory-only 的 `INVENTORY_PORT`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 落盘端到端。
|
||||
@@ -116,9 +116,11 @@ RUSTFS_SECRET_KEY=change-me
|
||||
|
||||
### 3. 启动项目服务
|
||||
|
||||
当前推荐通过:
|
||||
- [docker-compose.yml](../../../docker-compose.yml)
|
||||
- 或 [src/entrypoints/](../../../src/entrypoints/)
|
||||
当前推荐通过(按模式对应不同 compose 文件):
|
||||
- `docker compose up -d`([docker-compose.yml](../../../docker-compose.yml),默认 unified)
|
||||
- `docker compose -f docker-compose.moldinsight.yml up -d`
|
||||
- `docker compose -f docker-compose.inventory.yml up -d`
|
||||
- 或直接 [src/entrypoints/](../../../src/entrypoints/) 入口
|
||||
|
||||
而不是继续使用历史单体 `python src/main.py` 作为默认方式。
|
||||
|
||||
|
||||
+3
-7
@@ -51,17 +51,13 @@ npm run build
|
||||
- [deploy/Dockerfile.frontend](../deploy/Dockerfile.frontend)
|
||||
- [deploy/nginx/frontend.conf](../deploy/nginx/frontend.conf)
|
||||
|
||||
以及根目录 Compose 中的 `frontend` 服务:
|
||||
以及根目录 [docker-compose.yml](../docker-compose.yml)(unified 模式)中的 `frontend` 服务。完整系统一键启动:
|
||||
|
||||
```bash
|
||||
docker compose --profile frontend up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
完整系统:
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
```
|
||||
> 旧 `--profile frontend` 仅起前端的写法已随 compose 拆分移除;前端同域反代依赖 unified backend,推荐整栈启动。前后端分离开发时,前端本地 `npm run dev`、后端直跑 `uvicorn`(见 [LINUX_SETUP.md](../docs/deployment/LINUX_SETUP.md) §6)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<div class="card aluminum-price-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<span class="aluminum-icon">🪙</span> 铝金属价格 (SHFE)
|
||||
<span class="aluminum-icon">🪙</span> 铝金属价格
|
||||
</div>
|
||||
<span class="aluminum-source">数据来源: 上海期货交易所 | 更新于 {{ aluminum.date }}</span>
|
||||
<span class="aluminum-source">{{ aluminumSourceLabel }}</span>
|
||||
</div>
|
||||
<div class="aluminum-content">
|
||||
<div class="aluminum-price-row">
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, nextTick, ref } from 'vue'
|
||||
import { computed, reactive, onMounted, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
@@ -98,6 +98,8 @@ interface AluminumPrice {
|
||||
low: number
|
||||
prev_close: number
|
||||
week_ago_price: number
|
||||
// D2:数据来源声明——simulated 为模拟走势(参考数据,非实时行情)
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
@@ -112,6 +114,7 @@ const aluminum = reactive({
|
||||
change: 0,
|
||||
change_percent: 0,
|
||||
open: 0,
|
||||
source: '',
|
||||
high: 0,
|
||||
low: 0,
|
||||
prev_close: 0,
|
||||
@@ -124,12 +127,21 @@ const formatAluminumPrice = (val: number | null) => {
|
||||
return Math.round(val).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// D2:来源标注以接口 source 字段为准,不再硬编码交易所名称——
|
||||
// 此前显示"数据来源: 上海期货交易所"而数据实为模拟走势,属虚假来源声明
|
||||
const aluminumSourceLabel = computed(() => {
|
||||
if (aluminum.source === 'simulated') return '模拟数据 · 参考走势,非实时行情'
|
||||
if (aluminum.source) return `数据来源: ${aluminum.source}`
|
||||
return `更新于 ${aluminum.date}`
|
||||
})
|
||||
|
||||
const loadAluminumPrice = async () => {
|
||||
try {
|
||||
const data = await apiRequest<AluminumPrice>('/api/aluminum-price/current')
|
||||
aluminum.price = data.price
|
||||
aluminum.unit = data.unit
|
||||
aluminum.date = data.date
|
||||
aluminum.source = data.source ?? ''
|
||||
aluminum.change = data.change
|
||||
aluminum.change_percent = data.change_percent
|
||||
aluminum.open = data.open
|
||||
|
||||
@@ -33,7 +33,17 @@
|
||||
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
|
||||
<div class="summary-header">
|
||||
<h3>推荐方案</h3>
|
||||
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
|
||||
<div class="tag-row">
|
||||
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
|
||||
<t-tag
|
||||
v-if="currentAxisHint"
|
||||
theme="success"
|
||||
variant="light"
|
||||
:title="`历史 ${currentAxisHint.sample_count} 次相似产品反馈:采纳 ${currentAxisHint.adopted_count} / 拒绝 ${currentAxisHint.rejected_count}`"
|
||||
>
|
||||
📚 历史经验 {{ currentAxisHint.sample_count }} 条
|
||||
</t-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div class="info-item">
|
||||
@@ -126,12 +136,24 @@
|
||||
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
|
||||
💰 成本估算
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canGiveFeedback"
|
||||
type="default"
|
||||
size="small"
|
||||
@click="openFeedbackDialog"
|
||||
title="老师傅经验反馈:标记采纳 / 建议调整 / 拒绝,下一次同指纹产品分析将自动应用"
|
||||
>
|
||||
👍 老师傅反馈
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
|
||||
<div class="summary-header">
|
||||
<h3>3D 预览</h3>
|
||||
<t-tag theme="primary">重点区域</t-tag>
|
||||
<t-tag v-if="candidateSchemes.length > 1" theme="warning" variant="light">
|
||||
当前:{{ selectedScheme?.title || selectedScheme?.scheme_id }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<t-alert v-if="state.previewStatus === 'error'" theme="warning" title="预览加载失败" :message="'HTML 已生成但加载异常,请检查该链接是否可访问:' + selectedHtmlFile" style="margin-bottom: var(--space-3);" />
|
||||
<iframe
|
||||
@@ -535,6 +557,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</t-loading>
|
||||
|
||||
<HumanFeedbackDialog
|
||||
v-model:visible="state.feedbackDialogVisible"
|
||||
:task-id="(route.params.taskId as string)"
|
||||
:scheme-id="selectedScheme?.scheme_id || ''"
|
||||
:scheme-axis="selectedScheme?.axis || 'Z'"
|
||||
:scheme-title="selectedScheme?.title || selectedScheme?.scheme_id || ''"
|
||||
:fingerprint="state.hintsFingerprint"
|
||||
:score-at-submit="selectedScheme?.score"
|
||||
:confidence-at-submit="selectedScheme?.confidence_score"
|
||||
@submitted="onFeedbackSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -543,8 +577,10 @@ import { reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { moldinsightApi } from '@/shared/api-client'
|
||||
import { handleApiError, addNotification } from '@/shared/notification'
|
||||
import { formatDateTime, formatNumber } from '@/shared/utils'
|
||||
import HumanFeedbackDialog from './components/HumanFeedbackDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -573,9 +609,12 @@ interface Scheme {
|
||||
summary?: string
|
||||
reason?: string
|
||||
score?: number
|
||||
score_breakdown?: { manufacturability?: number; parting_quality?: number }
|
||||
confidence_score?: number
|
||||
score_breakdown?: { manufacturability?: number; parting_quality?: number; human_hint_bonus?: number }
|
||||
mold_structure_type?: string
|
||||
offset_label?: string
|
||||
axis?: string
|
||||
method?: string
|
||||
cavity_data?: CavityData
|
||||
key_info?: KeyInfo
|
||||
html_file?: string
|
||||
@@ -724,6 +763,18 @@ const state = reactive({
|
||||
costLoading: false,
|
||||
costError: '',
|
||||
costResult: null as CostEstimate | null,
|
||||
// D17 Human-in-Loop:老师傅经验反馈
|
||||
feedbackDialogVisible: false,
|
||||
hintsFingerprint: {} as Record<string, string>,
|
||||
hints: [] as Array<{
|
||||
scheme_axis: string
|
||||
adopted_count: number
|
||||
rejected_count: number
|
||||
adjust_count: number
|
||||
confidence: number
|
||||
weight: number
|
||||
sample_count: number
|
||||
}>,
|
||||
})
|
||||
|
||||
const camSteelOptions = [
|
||||
@@ -777,12 +828,63 @@ const createProductFromAnalysis = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// D17 Human-in-Loop:经验反馈
|
||||
const taskId = computed<string>(() => (route.params.taskId as string) || '')
|
||||
|
||||
const hintsByAxis = computed<Record<string, (typeof state.hints)[number]>>(() => {
|
||||
const map: Record<string, (typeof state.hints)[number]> = {}
|
||||
for (const h of state.hints) {
|
||||
map[h.scheme_axis] = h
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const currentAxisHint = computed<(typeof state.hints)[number] | undefined>(() => {
|
||||
const axis = selectedScheme.value?.axis
|
||||
if (!axis) return undefined
|
||||
return hintsByAxis.value[axis]
|
||||
})
|
||||
|
||||
const canGiveFeedback = computed(() => {
|
||||
const u: any = appStore.user
|
||||
if (!u) return false
|
||||
if (u.is_superuser) return true
|
||||
const roles = u.roles as Array<{ code: string }> | undefined
|
||||
return !!roles?.some(r => r.code === 'process_engineer')
|
||||
})
|
||||
|
||||
const loadExperienceHints = async () => {
|
||||
if (!taskId.value) return
|
||||
try {
|
||||
const data = await moldinsightApi.getExperienceHints(taskId.value)
|
||||
state.hints = (data.hints || []) as typeof state.hints
|
||||
state.hintsFingerprint = data.fingerprint || {}
|
||||
} catch (e) {
|
||||
// 不阻塞主流程:拉取失败时静默退化(按钮仍可点击,新反馈走 POST 写入)
|
||||
console.warn('拉取经验 hints 失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
if (!selectedScheme.value?.scheme_id) {
|
||||
addNotification('当前方案未确定,无法反馈', 'error')
|
||||
return
|
||||
}
|
||||
state.feedbackDialogVisible = true
|
||||
}
|
||||
|
||||
const onFeedbackSubmitted = async () => {
|
||||
addNotification('反馈已生效,正在刷新经验角标', 'success')
|
||||
await loadExperienceHints()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!appStore.user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
loadTask()
|
||||
loadExperienceHints()
|
||||
})
|
||||
|
||||
const getPriorityText = (priority: string) => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<t-dialog
|
||||
:visible="visible"
|
||||
@update:visible="(v: boolean) => emit('update:visible', v)"
|
||||
header="老师傅经验反馈"
|
||||
:close-on-overlay-click="true"
|
||||
width="540px"
|
||||
>
|
||||
<div v-if="schemeTitle" class="context-block">
|
||||
<div class="context-row">
|
||||
<span class="context-label">方案</span>
|
||||
<span class="context-value">{{ schemeTitle }}</span>
|
||||
</div>
|
||||
<div v-if="hasFingerprint" class="context-row">
|
||||
<span class="context-label">产品指纹</span>
|
||||
<span class="context-value">{{ formatFingerprint() }}</span>
|
||||
</div>
|
||||
<div class="context-row">
|
||||
<span class="context-label">本次应用</span>
|
||||
<span class="context-value hint-meta">写后即被下次同指纹分析消费(D17 闭环)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t-form label-width="80px">
|
||||
<t-form-item label="反馈">
|
||||
<t-radio-group v-model="form.feedback_status">
|
||||
<t-radio-button value="adopted">✅ 采纳</t-radio-button>
|
||||
<t-radio-button value="adjust">✏️ 建议调整</t-radio-button>
|
||||
<t-radio-button value="rejected">✕ 拒绝</t-radio-button>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item v-if="form.feedback_status === 'adjust'" label="调整建议">
|
||||
<t-textarea
|
||||
v-model="form.adjust_suggestion"
|
||||
placeholder="具体怎么调?(如:分型面偏上 0.5mm / 增加滑块位置 / 改水路直径)"
|
||||
:maxlength="2000"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item label="原因">
|
||||
<t-textarea
|
||||
v-model="form.feedback_reason"
|
||||
placeholder="为什么这样判断?(可选,便于团队理解)"
|
||||
:maxlength="2000"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
|
||||
<template #footer>
|
||||
<t-button theme="default" @click="cancel" :disabled="submitting">取消</t-button>
|
||||
<t-button theme="primary" :loading="submitting" @click="submit">提交反馈</t-button>
|
||||
</template>
|
||||
</t-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, computed } from 'vue'
|
||||
import { moldinsightApi } from '@/shared/api-client'
|
||||
import { addNotification } from '@/shared/notification'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
taskId: string
|
||||
schemeId: string
|
||||
schemeAxis: string
|
||||
schemeTitle: string
|
||||
fingerprint: Record<string, string>
|
||||
scoreAtSubmit?: number
|
||||
confidenceAtSubmit?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
interface FeedbackForm {
|
||||
feedback_status: 'adopted' | 'adjust' | 'rejected'
|
||||
feedback_reason: string
|
||||
adjust_suggestion: string
|
||||
}
|
||||
|
||||
const form = reactive<FeedbackForm>({
|
||||
feedback_status: 'adopted',
|
||||
feedback_reason: '',
|
||||
adjust_suggestion: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const hasFingerprint = computed(() => {
|
||||
return Boolean(props.fingerprint && Object.keys(props.fingerprint).length > 0)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
// 打开时重置表单
|
||||
form.feedback_status = 'adopted'
|
||||
form.feedback_reason = ''
|
||||
form.adjust_suggestion = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function cancel() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function formatFingerprint(): string {
|
||||
const f = props.fingerprint || {}
|
||||
const items = [
|
||||
f.bbox_aspect,
|
||||
f.volume_bucket,
|
||||
f.face_bucket,
|
||||
f.material_family,
|
||||
f.is_foam,
|
||||
].filter(Boolean)
|
||||
return items.join(' / ') || '—'
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.taskId || !props.schemeId) {
|
||||
addNotification('任务或方案标识缺失', 'error')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await moldinsightApi.submitExperienceFeedback(props.taskId, {
|
||||
scheme_id: props.schemeId,
|
||||
feedback_status: form.feedback_status,
|
||||
feedback_reason: form.feedback_reason || undefined,
|
||||
adjust_suggestion:
|
||||
form.feedback_status === 'adjust' ? form.adjust_suggestion || undefined : undefined,
|
||||
score_at_submit: typeof props.scoreAtSubmit === 'number' ? props.scoreAtSubmit : undefined,
|
||||
confidence_at_submit:
|
||||
typeof props.confidenceAtSubmit === 'number'
|
||||
? props.confidenceAtSubmit
|
||||
: undefined,
|
||||
})
|
||||
addNotification('反馈已提交,下次同指纹产品分析将自动应用', 'success')
|
||||
emit('submitted')
|
||||
emit('update:visible', false)
|
||||
} catch (e: any) {
|
||||
addNotification(e?.message || '反馈提交失败', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-block {
|
||||
background: var(--bg-secondary, #f5f7fa);
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color, #e7e7e7);
|
||||
}
|
||||
.context-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.context-label {
|
||||
color: var(--text-secondary, #888);
|
||||
min-width: 70px;
|
||||
}
|
||||
.context-value {
|
||||
color: var(--text-primary, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hint-meta {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -240,7 +240,7 @@ const resetPassword = async (user: UserItem) => {
|
||||
try {
|
||||
await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newPassword),
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
})
|
||||
addNotification('密码已重置', 'success')
|
||||
} catch (e) {
|
||||
|
||||
@@ -404,7 +404,46 @@ export const moldinsightApi = {
|
||||
})
|
||||
},
|
||||
|
||||
getAluminumPrice() {
|
||||
return apiRequest<{ price: number; unit: string; updated_at: string }>('/api/aluminum-price/current')
|
||||
// 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),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+1078
-1000
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
export default defineConfig(() => ({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+6
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
- 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用
|
||||
(项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2)
|
||||
- target_metadata 指向 shared.models.database.Base.metadata
|
||||
- target_metadata 指向 shared.models.base.Base.metadata(全量模型注册见下方 import)
|
||||
- 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db)
|
||||
"""
|
||||
from logging.config import fileConfig
|
||||
@@ -18,8 +18,11 @@ project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root / "src"))
|
||||
|
||||
from shared.config.settings import settings # noqa: E402
|
||||
from shared.models.database import Base # noqa: E402
|
||||
import shared.models.database # noqa: E402,F401 # 导入所有模型,确保 metadata 注册
|
||||
from shared.models.base import Base # noqa: E402
|
||||
# 导入全部三包模型,确保 metadata 注册(全量注册点约定见 shared/models/base.py)
|
||||
import shared.models.identity # noqa: E402,F401
|
||||
import moldinsight.models # noqa: E402,F401
|
||||
import inventory.models # noqa: E402,F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""add experience_feedback
|
||||
|
||||
Human-in-Loop 闭环(D17,2026-09):老师傅对系统推荐方案给出'采纳 / 调整 / 拒绝'
|
||||
反馈,按'产品指纹 + 工艺参数'为索引跨任务匹配;下次同指纹产品分析自动消费。
|
||||
- 新增 experience_feedback 表(方案级反馈)
|
||||
- 跨模块裸 FK(user_id / processing_task_id / stp_file_id)
|
||||
- 复合索引 (stp_file_id, scheme_axis, feedback_status) 用于按方向聚合采纳计数
|
||||
- GIN 索引(PG only)用于 fingerprint JSON 字段检索
|
||||
|
||||
Revision ID: b7d1f4a92c3e
|
||||
Revises: a3f8c2d91e47
|
||||
Create Date: 2026-09-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b7d1f4a92c3e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a3f8c2d91e47'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'experience_feedback',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('processing_task_id', sa.Integer(), nullable=False),
|
||||
sa.Column('stp_file_id', sa.Integer(), nullable=False),
|
||||
sa.Column('scheme_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('scheme_axis', sa.String(length=1), nullable=False),
|
||||
sa.Column('scheme_method', sa.String(length=50), nullable=True),
|
||||
sa.Column('feedback_status', sa.String(length=20), nullable=False),
|
||||
sa.Column('feedback_reason', sa.Text(), nullable=True),
|
||||
sa.Column('adjust_suggestion', sa.Text(), nullable=True),
|
||||
sa.Column('process_params_snapshot', sa.JSON(), nullable=True),
|
||||
sa.Column('fingerprint', sa.JSON(), nullable=False),
|
||||
sa.Column('confidence_at_submit', sa.Float(), nullable=True),
|
||||
sa.Column('score_at_submit', sa.Float(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role_code', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(
|
||||
['processing_task_id'], ['processing_tasks.id'], ondelete='CASCADE'
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['stp_file_id'], ['stp_files.id'], ondelete='CASCADE'
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], ondelete='RESTRICT'
|
||||
),
|
||||
)
|
||||
op.create_index(op.f('ix_experience_feedback_id'), 'experience_feedback', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_processing_task_id'), 'experience_feedback', ['processing_task_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_stp_file_id'), 'experience_feedback', ['stp_file_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_scheme_id'), 'experience_feedback', ['scheme_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_feedback_status'), 'experience_feedback', ['feedback_status'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_user_id'), 'experience_feedback', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_created_at'), 'experience_feedback', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_experience_feedback_expires_at'), 'experience_feedback', ['expires_at'], unique=False)
|
||||
op.create_index(
|
||||
op.f('ix_experience_feedback_stp_axis_status'),
|
||||
'experience_feedback',
|
||||
['stp_file_id', 'scheme_axis', 'feedback_status'],
|
||||
unique=False,
|
||||
)
|
||||
# 注意:此处原本计划给 fingerprint 建 GIN 索引("jsonb_path_query 类查询"),
|
||||
# 但该列是 sa.JSON() → PG 的 json 类型,而 GIN 只支持 jsonb(json 无默认
|
||||
# 操作符类,CREATE INDEX 直接报 UndefinedObject)。且代码侧并无 JSON 包含
|
||||
# 查询——聚合过滤在 Python 侧进行,DB 侧走上方 (stp_file_id, scheme_axis,
|
||||
# feedback_status) 复合索引。故不建此索引;未来若真需要 JSON 检索,应先把
|
||||
# 列迁为 jsonb 再建 GIN。首次对生产库执行时曾因此报错回滚(2026-09-26)。
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if op.get_bind().dialect.name == 'postgresql':
|
||||
op.execute("DROP INDEX IF EXISTS ix_experience_feedback_fingerprint_gin")
|
||||
op.drop_index(op.f('ix_experience_feedback_stp_axis_status'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_expires_at'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_created_at'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_user_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_feedback_status'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_scheme_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_stp_file_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_processing_task_id'), table_name='experience_feedback')
|
||||
op.drop_index(op.f('ix_experience_feedback_id'), table_name='experience_feedback')
|
||||
op.drop_table('experience_feedback')
|
||||
+1324
-783
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ sys.path.insert(0, str(src_root))
|
||||
from sqlalchemy import select
|
||||
|
||||
from database.database import db_manager
|
||||
from models.database import MoldCavityData
|
||||
from moldinsight.models import MoldCavityData
|
||||
from storage.rustfs_storage import rustfs_manager
|
||||
from config.settings import settings
|
||||
from services.storage_integration_rustfs import StorageIntegrationService
|
||||
|
||||
@@ -14,7 +14,7 @@ sys.path.insert(0, str(project_root / "src"))
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy import text, select
|
||||
from config.settings import settings
|
||||
from models.database import SalesOrder, Customer
|
||||
from inventory.models import SalesOrder, Customer
|
||||
|
||||
|
||||
async def check():
|
||||
|
||||
+8
-15
@@ -1,23 +1,16 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
"""Celery 应用入口。
|
||||
|
||||
D14:broker/backend 复用 settings 的 Redis 配置——此前本模块自行
|
||||
load_dotenv 并手拼 REDIS URL,与 settings 两份实现、行为可能漂移。
|
||||
"""
|
||||
from celery import Celery
|
||||
|
||||
load_dotenv()
|
||||
|
||||
redis_host = os.getenv("REDIS_HOST", "localhost")
|
||||
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||
redis_password = os.getenv("REDIS_PASSWORD", "")
|
||||
redis_db = os.getenv("REDIS_DB", "0")
|
||||
|
||||
if redis_password:
|
||||
broker_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
|
||||
else:
|
||||
broker_url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||
from shared.config.settings import settings
|
||||
|
||||
app = Celery(
|
||||
"moldinsight",
|
||||
broker=broker_url,
|
||||
backend=broker_url,
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["celery_tasks"],
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ def _register_routers(app):
|
||||
app = create_app(
|
||||
title="Gemold - 进销存管理系统",
|
||||
service_name="inventory",
|
||||
mount_html=False,
|
||||
serve_frontend_static=False,
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -10,13 +10,14 @@ sys.path.insert(0, str(src_root))
|
||||
os.chdir(Path(__file__).parent.parent.parent)
|
||||
|
||||
from shared.app_factory import create_app
|
||||
from moldinsight.storage.init_storage import rustfs_startup_hook
|
||||
|
||||
|
||||
def _register_routers(app):
|
||||
"""注册 moldinsight 业务路由"""
|
||||
"""注册 moldinsight 业务路由(/api 聚合 + HTML 报告代理,收敛于 moldinsight.api)"""
|
||||
try:
|
||||
from moldinsight.api import router as moldinsight_router
|
||||
app.include_router(moldinsight_router, prefix="/api")
|
||||
from moldinsight.api import register_moldinsight_routers
|
||||
register_moldinsight_routers(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] MoldInsight 路由: {e}")
|
||||
|
||||
@@ -24,7 +25,7 @@ def _register_routers(app):
|
||||
app = create_app(
|
||||
title="Gemold - 模具分析引擎",
|
||||
service_name="moldinsight",
|
||||
mount_html=True,
|
||||
serve_frontend_static=False,
|
||||
startup_hooks=[rustfs_startup_hook],
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -10,13 +10,14 @@ sys.path.insert(0, str(src_root))
|
||||
os.chdir(Path(__file__).parent.parent.parent)
|
||||
|
||||
from shared.app_factory import create_app
|
||||
from moldinsight.storage.init_storage import rustfs_startup_hook
|
||||
|
||||
|
||||
def _register_routers(app):
|
||||
"""注册 unified 业务路由"""
|
||||
"""注册 unified 业务路由(moldinsight 单点聚合 + inventory)"""
|
||||
try:
|
||||
from moldinsight.api import router as moldinsight_router
|
||||
app.include_router(moldinsight_router, prefix="/api")
|
||||
from moldinsight.api import register_moldinsight_routers
|
||||
register_moldinsight_routers(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] MoldInsight 路由: {e}")
|
||||
|
||||
@@ -30,7 +31,7 @@ def _register_routers(app):
|
||||
app = create_app(
|
||||
title="Gemold - Unified Backend",
|
||||
service_name="unified",
|
||||
mount_html=True,
|
||||
serve_frontend_static=False,
|
||||
startup_hooks=[rustfs_startup_hook],
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -9,16 +9,15 @@
|
||||
|
||||
路由前缀: /api/customers
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Customer
|
||||
from shared.models.identity import User
|
||||
from ..schemas import CustomerCreate, CustomerResponse
|
||||
from ..services.master_data_service import master_data_service
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
|
||||
@@ -31,12 +30,7 @@ async def list_customers(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
query = select(Customer).where(Customer.is_active == True)
|
||||
if search:
|
||||
query = query.where(Customer.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [CustomerResponse.from_orm(c) for c in result.scalars().all()]
|
||||
return await master_data_service.list_customers(db_session, skip, limit, search)
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerResponse, status_code=201)
|
||||
@@ -45,15 +39,7 @@ async def create_customer(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
data = customer_data.dict()
|
||||
if not data.get("code"):
|
||||
data["code"] = f"C{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
customer = Customer(**data)
|
||||
db_session.add(customer)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(customer)
|
||||
return CustomerResponse.from_orm(customer)
|
||||
return await master_data_service.create_customer(db_session, customer_data, current_user)
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerResponse)
|
||||
@@ -63,17 +49,7 @@ async def update_customer(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
for key, value in customer_data.dict().items():
|
||||
setattr(customer, key, value)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.refresh(customer)
|
||||
return CustomerResponse.from_orm(customer)
|
||||
return await master_data_service.update_customer(db_session, customer_id, customer_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{customer_id}")
|
||||
@@ -82,11 +58,4 @@ async def delete_customer(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
result = await db_session.execute(select(Customer).where(Customer.id == customer_id))
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
customer.is_active = False
|
||||
await db_session.flush()
|
||||
return {"message": "客户已删除"}
|
||||
return await master_data_service.delete_customer(db_session, customer_id, current_user)
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import (
|
||||
User, Product, Supplier, Customer, Warehouse,
|
||||
Inventory, PurchaseOrder, SalesOrder
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from ..services.dashboard_service import dashboard_service
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
|
||||
|
||||
@@ -28,55 +25,4 @@ async def get_dashboard(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
material_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
|
||||
) or 0
|
||||
finished_product_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
|
||||
) or 0
|
||||
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True))
|
||||
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True))
|
||||
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True))
|
||||
|
||||
total_stock = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
total_value = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity * Product.cost_price))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
|
||||
pending_purchase = await db_session.scalar(
|
||||
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
|
||||
)
|
||||
pending_sales = await db_session.scalar(
|
||||
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
|
||||
)
|
||||
|
||||
low_stock_products = await db_session.execute(
|
||||
select(Product, Inventory)
|
||||
.join(Inventory, Product.id == Inventory.product_id)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Inventory.quantity <= Product.min_stock)
|
||||
.limit(10)
|
||||
)
|
||||
low_stock = [
|
||||
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
|
||||
for p, i in low_stock_products.all()
|
||||
]
|
||||
|
||||
return {
|
||||
"finished_product_count": finished_product_count,
|
||||
"material_count": material_count,
|
||||
"supplier_count": supplier_count,
|
||||
"customer_count": customer_count,
|
||||
"warehouse_count": warehouse_count,
|
||||
"total_stock": total_stock,
|
||||
"total_value": round(total_value, 2),
|
||||
"pending_purchase": pending_purchase,
|
||||
"pending_sales": pending_sales,
|
||||
"low_stock_products": low_stock
|
||||
}
|
||||
return await dashboard_service.get_dashboard(db_session)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional, List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
from ..services.inventory_service import inventory_service
|
||||
|
||||
|
||||
@@ -8,14 +8,13 @@
|
||||
|
||||
路由前缀: /api/materials
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from typing import Optional, List
|
||||
from typing import List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
@@ -23,22 +22,11 @@ from ..schemas import (
|
||||
MaterialSupplierResponse,
|
||||
MaterialPriceTrendResponse
|
||||
)
|
||||
from ..services.material_service import material_service
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["物料管理"])
|
||||
|
||||
|
||||
async def _get_product(db_session: AsyncSession, product_id: int) -> Product:
|
||||
result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/{product_id}/price-history", response_model=MaterialPriceHistoryResponse, status_code=201)
|
||||
async def add_material_price_history(
|
||||
product_id: int,
|
||||
@@ -46,42 +34,7 @@ async def add_material_price_history(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
# 检查供应商是否存在
|
||||
if price_data.supplier_id:
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == price_data.supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
if not supplier_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="供应商不存在")
|
||||
|
||||
price_history = MaterialPriceHistory(
|
||||
product_id=product_id,
|
||||
price=price_data.price,
|
||||
supplier_id=price_data.supplier_id,
|
||||
remark=price_data.remark
|
||||
)
|
||||
db_session.add(price_history)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(price_history)
|
||||
|
||||
# 更新产品的成本价格为最新价格
|
||||
product.cost_price = price_data.price
|
||||
await db_session.flush()
|
||||
|
||||
return MaterialPriceHistoryResponse(
|
||||
id=price_history.id,
|
||||
product_id=price_history.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
price=price_history.price,
|
||||
effective_date=price_history.effective_date,
|
||||
supplier_id=price_history.supplier_id,
|
||||
supplier_name=price_history.supplier.name if price_history.supplier else None,
|
||||
remark=price_history.remark,
|
||||
created_at=price_history.created_at
|
||||
)
|
||||
return await material_service.add_price_history(db_session, product_id, price_data, current_user)
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-history", response_model=List[MaterialPriceHistoryResponse])
|
||||
@@ -91,31 +44,7 @@ async def get_material_price_history(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(limit)
|
||||
)
|
||||
price_history_list = result.scalars().all()
|
||||
|
||||
return [
|
||||
MaterialPriceHistoryResponse(
|
||||
id=ph.id,
|
||||
product_id=ph.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
price=ph.price,
|
||||
effective_date=ph.effective_date,
|
||||
supplier_id=ph.supplier_id,
|
||||
supplier_name=ph.supplier.name if ph.supplier else None,
|
||||
remark=ph.remark,
|
||||
created_at=ph.created_at
|
||||
)
|
||||
for ph in price_history_list
|
||||
]
|
||||
return await material_service.get_price_history(db_session, product_id, limit)
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-trend", response_model=MaterialPriceTrendResponse)
|
||||
@@ -125,45 +54,7 @@ async def get_material_price_trend(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
# 计算价格趋势
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(months)
|
||||
)
|
||||
price_history_list = result.scalars().all()
|
||||
|
||||
if not price_history_list:
|
||||
raise HTTPException(status_code=404, detail="无价格历史记录")
|
||||
|
||||
prices = [ph.price for ph in reversed(price_history_list)]
|
||||
dates = [ph.effective_date for ph in reversed(price_history_list)]
|
||||
|
||||
# 计算价格变化
|
||||
current_price = price_history_list[0].price
|
||||
first_price = price_history_list[-1].price
|
||||
price_change = current_price - first_price
|
||||
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
|
||||
|
||||
return MaterialPriceTrendResponse(
|
||||
product_id=product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
current_price=current_price,
|
||||
price_change=round(price_change, 2),
|
||||
price_change_percent=round(price_change_percent, 2),
|
||||
price_history=[
|
||||
{
|
||||
"date": ph.effective_date,
|
||||
"price": ph.price,
|
||||
"supplier_name": ph.supplier.name if ph.supplier else None
|
||||
}
|
||||
for ph in price_history_list
|
||||
]
|
||||
)
|
||||
return await material_service.get_price_trend(db_session, product_id, months)
|
||||
|
||||
|
||||
@router.post("/{product_id}/suppliers", response_model=MaterialSupplierResponse, status_code=201)
|
||||
@@ -173,63 +64,7 @@ async def add_material_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
# 检查供应商是否存在
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == supplier_data.supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=400, detail="供应商不存在")
|
||||
|
||||
# 检查是否已存在关联
|
||||
existing = await db_session.execute(
|
||||
select(MaterialSupplier)
|
||||
.where(
|
||||
MaterialSupplier.product_id == product_id,
|
||||
MaterialSupplier.supplier_id == supplier_data.supplier_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
|
||||
|
||||
# 如果设置为主要供应商,将其他供应商设置为非主要
|
||||
if supplier_data.is_primary:
|
||||
await db_session.execute(
|
||||
MaterialSupplier.__table__.update()
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
|
||||
material_supplier = MaterialSupplier(
|
||||
product_id=product_id,
|
||||
supplier_id=supplier_data.supplier_id,
|
||||
is_primary=supplier_data.is_primary,
|
||||
contact_person=supplier_data.contact_person,
|
||||
contact_phone=supplier_data.contact_phone,
|
||||
lead_time=supplier_data.lead_time,
|
||||
min_order_quantity=supplier_data.min_order_quantity
|
||||
)
|
||||
db_session.add(material_supplier)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(material_supplier)
|
||||
|
||||
return MaterialSupplierResponse(
|
||||
id=material_supplier.id,
|
||||
product_id=material_supplier.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
supplier_id=material_supplier.supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
is_primary=material_supplier.is_primary,
|
||||
contact_person=material_supplier.contact_person,
|
||||
contact_phone=material_supplier.contact_phone,
|
||||
lead_time=material_supplier.lead_time,
|
||||
min_order_quantity=material_supplier.min_order_quantity,
|
||||
created_at=material_supplier.created_at,
|
||||
updated_at=material_supplier.updated_at
|
||||
)
|
||||
return await material_service.add_material_supplier(db_session, product_id, supplier_data, current_user)
|
||||
|
||||
|
||||
@router.get("/{product_id}/suppliers", response_model=List[MaterialSupplierResponse])
|
||||
@@ -238,33 +73,7 @@ async def get_material_suppliers(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product = await _get_product(db_session, product_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier)
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
supplier_list = result.scalars().all()
|
||||
|
||||
return [
|
||||
MaterialSupplierResponse(
|
||||
id=ms.id,
|
||||
product_id=ms.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
supplier_id=ms.supplier_id,
|
||||
supplier_name=ms.supplier.name if ms.supplier else None,
|
||||
is_primary=ms.is_primary,
|
||||
contact_person=ms.contact_person,
|
||||
contact_phone=ms.contact_phone,
|
||||
lead_time=ms.lead_time,
|
||||
min_order_quantity=ms.min_order_quantity,
|
||||
created_at=ms.created_at,
|
||||
updated_at=ms.updated_at
|
||||
)
|
||||
for ms in supplier_list
|
||||
]
|
||||
return await material_service.get_material_suppliers(db_session, product_id)
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}")
|
||||
@@ -273,17 +82,7 @@ async def remove_material_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
|
||||
)
|
||||
material_supplier = result.scalar_one_or_none()
|
||||
if not material_supplier:
|
||||
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
|
||||
|
||||
await db_session.delete(material_supplier)
|
||||
await db_session.flush()
|
||||
|
||||
return {"message": "物料供应商关联已删除"}
|
||||
return await material_service.remove_material_supplier(db_session, supplier_id, current_user)
|
||||
|
||||
|
||||
@router.get("/suppliers/{supplier_id}/materials", response_model=List[MaterialSupplierResponse])
|
||||
@@ -292,36 +91,4 @@ async def get_supplier_materials(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
# 检查供应商是否存在
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier)
|
||||
.where(MaterialSupplier.supplier_id == supplier_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
material_list = result.scalars().all()
|
||||
|
||||
return [
|
||||
MaterialSupplierResponse(
|
||||
id=ms.id,
|
||||
product_id=ms.product_id,
|
||||
product_sku=ms.product.sku if ms.product else None,
|
||||
product_name=ms.product.name if ms.product else None,
|
||||
supplier_id=ms.supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
is_primary=ms.is_primary,
|
||||
contact_person=ms.contact_person,
|
||||
contact_phone=ms.contact_phone,
|
||||
lead_time=ms.lead_time,
|
||||
min_order_quantity=ms.min_order_quantity,
|
||||
created_at=ms.created_at,
|
||||
updated_at=ms.updated_at
|
||||
)
|
||||
for ms in material_list
|
||||
]
|
||||
return await material_service.get_supplier_materials(db_session, supplier_id)
|
||||
|
||||
@@ -9,65 +9,20 @@
|
||||
|
||||
路由前缀: /api/products
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_, func, delete
|
||||
from typing import Optional, List, Dict
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Product, ProductMaterial, STPFile, ProcessingTask
|
||||
from ..schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductBOMUpdate,
|
||||
ProductBOMResponse,
|
||||
ProductMaterialItemResponse
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from ..schemas import ProductBOMResponse, ProductBOMUpdate, ProductCreate, ProductResponse
|
||||
from ..services.product_service import product_service
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
|
||||
|
||||
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
|
||||
if not product_ids:
|
||||
return {}
|
||||
result = await db_session.execute(
|
||||
select(
|
||||
ProductMaterial.finished_product_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
Product.cost_price * ProductMaterial.quantity
|
||||
),
|
||||
0
|
||||
)
|
||||
)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(product_ids))
|
||||
.group_by(ProductMaterial.finished_product_id)
|
||||
)
|
||||
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
|
||||
|
||||
|
||||
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
sku=product.sku,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
category=product.category,
|
||||
unit=product.unit,
|
||||
item_type=product.item_type,
|
||||
cost_price=Decimal(str(product.cost_price or 0)),
|
||||
sale_price=Decimal(str(product.sale_price or 0)),
|
||||
min_stock=product.min_stock,
|
||||
max_stock=product.max_stock,
|
||||
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
|
||||
is_active=product.is_active,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ProductResponse])
|
||||
async def list_products(
|
||||
@@ -79,21 +34,7 @@ async def list_products(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
query = select(Product).where(Product.is_active == True)
|
||||
|
||||
if search:
|
||||
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||||
if category:
|
||||
query = query.where(Product.category == category)
|
||||
if item_type:
|
||||
query = query.where(Product.item_type == item_type)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
products = result.scalars().all()
|
||||
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
|
||||
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
|
||||
return await product_service.list_products(db_session, skip, limit, search, category, item_type)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductResponse, status_code=201)
|
||||
@@ -102,21 +43,7 @@ async def create_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if product_data.item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product_dict = product_data.dict()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
product = Product(**product_dict)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
return await product_service.create_product(db_session, product_data, current_user)
|
||||
|
||||
|
||||
@router.post("/from-task/{task_id}", response_model=ProductResponse, status_code=201)
|
||||
@@ -125,62 +52,7 @@ async def create_product_from_task(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""从模具分析任务创建进销存成品,回写 stp_files.product_id(P2-1)"""
|
||||
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="分析任务不存在")
|
||||
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
|
||||
stp_file = stp_result.scalar_one_or_none()
|
||||
if not stp_file:
|
||||
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
|
||||
|
||||
# 已关联成品则直接返回(幂等)
|
||||
if stp_file.product_id:
|
||||
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
|
||||
product = existed.scalar_one_or_none()
|
||||
if product:
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
# 生成唯一 SKU:MI{stp_file_id},冲突则追加序号
|
||||
base_sku = f"MI{stp_file_id}"
|
||||
sku = base_sku
|
||||
n = 1
|
||||
while True:
|
||||
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
|
||||
if not conflict.scalar_one_or_none():
|
||||
break
|
||||
n += 1
|
||||
sku = f"{base_sku}-{n}"
|
||||
|
||||
name = Path(stp_file.original_filename or f"mold_{stp_file_id}").stem or f"模具分析-{stp_file_id}"
|
||||
desc_parts = []
|
||||
if stp_file.volume:
|
||||
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
|
||||
if stp_file.product_weight:
|
||||
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
|
||||
if stp_file.surface_area:
|
||||
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
|
||||
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
|
||||
|
||||
product = Product(
|
||||
sku=sku,
|
||||
name=name,
|
||||
description=description,
|
||||
category="模具成品",
|
||||
unit="件",
|
||||
item_type="finished",
|
||||
cost_price=0,
|
||||
sale_price=0,
|
||||
min_stock=0,
|
||||
max_stock=0,
|
||||
)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
stp_file.product_id = product.id
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
return await product_service.create_product_from_task(db_session, task_id, current_user)
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductResponse)
|
||||
@@ -190,25 +62,7 @@ async def update_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Product).where(Product.id == product_id))
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product_data.item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
|
||||
product_dict = product_data.dict()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
|
||||
for key, value in product_dict.items():
|
||||
setattr(product, key, value)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
|
||||
return _build_product_response(product, material_cost_map.get(product.id, 0))
|
||||
return await product_service.update_product(db_session, product_id, product_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{product_id}")
|
||||
@@ -217,14 +71,7 @@ async def delete_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
result = await db_session.execute(select(Product).where(Product.id == product_id))
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
product.is_active = False
|
||||
await db_session.flush()
|
||||
return {"message": "产品已删除"}
|
||||
return await product_service.delete_product(db_session, product_id, current_user)
|
||||
|
||||
|
||||
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
|
||||
@@ -233,44 +80,7 @@ async def get_product_bom(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id == product_id)
|
||||
.order_by(ProductMaterial.id.asc())
|
||||
)
|
||||
|
||||
items: List[ProductMaterialItemResponse] = []
|
||||
total_material_cost = Decimal("0")
|
||||
for bom, material in bom_result.all():
|
||||
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
|
||||
total_material_cost += line_cost
|
||||
items.append(
|
||||
ProductMaterialItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
quantity=Decimal(str(bom.quantity)),
|
||||
unit_cost=Decimal(str(material.cost_price or 0)),
|
||||
line_cost=line_cost,
|
||||
)
|
||||
)
|
||||
|
||||
return ProductBOMResponse(
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
total_material_cost=total_material_cost,
|
||||
items=items,
|
||||
)
|
||||
return await product_service.get_product_bom(db_session, product_id)
|
||||
|
||||
|
||||
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
|
||||
@@ -280,46 +90,4 @@ async def replace_product_bom(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
material_ids = [item.material_id for item in payload.items]
|
||||
if len(material_ids) != len(set(material_ids)):
|
||||
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
|
||||
|
||||
if material_ids:
|
||||
material_result = await db_session.execute(
|
||||
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
|
||||
)
|
||||
materials = material_result.scalars().all()
|
||||
material_map = {m.id: m for m in materials}
|
||||
if len(material_map) != len(material_ids):
|
||||
raise HTTPException(status_code=400, detail="存在无效物料")
|
||||
invalid_materials = [m.name for m in materials if m.item_type != "material"]
|
||||
if invalid_materials:
|
||||
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
|
||||
else:
|
||||
material_map = {}
|
||||
|
||||
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||||
|
||||
for item in payload.items:
|
||||
if item.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||||
db_session.add(
|
||||
ProductMaterial(
|
||||
finished_product_id=product_id,
|
||||
material_product_id=item.material_id,
|
||||
quantity=item.quantity,
|
||||
loss_rate=item.loss_rate,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.flush()
|
||||
return await get_product_bom(product_id, db_session, current_user)
|
||||
return await product_service.replace_product_bom(db_session, product_id, payload, current_user)
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
PurchaseDemandResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..services.stock_movement_service import stock_movement_service
|
||||
|
||||
|
||||
@@ -9,16 +9,15 @@
|
||||
|
||||
路由前缀: /api/suppliers
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Supplier
|
||||
from shared.models.identity import User
|
||||
from ..schemas import SupplierCreate, SupplierResponse
|
||||
from ..services.master_data_service import master_data_service
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
|
||||
|
||||
@@ -31,12 +30,7 @@ async def list_suppliers(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
query = select(Supplier).where(Supplier.is_active == True)
|
||||
if search:
|
||||
query = query.where(Supplier.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [SupplierResponse.from_orm(s) for s in result.scalars().all()]
|
||||
return await master_data_service.list_suppliers(db_session, skip, limit, search)
|
||||
|
||||
|
||||
@router.post("", response_model=SupplierResponse, status_code=201)
|
||||
@@ -45,15 +39,7 @@ async def create_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
data = supplier_data.dict()
|
||||
if not data.get("code"):
|
||||
data["code"] = f"S{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
supplier = Supplier(**data)
|
||||
db_session.add(supplier)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(supplier)
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
return await master_data_service.create_supplier(db_session, supplier_data, current_user)
|
||||
|
||||
|
||||
@router.put("/{supplier_id}", response_model=SupplierResponse)
|
||||
@@ -63,17 +49,7 @@ async def update_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
|
||||
supplier = result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
for key, value in supplier_data.dict().items():
|
||||
setattr(supplier, key, value)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.refresh(supplier)
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
return await master_data_service.update_supplier(db_session, supplier_id, supplier_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
@@ -82,11 +58,4 @@ async def delete_supplier(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
result = await db_session.execute(select(Supplier).where(Supplier.id == supplier_id))
|
||||
supplier = result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
supplier.is_active = False
|
||||
await db_session.flush()
|
||||
return {"message": "供应商已删除"}
|
||||
return await master_data_service.delete_supplier(db_session, supplier_id, current_user)
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Warehouse
|
||||
from shared.models.identity import User
|
||||
from ..schemas import WarehouseCreate, WarehouseResponse
|
||||
from ..services.master_data_service import master_data_service
|
||||
|
||||
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
|
||||
|
||||
@@ -26,10 +25,7 @@ async def list_warehouses(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
|
||||
)
|
||||
return [WarehouseResponse.from_orm(w) for w in result.scalars().all()]
|
||||
return await master_data_service.list_warehouses(db_session)
|
||||
|
||||
|
||||
@router.post("", response_model=WarehouseResponse, status_code=201)
|
||||
@@ -38,12 +34,4 @@ async def create_warehouse(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
data = warehouse_data.dict()
|
||||
if not data.get("code"):
|
||||
data["code"] = f"W{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
warehouse = Warehouse(**data)
|
||||
db_session.add(warehouse)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(warehouse)
|
||||
return WarehouseResponse.from_orm(warehouse)
|
||||
return await master_data_service.create_warehouse(db_session, warehouse_data, current_user)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""inventory 域模型出口(目录 / 仓储 / 交易 / 财务四个域文件)。
|
||||
|
||||
全量模型注册点见 shared/models/base.py 模块 docstring;
|
||||
业务代码按需 `from inventory.models import Product, ...`。
|
||||
"""
|
||||
from inventory.models.catalog import (
|
||||
Product,
|
||||
ProductMaterial,
|
||||
MaterialPriceHistory,
|
||||
MaterialSupplier,
|
||||
Supplier,
|
||||
Customer,
|
||||
)
|
||||
from inventory.models.warehouse import (
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
)
|
||||
from inventory.models.trading import (
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from inventory.models.finance import (
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Product",
|
||||
"ProductMaterial",
|
||||
"MaterialPriceHistory",
|
||||
"MaterialSupplier",
|
||||
"Supplier",
|
||||
"Customer",
|
||||
"Warehouse",
|
||||
"Inventory",
|
||||
"StockMovement",
|
||||
"PurchaseOrder",
|
||||
"PurchaseOrderItem",
|
||||
"SalesOrder",
|
||||
"SalesOrderItem",
|
||||
"FinanceTransaction",
|
||||
"FinanceAllocation",
|
||||
]
|
||||
@@ -0,0 +1,166 @@
|
||||
"""inventory 目录域模型:成品/物料/BOM/价格/供应商/客户。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
跨模块桥接只保留裸 FK(base.py 约定):operator 类字段 user_id -> users.id 不建 relationship。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Numeric, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""产品表"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sku = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
unit = Column(String(20), default="件")
|
||||
item_type = Column(String(20), default="finished", index=True)
|
||||
cost_price = Column(Numeric(12, 2), default=0)
|
||||
sale_price = Column(Numeric(12, 2), default=0)
|
||||
min_stock = Column(Integer, default=0)
|
||||
max_stock = Column(Integer, default=1000)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
stock_movements = relationship("StockMovement", back_populates="product")
|
||||
bom_materials = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.finished_product_id",
|
||||
back_populates="finished_product",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
used_in_products = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.material_product_id",
|
||||
back_populates="material_product"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
||||
|
||||
|
||||
class ProductMaterial(Base):
|
||||
__tablename__ = "product_materials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
loss_rate = Column(Numeric(5, 4), default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
finished_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[finished_product_id],
|
||||
back_populates="bom_materials"
|
||||
)
|
||||
material_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[material_product_id],
|
||||
back_populates="used_in_products"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
|
||||
|
||||
|
||||
class MaterialPriceHistory(Base):
|
||||
"""物料价格历史表"""
|
||||
__tablename__ = "material_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
price = Column(Numeric(12, 2), nullable=False)
|
||||
effective_date = Column(DateTime, default=func.now(), index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
product = relationship("Product", backref="price_history")
|
||||
supplier = relationship("Supplier", backref="price_history")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
|
||||
|
||||
|
||||
class MaterialSupplier(Base):
|
||||
"""物料供应商关联表"""
|
||||
__tablename__ = "material_suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
is_primary = Column(Boolean, default=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
contact_phone = Column(String(50), nullable=True)
|
||||
lead_time = Column(Integer, nullable=True) # 交货周期(天)
|
||||
min_order_quantity = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", backref="suppliers")
|
||||
supplier = relationship("Supplier", backref="materials")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""供应商表"""
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Supplier(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""客户表"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
credit_limit = Column(Numeric(12, 2), default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
sales_orders = relationship("SalesOrder", back_populates="customer")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Customer(id={self.id}, name='{self.name}')>"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""inventory 财务域模型:收付款交易与订单分摊。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
txn_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
txn_type = Column(String(20), nullable=False, index=True)
|
||||
partner_type = Column(String(20), nullable=False, index=True)
|
||||
partner_id = Column(Integer, nullable=False, index=True)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
txn_date = Column(DateTime, default=func.now(), index=True)
|
||||
method = Column(String(30), default="bank")
|
||||
account_name = Column(String(100), nullable=True)
|
||||
status = Column(String(20), default="confirmed", index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
allocations = relationship("FinanceAllocation", back_populates="transaction", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceTransaction(txn_no='{self.txn_no}', txn_type='{self.txn_type}', amount={self.amount})>"
|
||||
|
||||
|
||||
class FinanceAllocation(Base):
|
||||
__tablename__ = "finance_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(Integer, ForeignKey("finance_transactions.id"), nullable=False, index=True)
|
||||
order_type = Column(String(20), nullable=False, index=True)
|
||||
order_id = Column(Integer, nullable=False, index=True)
|
||||
allocated_amount = Column(Numeric(12, 2), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
transaction = relationship("FinanceTransaction", back_populates="allocations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceAllocation(transaction_id={self.transaction_id}, order_type='{self.order_type}', amount={self.allocated_amount})>"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""inventory 交易域模型:采购订单/销售订单及明细。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, Numeric, ForeignKey, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
"""采购订单表"""
|
||||
__tablename__ = "purchase_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
expected_date = Column(Date, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
paid_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
# 状态变更时间
|
||||
received_date = Column(DateTime, nullable=True) # 已收货时间
|
||||
paid_date = Column(DateTime, nullable=True) # 已付款时间
|
||||
|
||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
"""采购订单明细表"""
|
||||
__tablename__ = "purchase_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity", name="ck_purchase_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
received_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("PurchaseOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
|
||||
class SalesOrder(Base):
|
||||
"""销售订单表"""
|
||||
__tablename__ = "sales_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
delivery_date = Column(Date, nullable=True)
|
||||
manufacturing_date = Column(DateTime, nullable=True)
|
||||
actual_delivery_date = Column(DateTime, nullable=True)
|
||||
actual_payment_date = Column(DateTime, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
production_status = Column(String(20), default="not_started", index=True)
|
||||
production_no = Column(String(50), nullable=True, index=True)
|
||||
planned_material_cost = Column(Numeric(12, 2), default=0)
|
||||
actual_material_cost = Column(Numeric(12, 2), default=0)
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
received_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
customer = relationship("Customer", back_populates="sales_orders")
|
||||
items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class SalesOrderItem(Base):
|
||||
"""销售订单明细表"""
|
||||
__tablename__ = "sales_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity", name="ck_sales_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
delivered_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("SalesOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""inventory 仓储域模型:仓库/库存/库存流水。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Numeric, ForeignKey, UniqueConstraint, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class Warehouse(Base):
|
||||
"""仓库表"""
|
||||
__tablename__ = "warehouses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
address = Column(Text, nullable=True)
|
||||
manager = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
inventories = relationship("Inventory", back_populates="warehouse")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Warehouse(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Inventory(Base):
|
||||
"""库存表"""
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"),
|
||||
CheckConstraint("quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity", name="ck_inventory_qty_nonnegative"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), default=0)
|
||||
locked_quantity = Column(Numeric(12, 4), default=0)
|
||||
batch_number = Column(String(50), nullable=True)
|
||||
location = Column(String(100), nullable=True)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", back_populates="inventory")
|
||||
warehouse = relationship("Warehouse", back_populates="inventories")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory(product_id={self.product_id}, quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.locked_quantity
|
||||
|
||||
|
||||
class StockMovement(Base):
|
||||
"""库存变动记录表"""
|
||||
__tablename__ = "stock_movements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
||||
movement_type = Column(String(20), nullable=False)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
before_quantity = Column(Numeric(12, 4), default=0)
|
||||
after_quantity = Column(Numeric(12, 4), default=0)
|
||||
reference_type = Column(String(50), nullable=True)
|
||||
reference_id = Column(Integer, nullable=True)
|
||||
reference_no = Column(String(50), nullable=True)
|
||||
unit_price = Column(Numeric(12, 2), nullable=True)
|
||||
total_amount = Column(Numeric(12, 2), nullable=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
product = relationship("Product", back_populates="stock_movements")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StockMovement(id={self.id}, type='{self.movement_type}', qty={self.quantity})>"
|
||||
@@ -54,7 +54,8 @@ from .material_schemas import (
|
||||
MaterialPriceHistoryResponse,
|
||||
MaterialSupplierCreate,
|
||||
MaterialSupplierResponse,
|
||||
MaterialPriceTrendResponse
|
||||
MaterialPriceTrendResponse,
|
||||
PriceHistoryItem
|
||||
)
|
||||
from .purchase_demand_schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
@@ -89,6 +90,6 @@ __all__ = [
|
||||
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
|
||||
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
|
||||
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
|
||||
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
|
||||
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse", "PriceHistoryItem",
|
||||
"PurchaseDemandCalculateRequest", "PurchaseDemandItemResponse", "PurchaseDemandResponse",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -20,5 +20,4 @@ class CustomerResponse(BaseModel):
|
||||
email: Optional[str]
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List, Literal
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -39,8 +39,7 @@ class FinanceAllocationResponse(BaseModel):
|
||||
order_id: int
|
||||
allocated_amount: Decimal
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FinanceTransactionResponse(BaseModel):
|
||||
@@ -59,8 +58,7 @@ class FinanceTransactionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
allocations: List[FinanceAllocationResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FinanceSummaryResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -14,8 +14,7 @@ class InventoryResponse(BaseModel):
|
||||
locked_quantity: Decimal
|
||||
available_quantity: Decimal
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryCreate(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
定义物料价格历史和物料供应商关联的数据结构
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
@@ -27,9 +27,8 @@ class MaterialPriceHistoryResponse(BaseModel):
|
||||
supplier_name: Optional[str]
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MaterialSupplierCreate(BaseModel):
|
||||
@@ -57,9 +56,8 @@ class MaterialSupplierResponse(BaseModel):
|
||||
min_order_quantity: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PriceHistoryItem(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -33,8 +33,7 @@ class ProductResponse(BaseModel):
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProductMaterialItemUpdate(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
@@ -34,8 +34,7 @@ class PurchaseOrderResponse(BaseModel):
|
||||
received_date: Optional[datetime]
|
||||
paid_date: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PurchaseOrderItemResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
@@ -43,8 +43,7 @@ class SalesOrderResponse(BaseModel):
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SalesOrderItemResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -27,5 +27,4 @@ class StockMovementResponse(BaseModel):
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -20,5 +20,4 @@ class SupplierResponse(BaseModel):
|
||||
email: Optional[str]
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -19,5 +19,4 @@ class WarehouseResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_default: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""仪表盘聚合业务服务层
|
||||
|
||||
将 dashboard_routes 中的聚合查询与统计编排下沉到此,
|
||||
路由层只做依赖注入与响应返回。
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from inventory.models import Customer, Inventory, Product, PurchaseOrder, SalesOrder, Supplier, Warehouse
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""仪表盘统计服务"""
|
||||
|
||||
@staticmethod
|
||||
async def get_dashboard(db_session: AsyncSession) -> dict:
|
||||
material_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
|
||||
) or 0
|
||||
finished_product_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
|
||||
) or 0
|
||||
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) or 0
|
||||
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) or 0
|
||||
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) or 0
|
||||
|
||||
total_stock = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
total_value = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity * Product.cost_price))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
|
||||
pending_purchase = await db_session.scalar(
|
||||
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
|
||||
) or 0
|
||||
pending_sales = await db_session.scalar(
|
||||
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
|
||||
) or 0
|
||||
|
||||
low_stock_products = await db_session.execute(
|
||||
select(Product, Inventory)
|
||||
.join(Inventory, Product.id == Inventory.product_id)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Inventory.quantity <= Product.min_stock)
|
||||
.limit(10)
|
||||
)
|
||||
low_stock = [
|
||||
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
|
||||
for p, i in low_stock_products.all()
|
||||
]
|
||||
|
||||
return {
|
||||
"finished_product_count": finished_product_count,
|
||||
"material_count": material_count,
|
||||
"supplier_count": supplier_count,
|
||||
"customer_count": customer_count,
|
||||
"warehouse_count": warehouse_count,
|
||||
"total_stock": total_stock,
|
||||
"total_value": round(total_value, 2),
|
||||
"pending_purchase": pending_purchase,
|
||||
"pending_sales": pending_sales,
|
||||
"low_stock_products": low_stock,
|
||||
}
|
||||
|
||||
|
||||
dashboard_service = DashboardService()
|
||||
@@ -11,18 +11,8 @@ from typing import Optional, List, Dict, Tuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Supplier,
|
||||
Product,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Supplier, Product, SalesOrder, SalesOrderItem, PurchaseOrder, PurchaseOrderItem, FinanceTransaction, FinanceAllocation
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
|
||||
@@ -10,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Warehouse, Inventory
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""进销存主数据业务服务层
|
||||
|
||||
将 customer / supplier / warehouse 这类主数据 CRUD 编排从路由层下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Supplier, Warehouse
|
||||
from ..schemas import (
|
||||
CustomerCreate,
|
||||
CustomerResponse,
|
||||
SupplierCreate,
|
||||
SupplierResponse,
|
||||
WarehouseCreate,
|
||||
WarehouseResponse,
|
||||
)
|
||||
|
||||
|
||||
def _generate_code(prefix: str) -> str:
|
||||
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
|
||||
async def _get_entity_or_404(
|
||||
db_session: AsyncSession,
|
||||
model: Type[Customer] | Type[Supplier] | Type[Warehouse],
|
||||
entity_id: int,
|
||||
detail: str,
|
||||
):
|
||||
result = await db_session.execute(select(model).where(model.id == entity_id))
|
||||
entity = result.scalar_one_or_none()
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
return entity
|
||||
|
||||
|
||||
def _build_customer_response(customer: Customer) -> CustomerResponse:
|
||||
return CustomerResponse.model_validate(customer)
|
||||
|
||||
|
||||
def _build_supplier_response(supplier: Supplier) -> SupplierResponse:
|
||||
return SupplierResponse.model_validate(supplier)
|
||||
|
||||
|
||||
def _build_warehouse_response(warehouse: Warehouse) -> WarehouseResponse:
|
||||
return WarehouseResponse.model_validate(warehouse)
|
||||
|
||||
|
||||
class MasterDataService:
|
||||
"""客户 / 供应商 / 仓库主数据服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_customers(
|
||||
db_session: AsyncSession,
|
||||
skip: int,
|
||||
limit: int,
|
||||
search: Optional[str],
|
||||
) -> List[CustomerResponse]:
|
||||
query: Select = select(Customer).where(Customer.is_active == True)
|
||||
if search:
|
||||
query = query.where(Customer.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [_build_customer_response(customer) for customer in result.scalars().all()]
|
||||
|
||||
@staticmethod
|
||||
async def create_customer(
|
||||
db_session: AsyncSession,
|
||||
customer_data: CustomerCreate,
|
||||
current_user: User,
|
||||
) -> CustomerResponse:
|
||||
data = customer_data.model_dump()
|
||||
if not data.get("code"):
|
||||
data["code"] = _generate_code("C")
|
||||
|
||||
customer = Customer(**data)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(customer)
|
||||
return _build_customer_response(customer)
|
||||
|
||||
@staticmethod
|
||||
async def update_customer(
|
||||
db_session: AsyncSession,
|
||||
customer_id: int,
|
||||
customer_data: CustomerCreate,
|
||||
current_user: User,
|
||||
) -> CustomerResponse:
|
||||
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
|
||||
for key, value in customer_data.model_dump().items():
|
||||
setattr(customer, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(customer)
|
||||
return _build_customer_response(customer)
|
||||
|
||||
@staticmethod
|
||||
async def delete_customer(
|
||||
db_session: AsyncSession,
|
||||
customer_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
|
||||
customer.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "客户已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def list_suppliers(
|
||||
db_session: AsyncSession,
|
||||
skip: int,
|
||||
limit: int,
|
||||
search: Optional[str],
|
||||
) -> List[SupplierResponse]:
|
||||
query: Select = select(Supplier).where(Supplier.is_active == True)
|
||||
if search:
|
||||
query = query.where(Supplier.name.ilike(f"%{search}%"))
|
||||
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
return [_build_supplier_response(supplier) for supplier in result.scalars().all()]
|
||||
|
||||
@staticmethod
|
||||
async def create_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_data: SupplierCreate,
|
||||
current_user: User,
|
||||
) -> SupplierResponse:
|
||||
data = supplier_data.model_dump()
|
||||
if not data.get("code"):
|
||||
data["code"] = _generate_code("S")
|
||||
|
||||
supplier = Supplier(**data)
|
||||
db_session.add(supplier)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(supplier)
|
||||
return _build_supplier_response(supplier)
|
||||
|
||||
@staticmethod
|
||||
async def update_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
supplier_data: SupplierCreate,
|
||||
current_user: User,
|
||||
) -> SupplierResponse:
|
||||
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
|
||||
for key, value in supplier_data.model_dump().items():
|
||||
setattr(supplier, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(supplier)
|
||||
return _build_supplier_response(supplier)
|
||||
|
||||
@staticmethod
|
||||
async def delete_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
|
||||
supplier.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "供应商已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def list_warehouses(
|
||||
db_session: AsyncSession,
|
||||
) -> List[WarehouseResponse]:
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
|
||||
)
|
||||
return [_build_warehouse_response(warehouse) for warehouse in result.scalars().all()]
|
||||
|
||||
@staticmethod
|
||||
async def create_warehouse(
|
||||
db_session: AsyncSession,
|
||||
warehouse_data: WarehouseCreate,
|
||||
current_user: User,
|
||||
) -> WarehouseResponse:
|
||||
data = warehouse_data.model_dump()
|
||||
if not data.get("code"):
|
||||
data["code"] = _generate_code("W")
|
||||
|
||||
warehouse = Warehouse(**data)
|
||||
db_session.add(warehouse)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(warehouse)
|
||||
return _build_warehouse_response(warehouse)
|
||||
|
||||
|
||||
master_data_service = MasterDataService()
|
||||
@@ -0,0 +1,284 @@
|
||||
"""物料管理业务服务层
|
||||
|
||||
将 material_routes 中的价格历史、价格趋势、物料供应商关联等业务编排下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import desc, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User
|
||||
from inventory.models import MaterialPriceHistory, MaterialSupplier, Product, Supplier
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
MaterialPriceTrendResponse,
|
||||
MaterialSupplierCreate,
|
||||
MaterialSupplierResponse,
|
||||
PriceHistoryItem,
|
||||
)
|
||||
|
||||
|
||||
async def _get_material_product(db_session: AsyncSession, product_id: int) -> Product:
|
||||
result = await db_session.execute(
|
||||
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
|
||||
return product
|
||||
|
||||
|
||||
async def _get_active_supplier(db_session: AsyncSession, supplier_id: int, detail: str = "供应商不存在") -> Supplier:
|
||||
result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=400 if detail == "供应商不存在" else 404, detail=detail)
|
||||
return supplier
|
||||
|
||||
|
||||
def _build_price_history_response(
|
||||
product: Product,
|
||||
price_history: MaterialPriceHistory,
|
||||
supplier_name: str | None,
|
||||
) -> MaterialPriceHistoryResponse:
|
||||
return MaterialPriceHistoryResponse(
|
||||
id=price_history.id,
|
||||
product_id=price_history.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
price=price_history.price,
|
||||
effective_date=price_history.effective_date,
|
||||
supplier_id=price_history.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
remark=price_history.remark,
|
||||
created_at=price_history.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_material_supplier_response(
|
||||
product: Product,
|
||||
material_supplier: MaterialSupplier,
|
||||
supplier_name: str | None,
|
||||
) -> MaterialSupplierResponse:
|
||||
return MaterialSupplierResponse(
|
||||
id=material_supplier.id,
|
||||
product_id=material_supplier.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
supplier_id=material_supplier.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
is_primary=material_supplier.is_primary,
|
||||
contact_person=material_supplier.contact_person,
|
||||
contact_phone=material_supplier.contact_phone,
|
||||
lead_time=material_supplier.lead_time,
|
||||
min_order_quantity=material_supplier.min_order_quantity,
|
||||
created_at=material_supplier.created_at,
|
||||
updated_at=material_supplier.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class MaterialService:
|
||||
"""物料价格与供应商关联服务"""
|
||||
|
||||
@staticmethod
|
||||
async def add_price_history(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
price_data: MaterialPriceHistoryCreate,
|
||||
current_user: User,
|
||||
) -> MaterialPriceHistoryResponse:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
|
||||
supplier_name = None
|
||||
if price_data.supplier_id:
|
||||
supplier = await _get_active_supplier(db_session, price_data.supplier_id)
|
||||
supplier_name = supplier.name
|
||||
|
||||
price_history = MaterialPriceHistory(
|
||||
product_id=product_id,
|
||||
price=price_data.price,
|
||||
supplier_id=price_data.supplier_id,
|
||||
remark=price_data.remark,
|
||||
)
|
||||
db_session.add(price_history)
|
||||
product.cost_price = price_data.price
|
||||
await db_session.commit()
|
||||
await db_session.refresh(price_history)
|
||||
|
||||
return _build_price_history_response(product, price_history, supplier_name)
|
||||
|
||||
@staticmethod
|
||||
async def get_price_history(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
limit: int,
|
||||
) -> List[MaterialPriceHistoryResponse]:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory, Supplier)
|
||||
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(limit)
|
||||
)
|
||||
return [
|
||||
_build_price_history_response(product, ph, supplier.name if supplier else None)
|
||||
for ph, supplier in result.all()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_price_trend(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
months: int,
|
||||
) -> MaterialPriceTrendResponse:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
result = await db_session.execute(
|
||||
select(MaterialPriceHistory, Supplier)
|
||||
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
|
||||
.where(MaterialPriceHistory.product_id == product_id)
|
||||
.order_by(desc(MaterialPriceHistory.effective_date))
|
||||
.limit(months)
|
||||
)
|
||||
rows = result.all()
|
||||
price_history_list = [ph for ph, _supplier in rows]
|
||||
if not price_history_list:
|
||||
raise HTTPException(status_code=404, detail="无价格历史记录")
|
||||
|
||||
current_price = price_history_list[0].price
|
||||
first_price = price_history_list[-1].price
|
||||
price_change = current_price - first_price
|
||||
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
|
||||
|
||||
return MaterialPriceTrendResponse(
|
||||
product_id=product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
current_price=current_price,
|
||||
price_change=round(price_change, 2),
|
||||
price_change_percent=round(price_change_percent, 2),
|
||||
price_history=[
|
||||
PriceHistoryItem(
|
||||
date=ph.effective_date,
|
||||
price=ph.price,
|
||||
supplier_name=supplier.name if supplier else None,
|
||||
)
|
||||
for ph, supplier in rows
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def add_material_supplier(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
supplier_data: MaterialSupplierCreate,
|
||||
current_user: User,
|
||||
) -> MaterialSupplierResponse:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
supplier = await _get_active_supplier(db_session, supplier_data.supplier_id)
|
||||
|
||||
existing = await db_session.execute(
|
||||
select(MaterialSupplier).where(
|
||||
MaterialSupplier.product_id == product_id,
|
||||
MaterialSupplier.supplier_id == supplier_data.supplier_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
|
||||
|
||||
if supplier_data.is_primary:
|
||||
await db_session.execute(
|
||||
update(MaterialSupplier)
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
|
||||
material_supplier = MaterialSupplier(
|
||||
product_id=product_id,
|
||||
supplier_id=supplier_data.supplier_id,
|
||||
is_primary=supplier_data.is_primary,
|
||||
contact_person=supplier_data.contact_person,
|
||||
contact_phone=supplier_data.contact_phone,
|
||||
lead_time=supplier_data.lead_time,
|
||||
min_order_quantity=supplier_data.min_order_quantity,
|
||||
)
|
||||
db_session.add(material_supplier)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(material_supplier)
|
||||
|
||||
return _build_material_supplier_response(product, material_supplier, supplier.name)
|
||||
|
||||
@staticmethod
|
||||
async def get_material_suppliers(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
) -> List[MaterialSupplierResponse]:
|
||||
product = await _get_material_product(db_session, product_id)
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier, Supplier)
|
||||
.outerjoin(Supplier, MaterialSupplier.supplier_id == Supplier.id)
|
||||
.where(MaterialSupplier.product_id == product_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
return [
|
||||
_build_material_supplier_response(product, ms, supplier.name if supplier else None)
|
||||
for ms, supplier in result.all()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def remove_material_supplier(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
|
||||
)
|
||||
material_supplier = result.scalar_one_or_none()
|
||||
if not material_supplier:
|
||||
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
|
||||
|
||||
await db_session.delete(material_supplier)
|
||||
await db_session.commit()
|
||||
return {"message": "物料供应商关联已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def get_supplier_materials(
|
||||
db_session: AsyncSession,
|
||||
supplier_id: int,
|
||||
) -> List[MaterialSupplierResponse]:
|
||||
supplier = await _get_active_supplier(db_session, supplier_id, detail="供应商不存在")
|
||||
result = await db_session.execute(
|
||||
select(MaterialSupplier, Product)
|
||||
.outerjoin(Product, MaterialSupplier.product_id == Product.id)
|
||||
.where(MaterialSupplier.supplier_id == supplier_id)
|
||||
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
|
||||
)
|
||||
return [
|
||||
MaterialSupplierResponse(
|
||||
id=ms.id,
|
||||
product_id=ms.product_id,
|
||||
product_sku=product.sku if product else None,
|
||||
product_name=product.name if product else None,
|
||||
supplier_id=ms.supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
is_primary=ms.is_primary,
|
||||
contact_person=ms.contact_person,
|
||||
contact_phone=ms.contact_phone,
|
||||
lead_time=ms.lead_time,
|
||||
min_order_quantity=ms.min_order_quantity,
|
||||
created_at=ms.created_at,
|
||||
updated_at=ms.updated_at,
|
||||
)
|
||||
for ms, product in result.all()
|
||||
]
|
||||
|
||||
|
||||
material_service = MaterialService()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""产品管理业务服务层
|
||||
|
||||
将 product_routes 中不跨模块的产品 CRUD / BOM 编排下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from inventory.models import Product, ProductMaterial
|
||||
from ..schemas import (
|
||||
ProductBOMResponse,
|
||||
ProductBOMUpdate,
|
||||
ProductCreate,
|
||||
ProductMaterialItemResponse,
|
||||
ProductResponse,
|
||||
)
|
||||
|
||||
|
||||
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, Decimal]:
|
||||
if not product_ids:
|
||||
return {}
|
||||
result = await db_session.execute(
|
||||
select(
|
||||
ProductMaterial.finished_product_id,
|
||||
func.coalesce(
|
||||
func.sum(Product.cost_price * ProductMaterial.quantity),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(product_ids))
|
||||
.group_by(ProductMaterial.finished_product_id)
|
||||
)
|
||||
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
|
||||
|
||||
|
||||
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
sku=product.sku,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
category=product.category,
|
||||
unit=product.unit,
|
||||
item_type=product.item_type,
|
||||
cost_price=Decimal(str(product.cost_price or 0)),
|
||||
sale_price=Decimal(str(product.sale_price or 0)),
|
||||
min_stock=product.min_stock,
|
||||
max_stock=product.max_stock,
|
||||
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
|
||||
is_active=product.is_active,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_product_or_404(db_session: AsyncSession, product_id: int, active_only: bool = False) -> Product:
|
||||
query = select(Product).where(Product.id == product_id)
|
||||
if active_only:
|
||||
query = query.where(Product.is_active == True)
|
||||
result = await db_session.execute(query)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
return product
|
||||
|
||||
|
||||
def _validate_item_type(item_type: str) -> None:
|
||||
if item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
|
||||
|
||||
async def _build_bom_response(db_session: AsyncSession, product: Product) -> ProductBOMResponse:
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id == product.id)
|
||||
.order_by(ProductMaterial.id.asc())
|
||||
)
|
||||
|
||||
items: List[ProductMaterialItemResponse] = []
|
||||
total_material_cost = Decimal("0")
|
||||
for bom, material in bom_result.all():
|
||||
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
|
||||
total_material_cost += line_cost
|
||||
items.append(
|
||||
ProductMaterialItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
quantity=Decimal(str(bom.quantity)),
|
||||
unit_cost=Decimal(str(material.cost_price or 0)),
|
||||
line_cost=line_cost,
|
||||
)
|
||||
)
|
||||
|
||||
return ProductBOMResponse(
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
total_material_cost=total_material_cost,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
class ProductService:
|
||||
"""产品 CRUD 与 BOM 服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_products(
|
||||
db_session: AsyncSession,
|
||||
skip: int,
|
||||
limit: int,
|
||||
search: Optional[str],
|
||||
category: Optional[str],
|
||||
item_type: Optional[str],
|
||||
) -> List[ProductResponse]:
|
||||
query = select(Product).where(Product.is_active == True)
|
||||
|
||||
if search:
|
||||
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||||
if category:
|
||||
query = query.where(Product.category == category)
|
||||
if item_type:
|
||||
query = query.where(Product.item_type == item_type)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
products = result.scalars().all()
|
||||
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
|
||||
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
|
||||
|
||||
@staticmethod
|
||||
async def create_product(
|
||||
db_session: AsyncSession,
|
||||
product_data: ProductCreate,
|
||||
current_user: User,
|
||||
) -> ProductResponse:
|
||||
_validate_item_type(product_data.item_type)
|
||||
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product_dict = product_data.model_dump()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
product = Product(**product_dict)
|
||||
db_session.add(product)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
@staticmethod
|
||||
async def create_product_from_task(
|
||||
db_session: AsyncSession,
|
||||
task_id: str,
|
||||
current_user: User,
|
||||
) -> ProductResponse:
|
||||
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="分析任务不存在")
|
||||
|
||||
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
|
||||
stp_file = stp_result.scalar_one_or_none()
|
||||
if not stp_file:
|
||||
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
|
||||
|
||||
if stp_file.product_id:
|
||||
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
|
||||
product = existed.scalar_one_or_none()
|
||||
if product:
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
base_sku = f"MI{stp_file.id}"
|
||||
sku = base_sku
|
||||
n = 1
|
||||
while True:
|
||||
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
|
||||
if not conflict.scalar_one_or_none():
|
||||
break
|
||||
n += 1
|
||||
sku = f"{base_sku}-{n}"
|
||||
|
||||
name = Path(stp_file.original_filename or f"mold_{stp_file.id}").stem or f"模具分析-{stp_file.id}"
|
||||
desc_parts = []
|
||||
if stp_file.volume:
|
||||
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
|
||||
if stp_file.product_weight:
|
||||
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
|
||||
if stp_file.surface_area:
|
||||
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
|
||||
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
|
||||
|
||||
product = Product(
|
||||
sku=sku,
|
||||
name=name,
|
||||
description=description,
|
||||
category="模具成品",
|
||||
unit="件",
|
||||
item_type="finished",
|
||||
cost_price=0,
|
||||
sale_price=0,
|
||||
min_stock=0,
|
||||
max_stock=0,
|
||||
)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
stp_file.product_id = product.id
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_product(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
product_data: ProductCreate,
|
||||
current_user: User,
|
||||
) -> ProductResponse:
|
||||
product = await _get_product_or_404(db_session, product_id)
|
||||
_validate_item_type(product_data.item_type)
|
||||
|
||||
conflict = await db_session.execute(
|
||||
select(Product).where(Product.sku == product_data.sku, Product.id != product_id)
|
||||
)
|
||||
if conflict.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product_dict = product_data.model_dump()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
|
||||
for key, value in product_dict.items():
|
||||
setattr(product, key, value)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
|
||||
return _build_product_response(product, material_cost_map.get(product.id, 0))
|
||||
|
||||
@staticmethod
|
||||
async def delete_product(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
product = await _get_product_or_404(db_session, product_id)
|
||||
product.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "产品已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def get_product_bom(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
) -> ProductBOMResponse:
|
||||
product = await _get_product_or_404(db_session, product_id, active_only=True)
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
return await _build_bom_response(db_session, product)
|
||||
|
||||
@staticmethod
|
||||
async def replace_product_bom(
|
||||
db_session: AsyncSession,
|
||||
product_id: int,
|
||||
payload: ProductBOMUpdate,
|
||||
current_user: User,
|
||||
) -> ProductBOMResponse:
|
||||
product = await _get_product_or_404(db_session, product_id, active_only=True)
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
material_ids = [item.material_id for item in payload.items]
|
||||
if len(material_ids) != len(set(material_ids)):
|
||||
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
|
||||
|
||||
if material_ids:
|
||||
material_result = await db_session.execute(
|
||||
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
|
||||
)
|
||||
materials = material_result.scalars().all()
|
||||
material_map = {m.id: m for m in materials}
|
||||
if len(material_map) != len(material_ids):
|
||||
raise HTTPException(status_code=400, detail="存在无效物料")
|
||||
invalid_materials = [m.name for m in materials if m.item_type != "material"]
|
||||
if invalid_materials:
|
||||
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
|
||||
|
||||
for item in payload.items:
|
||||
if item.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||||
|
||||
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||||
for item in payload.items:
|
||||
db_session.add(
|
||||
ProductMaterial(
|
||||
finished_product_id=product_id,
|
||||
material_product_id=item.material_id,
|
||||
quantity=item.quantity,
|
||||
loss_rate=item.loss_rate,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
return await _build_bom_response(db_session, product)
|
||||
|
||||
|
||||
product_service = ProductService()
|
||||
@@ -10,18 +10,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
Inventory,
|
||||
MaterialSupplier,
|
||||
Supplier,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, ProductMaterial, SalesOrder, SalesOrderItem, Inventory, MaterialSupplier, Supplier, PurchaseOrder, PurchaseOrderItem
|
||||
from ..utils import generate_order_no
|
||||
from ..schemas.purchase_demand_schemas import (
|
||||
PurchaseDemandItemResponse,
|
||||
|
||||
@@ -10,16 +10,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Supplier,
|
||||
Product,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Supplier, Product, Warehouse, Inventory, StockMovement, PurchaseOrder, PurchaseOrderItem
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
|
||||
@@ -12,17 +12,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Product, ProductMaterial, Warehouse, Inventory, StockMovement, SalesOrder, SalesOrderItem
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
|
||||
@@ -9,7 +9,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Warehouse, Inventory, StockMovement
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..utils import generate_order_no
|
||||
|
||||
|
||||
@@ -3,32 +3,68 @@ import importlib
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
from moldinsight.api.html_report_router import include_into as include_html_report
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _safe_include(module_path: str, label: str):
|
||||
# 业务路由装载清单:新增路由必须登记于此。
|
||||
# 失败语义(原 _safe_include 仅 WARNING 跳过,进程带病启动不可感知):
|
||||
# - 非 DEBUG:记录进 route_load_status["failed"],/api/health 呈现 degraded
|
||||
# - DEBUG:直接抛错 fail fast——开发环境路由缺失必须当场暴露
|
||||
ROUTE_MODULES = [
|
||||
# (label, module_path, debug_only)
|
||||
("健康检查", "moldinsight.api.health_router", False),
|
||||
("上传", "moldinsight.api.upload_router", False),
|
||||
("批量", "moldinsight.api.batch_router", False),
|
||||
("任务", "moldinsight.api.task_router", False),
|
||||
("历史", "moldinsight.api.history_router", False),
|
||||
("CAM", "moldinsight.api.cam_router", False),
|
||||
("设计", "moldinsight.api.design_router", False),
|
||||
("成本", "moldinsight.api.cost_router", False),
|
||||
("加工", "moldinsight.api.machining_router", False),
|
||||
("导出", "moldinsight.api.export_router", False),
|
||||
("铝价", "moldinsight.api.aluminum_price_routes", False),
|
||||
("老师傅经验反馈", "moldinsight.api.experience_feedback_router", False), # D17 Human-in-Loop
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
("调试", "moldinsight.api.debug_router", True),
|
||||
]
|
||||
|
||||
|
||||
def _safe_include(label: str, module_path: str, debug_only: bool = False):
|
||||
if debug_only and not settings.DEBUG:
|
||||
route_load_status["disabled"].append({"label": label, "module": module_path})
|
||||
return
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
router_obj = getattr(module, "router", None)
|
||||
if router_obj is None:
|
||||
raise ValueError("未找到 router 对象")
|
||||
router.include_router(router_obj)
|
||||
route_load_status["loaded"].append({"label": label, "module": module_path})
|
||||
logger.info(f"{label} 路由加载成功")
|
||||
except Exception as exc:
|
||||
logger.warning(f"{label} 路由加载失败,已跳过: {exc}")
|
||||
route_load_status["failed"].append(
|
||||
{"label": label, "module": module_path, "error": str(exc)}
|
||||
)
|
||||
logger.error(f"{label} 路由加载失败: {exc}")
|
||||
if settings.DEBUG:
|
||||
raise
|
||||
|
||||
|
||||
_safe_include("moldinsight.api.health_router", "健康检查")
|
||||
_safe_include("moldinsight.api.upload_router", "上传")
|
||||
_safe_include("moldinsight.api.batch_router", "批量")
|
||||
_safe_include("moldinsight.api.task_router", "任务")
|
||||
_safe_include("moldinsight.api.history_router", "历史")
|
||||
_safe_include("moldinsight.api.cam_router", "CAM")
|
||||
_safe_include("moldinsight.api.advanced_router", "高级")
|
||||
_safe_include("moldinsight.api.aluminum_price_routes", "铝价")
|
||||
for _label, _module_path, _debug_only in ROUTE_MODULES:
|
||||
_safe_include(_label, _module_path, _debug_only)
|
||||
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
if settings.DEBUG:
|
||||
_safe_include("moldinsight.api.debug_router", "调试")
|
||||
|
||||
def register_moldinsight_routers(app):
|
||||
"""注册 moldinsight 全部业务路由到 app(D3 收敛:入口侧单点调用)。
|
||||
|
||||
- /api 聚合路由:各子路由模块装载失败经 ROUTE_MODULES/route_load_status 呈现
|
||||
(/api/health degraded,DEBUG fail-fast),见 _safe_include
|
||||
- HTML 报告代理挂根路径 /html/{filename}(URL 形状与原 StaticFiles 一致),
|
||||
失败同样登记 route_load_status(html_report_router.include_into)
|
||||
"""
|
||||
app.include_router(router, prefix="/api")
|
||||
include_html_report(app)
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cad_exporter = CADExporter()
|
||||
storage_service = StorageIntegrationService()
|
||||
|
||||
_cached_instances = {}
|
||||
|
||||
|
||||
def _get_cached_import(key: str):
|
||||
"""惰性导入核心模块,避免路由器模块级加载时的循环依赖。"""
|
||||
if key in _cached_instances:
|
||||
return _cached_instances[key]
|
||||
try:
|
||||
if key == "side_action_designer":
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
instance = SideActionDesigner()
|
||||
elif key == "cavity_layout_optimizer":
|
||||
from moldinsight.core.cavity_layout_optimizer import CavityLayoutOptimizer
|
||||
instance = CavityLayoutOptimizer()
|
||||
elif key == "mold_system_designer":
|
||||
from moldinsight.core.mold_system_designer import MoldSystemDesigner
|
||||
instance = MoldSystemDesigner()
|
||||
elif key == "mold_cam_designer":
|
||||
from moldinsight.core.mold_cam import MoldCAMDesigner
|
||||
instance = MoldCAMDesigner()
|
||||
elif key == "collision_detector":
|
||||
from moldinsight.core.mold_machining import CollisionDetector
|
||||
instance = CollisionDetector()
|
||||
elif key == "toolpath_optimizer":
|
||||
from moldinsight.core.mold_machining import ToolpathOptimizer
|
||||
instance = ToolpathOptimizer()
|
||||
elif key == "edm_designer":
|
||||
from moldinsight.core.mold_machining import EDMElectrodeDesigner
|
||||
instance = EDMElectrodeDesigner()
|
||||
elif key == "machining_simulator":
|
||||
from moldinsight.core.mold_machining import MachiningSimulator
|
||||
instance = MachiningSimulator()
|
||||
else:
|
||||
return None
|
||||
_cached_instances[key] = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_task_access(
|
||||
db_session: AsyncSession,
|
||||
task_id: str,
|
||||
user_id: int,
|
||||
):
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
return await TaskQueryService.ensure_task_access(db_session, task_id, user_id)
|
||||
|
||||
|
||||
def _get_export_artifacts(task_data: dict) -> dict:
|
||||
if not isinstance(task_data, dict):
|
||||
return {}
|
||||
direct = task_data.get("export_artifacts")
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
parameters = task_data.get("parameters")
|
||||
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
|
||||
return parameters.get("export_artifacts")
|
||||
return {}
|
||||
|
||||
|
||||
def _expand_components(components):
|
||||
requested = components or ["cavity", "core"]
|
||||
if "all" in requested:
|
||||
return ["cavity", "core", "parting_surface"]
|
||||
return list(dict.fromkeys(requested))
|
||||
|
||||
|
||||
def _augment_export_files(task_id: str, files):
|
||||
items = []
|
||||
for file in files or []:
|
||||
item = dict(file)
|
||||
relative_path = item.get("relative_path")
|
||||
if not relative_path and item.get("filepath"):
|
||||
relative_path = cad_exporter.get_relative_path(item["filepath"])
|
||||
if relative_path:
|
||||
relative_path = str(relative_path).replace("\\", "/").strip("/")
|
||||
item["relative_path"] = relative_path
|
||||
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
schemes = dict(merged.get("schemes") or {})
|
||||
scheme_id = export_result.get("scheme_id") or "default"
|
||||
previous = dict(schemes.get(scheme_id) or {})
|
||||
|
||||
file_map = {}
|
||||
for file in previous.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
for file in export_result.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
|
||||
schemes[scheme_id] = {
|
||||
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": sorted(
|
||||
file_map.values(),
|
||||
key=lambda item: (item.get("component", ""), item.get("format", "")),
|
||||
),
|
||||
"errors": export_result.get("errors", []),
|
||||
"total_files": len(file_map),
|
||||
"total_errors": len(export_result.get("errors", [])),
|
||||
}
|
||||
|
||||
merged["version"] = 1
|
||||
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
|
||||
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
|
||||
merged["schemes"] = schemes
|
||||
return merged
|
||||
|
||||
|
||||
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
|
||||
if not scheme_data:
|
||||
return None
|
||||
|
||||
component_list = _expand_components(components)
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
expected = {(component, fmt) for component in component_list for fmt in format_list}
|
||||
|
||||
available = []
|
||||
available_keys = set()
|
||||
for file in scheme_data.get("files", []):
|
||||
component = file.get("component")
|
||||
fmt = file.get("format")
|
||||
if component not in component_list or fmt not in format_list:
|
||||
continue
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if not relative_path:
|
||||
continue
|
||||
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
available.append(file)
|
||||
available_keys.add((component, fmt))
|
||||
|
||||
if expected and not expected.issubset(available_keys):
|
||||
return None
|
||||
|
||||
return _augment_export_files(task_id, available)
|
||||
|
||||
|
||||
@router.post("/optimize-layout")
|
||||
async def optimize_cavity_layout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
mold_base_size = body.get("mold_base_size")
|
||||
layout_type = body.get("layout_type", "auto")
|
||||
if cavity_count < 1 or cavity_count > 64:
|
||||
raise HTTPException(400, "型腔数量必须在 1-64 之间")
|
||||
optimizer = _get_cached_import("cavity_layout_optimizer")
|
||||
if not optimizer:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = optimizer.optimize_layout(
|
||||
product_bbox=product_bbox,
|
||||
cavity_count=cavity_count,
|
||||
mold_base_size=mold_base_size,
|
||||
layout_type=layout_type,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-cooling")
|
||||
async def design_cooling_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
cycle_time_target = body.get("cycle_time_target")
|
||||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||||
designer = CoolingSystemDesigner()
|
||||
result = designer.design_cooling_system(
|
||||
mold_size=mold_size, product_bbox=product_bbox,
|
||||
material=material, cavity_count=cavity_count,
|
||||
cycle_time_target=cycle_time_target,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-gating")
|
||||
async def design_gating_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
gate_type = body.get("gate_type", "auto")
|
||||
layout_positions = body.get("layout_positions")
|
||||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||||
designer = GatingSystemDesigner()
|
||||
result = designer.design_gating_system(
|
||||
product_bbox=product_bbox, material=material,
|
||||
cavity_count=cavity_count, gate_type=gate_type,
|
||||
layout_positions=layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-mold-system")
|
||||
async def design_complete_mold_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
gate_type = body.get("gate_type", "auto")
|
||||
cycle_time_target = body.get("cycle_time_target")
|
||||
layout_positions = body.get("layout_positions")
|
||||
ds = _get_cached_import("mold_system_designer")
|
||||
if not ds:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ds.design_complete_system(
|
||||
mold_size=mold_size, product_bbox=product_bbox,
|
||||
material=material, cavity_count=cavity_count,
|
||||
gate_type=gate_type, cycle_time_target=cycle_time_target,
|
||||
layout_positions=layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/detect-undercuts")
|
||||
async def detect_undercuts(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
parting_direction = body.get("parting_direction", [0, 0, 1])
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
sd = _get_cached_import("side_action_designer")
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
|
||||
from moldinsight.services.shape_loader import get_shape_loader
|
||||
shape = await get_shape_loader().load_shape_for_task(db_session, task_id)
|
||||
if shape is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
result = await processing_service.run_occ(
|
||||
sd.analyze_and_design,
|
||||
shape,
|
||||
parting_direction,
|
||||
mold_size,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/cost-estimate")
|
||||
async def estimate_cost(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
analysis_result = task_data.get("analysis_result")
|
||||
if not analysis_result:
|
||||
raise HTTPException(400, "该任务尚未完成分析")
|
||||
detailed_context = {
|
||||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||||
"geometry_data": task_data.get("geometry_data", {}),
|
||||
"metadata": {"selected_material": task_data.get("material")},
|
||||
}
|
||||
# 优先使用 LLM
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||||
if result is not None:
|
||||
result["source"] = "ai"
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
# LLM 未启用或失败,降级为规则估算
|
||||
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
|
||||
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
|
||||
return {"status": "success", "data": rules_result}
|
||||
|
||||
|
||||
@router.post("/design-cam")
|
||||
async def design_mold_cam(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
stock_bbox = body.get("stock_bbox", {"dimensions": [150, 150, 100], "min": [-75, -75, -50], "max": [75, 75, 50]})
|
||||
mold_steel = body.get("mold_steel", "P20")
|
||||
surface_quality = body.get("surface_quality", "standard")
|
||||
controller = body.get("controller", "fanuc")
|
||||
cam = _get_cached_import("mold_cam_designer")
|
||||
if not cam:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = cam.design_mold_cam(
|
||||
cavity_bbox=cavity_bbox, stock_bbox=stock_bbox,
|
||||
mold_steel=mold_steel, surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/check-collision")
|
||||
async def check_toolpath_collision(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||||
tool = body.get("tool", {"diameter": 10, "flute_length": 30, "shank_diameter": 10})
|
||||
stock_bbox = body.get("stock_bbox", {"min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
clamp_positions = body.get("clamp_positions")
|
||||
cd = _get_cached_import("collision_detector")
|
||||
if not cd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = cd.check_toolpath_safety(toolpath_points, tool, stock_bbox, clamp_positions)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/optimize-toolpath")
|
||||
async def optimize_toolpath(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||||
cutting_params = body.get("cutting_params", {"feed_rate_mm_min": 500})
|
||||
stock_bbox = body.get("stock_bbox")
|
||||
to = _get_cached_import("toolpath_optimizer")
|
||||
if not to:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = to.optimize_toolpath(toolpath_points, cutting_params, stock_bbox)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-electrodes")
|
||||
async def design_edm_electrodes(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
undercut_regions = body.get("undercut_regions", [{"center": [0, 0, 0], "area": 100, "type": "undercut"}])
|
||||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "copper")
|
||||
spark_gap = body.get("spark_gap", 0.05)
|
||||
overburn = body.get("overburn", 0.1)
|
||||
ed = _get_cached_import("edm_designer")
|
||||
if not ed:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ed.design_electrodes(undercut_regions, cavity_bbox, material, spark_gap, overburn)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/simulate-machining")
|
||||
async def simulate_machining(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
operations = body.get("operations", [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}])
|
||||
stock_bbox = body.get("stock_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
resolution = body.get("resolution", 2.0)
|
||||
ms = _get_cached_import("machining_simulator")
|
||||
if not ms:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ms.simulate_machining(operations, stock_bbox, resolution)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/export-mold")
|
||||
async def export_mold_results(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
formats = body.get("formats", ["step", "stl"])
|
||||
components = body.get("components", ["cavity", "core"])
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(404, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
resolved_scheme_id = scheme_id or task_data.get("best_scheme_id") or "default"
|
||||
persisted_files = _select_persisted_files(
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=components,
|
||||
)
|
||||
if persisted_files:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
|
||||
"task_id": task_id,
|
||||
"scheme_id": resolved_scheme_id,
|
||||
"files": persisted_files,
|
||||
"errors": [],
|
||||
"total_files": len(persisted_files),
|
||||
"total_errors": 0,
|
||||
"source": "persisted",
|
||||
},
|
||||
}
|
||||
|
||||
cavity_shapes = processing_service.get_export_shapes(
|
||||
task_id,
|
||||
resolved_scheme_id,
|
||||
)
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
|
||||
if not cavity_shapes:
|
||||
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
|
||||
# 现场转换缺失格式,用户无需重新分析
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
|
||||
)
|
||||
|
||||
base_filename = Path(filename).stem
|
||||
result = cad_exporter.export_mold_results(
|
||||
cavity_data=cavity_shapes,
|
||||
base_filename=base_filename,
|
||||
formats=formats,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
)
|
||||
result["files"] = _augment_export_files(task_id, result.get("files", []))
|
||||
result["source"] = "generated"
|
||||
|
||||
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
|
||||
await storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
|
||||
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
|
||||
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
async def download_export_file(
|
||||
filepath: str,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
allowed_paths = set()
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
for scheme in (artifacts.get("schemes") or {}).values():
|
||||
for file in scheme.get("files", []):
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if relative_path:
|
||||
allowed_paths.add(relative_path)
|
||||
|
||||
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
|
||||
if normalized_path not in allowed_paths:
|
||||
raise HTTPException(403, "该文件不在任务允许下载清单中")
|
||||
|
||||
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "文件不存在")
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
|
||||
raise HTTPException(403, "禁止访问")
|
||||
media_types = {
|
||||
".step": "application/step", ".stp": "application/step",
|
||||
".iges": "application/iges", ".igs": "application/iges",
|
||||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||||
}
|
||||
ext = Path(full_path).suffix.lower()
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||||
|
||||
|
||||
@router.get("/export-recommendations")
|
||||
async def get_export_recommendations(
|
||||
target: str = "ug",
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = cad_exporter.get_export_recommendations(target)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@@ -18,19 +18,22 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, ProcessingTask, STPFile
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.models.schemas import ProcessingStatus, create_task_info
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.utils.file_handler import FileHandler
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from shared.config.settings import settings
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.task_dispatcher import dispatch_processing
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
file_handler = FileHandler()
|
||||
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
|
||||
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
|
||||
|
||||
|
||||
@router.post("/batch-upload")
|
||||
@@ -60,7 +63,7 @@ async def batch_upload(
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = TaskStorageService()
|
||||
|
||||
for file in files:
|
||||
# 文件类型检查
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, ProcessingTask
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from moldinsight.services.cam_bundle_service import cam_bundle_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
@@ -21,20 +26,25 @@ DEFAULT_CAM_PREFERENCES = {
|
||||
}
|
||||
|
||||
|
||||
class CamPlanRequest(BaseModel):
|
||||
"""未提供的偏好字段回落到任务持久化偏好,再回落到默认值。"""
|
||||
task_id: str
|
||||
scheme_id: Optional[str] = None
|
||||
mold_steel: Optional[str] = None
|
||||
surface_quality: Optional[str] = None
|
||||
controller: Optional[str] = None
|
||||
include_gcode: Optional[bool] = None
|
||||
|
||||
|
||||
@router.post("/cam/plan")
|
||||
async def generate_cam_plan(
|
||||
request: Request,
|
||||
body: CamPlanRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""基于任务分模结果生成 CAM 准备包(MVP)。"""
|
||||
_ = current_user
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 task_id")
|
||||
task_id = body.task_id
|
||||
|
||||
task_result = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
@@ -47,23 +57,26 @@ async def generate_cam_plan(
|
||||
processing_task.parameters.get("cam_preferences", {}) or {}
|
||||
)
|
||||
|
||||
mold_steel = body.get(
|
||||
"mold_steel",
|
||||
persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]),
|
||||
# 注意 include_gcode 显式判 None:False 是有效值,不能走 or 回落
|
||||
mold_steel = (
|
||||
body.mold_steel
|
||||
if body.mold_steel is not None
|
||||
else persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"])
|
||||
)
|
||||
surface_quality = body.get(
|
||||
"surface_quality",
|
||||
persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]),
|
||||
surface_quality = (
|
||||
body.surface_quality
|
||||
if body.surface_quality is not None
|
||||
else persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"])
|
||||
)
|
||||
controller = body.get(
|
||||
"controller",
|
||||
persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]),
|
||||
controller = (
|
||||
body.controller
|
||||
if body.controller is not None
|
||||
else persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"])
|
||||
)
|
||||
include_gcode = bool(
|
||||
body.get(
|
||||
"include_gcode",
|
||||
persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]),
|
||||
)
|
||||
include_gcode = (
|
||||
body.include_gcode
|
||||
if body.include_gcode is not None
|
||||
else persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"])
|
||||
)
|
||||
|
||||
task_view = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
@@ -73,9 +86,11 @@ async def generate_cam_plan(
|
||||
raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划")
|
||||
|
||||
try:
|
||||
data = cam_bundle_service.build_bundle(
|
||||
# CAM 刀路计算为纯 Python 重计算,投放线程池避免阻塞事件循环
|
||||
data = await asyncio.to_thread(
|
||||
cam_bundle_service.build_bundle,
|
||||
task_view=task_view,
|
||||
scheme_id=scheme_id,
|
||||
scheme_id=body.scheme_id,
|
||||
mold_steel=mold_steel,
|
||||
surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""核心计算模块的惰性装载器(原 advanced_router._get_cached_import,D1 拆分时上提共用)。
|
||||
|
||||
- 惰性导入:避免路由模块级加载核心包(含 OCC 重模块)的导入开销与循环依赖
|
||||
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
|
||||
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
|
||||
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
|
||||
经 processing_service.run_occ 的常驻 OCC 进程池使用(方案 B,见
|
||||
docs/topics/performance/OCC_THROUGHPUT.md)
|
||||
"""
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_instances: dict = {}
|
||||
|
||||
_LOADERS = {
|
||||
"side_action_designer": ("moldinsight.core.side_action_designer", "SideActionDesigner"),
|
||||
"cavity_layout_optimizer": ("moldinsight.core.cavity_layout_optimizer", "CavityLayoutOptimizer"),
|
||||
"mold_system_designer": ("moldinsight.core.mold_system_designer", "MoldSystemDesigner"),
|
||||
"mold_cam_designer": ("moldinsight.core.mold_cam", "MoldCAMDesigner"),
|
||||
"collision_detector": ("moldinsight.core.mold_machining", "CollisionDetector"),
|
||||
"toolpath_optimizer": ("moldinsight.core.mold_machining", "ToolpathOptimizer"),
|
||||
"edm_designer": ("moldinsight.core.mold_machining", "EDMElectrodeDesigner"),
|
||||
"machining_simulator": ("moldinsight.core.mold_machining", "MachiningSimulator"),
|
||||
}
|
||||
|
||||
|
||||
def get_core_module(key: str):
|
||||
if key in _instances:
|
||||
return _instances[key]
|
||||
if key not in _LOADERS:
|
||||
return None
|
||||
with _lock:
|
||||
if key in _instances:
|
||||
return _instances[key]
|
||||
module_path, class_name = _LOADERS[key]
|
||||
try:
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
instance = getattr(module, class_name)()
|
||||
except Exception as e:
|
||||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||||
return None
|
||||
_instances[key] = instance
|
||||
return instance
|
||||
@@ -0,0 +1,51 @@
|
||||
# api/cost_router.py
|
||||
"""成本估算接口(批次 3 自 advanced_router 拆分,D1)。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CostEstimateRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
@router.post("/cost-estimate")
|
||||
async def estimate_cost(
|
||||
body: CostEstimateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
|
||||
|
||||
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, body.task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
analysis_result = task_data.get("analysis_result")
|
||||
if not analysis_result:
|
||||
raise HTTPException(400, "该任务尚未完成分析")
|
||||
detailed_context = {
|
||||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||||
"geometry_data": task_data.get("geometry_data", {}),
|
||||
"metadata": {"selected_material": task_data.get("material")},
|
||||
}
|
||||
# 优先使用 LLM
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||||
if result is not None:
|
||||
result["source"] = "ai"
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
# LLM 未启用或失败,降级为规则估算
|
||||
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
|
||||
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
|
||||
return {"status": "success", "data": rules_result}
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# api/design_router.py
|
||||
"""模具结构设计类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
|
||||
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
|
||||
OCC 相关的倒扣检测经 processing_service.run_occ 的常驻 OCC 进程池
|
||||
(PythonOCC 非线程安全,进程内串行;见 OCC_THROUGHPUT.md 方案 B)
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.api.core_modules import get_core_module
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---- 请求模型 ----
|
||||
|
||||
class BBox3D(BaseModel):
|
||||
dimensions: List[float] = Field(default_factory=lambda: [100.0, 100.0, 50.0])
|
||||
min: Optional[List[float]] = None
|
||||
max: Optional[List[float]] = None
|
||||
|
||||
|
||||
class MoldSize(BaseModel):
|
||||
length: float = 300.0
|
||||
width: float = 300.0
|
||||
height: float = 200.0
|
||||
|
||||
|
||||
class OptimizeLayoutRequest(BaseModel):
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
mold_base_size: Optional[BBox3D] = None
|
||||
layout_type: str = "auto"
|
||||
|
||||
|
||||
class CoolingDesignRequest(BaseModel):
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
cycle_time_target: Optional[float] = None
|
||||
|
||||
|
||||
class GatingDesignRequest(BaseModel):
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
gate_type: str = "auto"
|
||||
layout_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class MoldSystemDesignRequest(BaseModel):
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
gate_type: str = "auto"
|
||||
cycle_time_target: Optional[float] = None
|
||||
layout_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class UndercutDetectRequest(BaseModel):
|
||||
task_id: str
|
||||
parting_direction: List[float] = Field(default_factory=lambda: [0.0, 0.0, 1.0])
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
|
||||
|
||||
@router.post("/optimize-layout")
|
||||
async def optimize_cavity_layout(
|
||||
body: OptimizeLayoutRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
optimizer = get_core_module("cavity_layout_optimizer")
|
||||
if not optimizer:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
# 纯 Python 布局优化,投放线程池避免阻塞事件循环
|
||||
result = await asyncio.to_thread(
|
||||
optimizer.optimize_layout,
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
cavity_count=body.cavity_count,
|
||||
mold_base_size=body.mold_base_size.model_dump(exclude_none=True) if body.mold_base_size else None,
|
||||
layout_type=body.layout_type,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-cooling")
|
||||
async def design_cooling_system(
|
||||
body: CoolingDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||||
designer = CoolingSystemDesigner()
|
||||
result = await asyncio.to_thread(
|
||||
designer.design_cooling_system,
|
||||
mold_size=body.mold_size.model_dump(),
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
cycle_time_target=body.cycle_time_target,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-gating")
|
||||
async def design_gating_system(
|
||||
body: GatingDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||||
designer = GatingSystemDesigner()
|
||||
result = await asyncio.to_thread(
|
||||
designer.design_gating_system,
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
gate_type=body.gate_type,
|
||||
layout_positions=body.layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-mold-system")
|
||||
async def design_complete_mold_system(
|
||||
body: MoldSystemDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ds = get_core_module("mold_system_designer")
|
||||
if not ds:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ds.design_complete_system,
|
||||
mold_size=body.mold_size.model_dump(),
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
gate_type=body.gate_type,
|
||||
cycle_time_target=body.cycle_time_target,
|
||||
layout_positions=body.layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/detect-undercuts")
|
||||
async def detect_undercuts(
|
||||
body: UndercutDetectRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
|
||||
|
||||
sd = get_core_module("side_action_designer")
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 方案 B:从持久化 STP 原件落盘,倒扣分析在常驻 OCC 子进程内完成(形状不跨进程)
|
||||
from moldinsight.services.stp_materializer import get_stp_materializer
|
||||
stp_path = await get_stp_materializer().materialize_stp_for_task(db_session, body.task_id)
|
||||
if stp_path is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
try:
|
||||
result = await processing_service.run_occ(
|
||||
"detect_undercuts",
|
||||
{
|
||||
"stp_path": str(stp_path),
|
||||
"parting_direction": body.parting_direction,
|
||||
"mold_size": body.mold_size.model_dump(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
stp_path.unlink(missing_ok=True)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -0,0 +1,199 @@
|
||||
"""老师傅经验反馈 API:D17 Human-in-Loop 闭环。
|
||||
|
||||
端点:
|
||||
- POST /api/tasks/{task_id}/experience-feedback 提交方案级反馈
|
||||
- GET /api/tasks/{task_id}/experience-hints 拉取同指纹历史 hints 摘要
|
||||
|
||||
权限:
|
||||
- 写入:Depends(get_current_active_user) + ensure_task_access + 行内 has_permission
|
||||
- 读取:Depends(get_current_active_user) + ensure_task_access(所有登录用户可看)
|
||||
|
||||
Pydantic 模型写在路由文件内(项目硬规则,shared/models/schemas.py 不扩张)。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
from moldinsight.services.experience_feedback_service import (
|
||||
ExperienceFeedbackService,
|
||||
compute_fingerprint,
|
||||
)
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.models import ExperienceFeedback, ProcessingTask, GeometryData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic 请求 / 响应模型(写在路由文件内,硬规则)──
|
||||
|
||||
class ExperienceFeedbackCreate(BaseModel):
|
||||
"""老师傅方案级反馈请求体。"""
|
||||
|
||||
scheme_id: str = Field(..., min_length=1, max_length=64)
|
||||
feedback_status: Literal["adopted", "adjust", "rejected"]
|
||||
feedback_reason: Optional[str] = Field(None, max_length=2000)
|
||||
adjust_suggestion: Optional[str] = Field(None, max_length=2000)
|
||||
confidence_at_submit: Optional[float] = Field(None, ge=0.0, le=1.0)
|
||||
score_at_submit: Optional[float] = Field(None, ge=0.0, le=100.0)
|
||||
|
||||
|
||||
class ExperienceFeedbackResponse(BaseModel):
|
||||
"""反馈写入响应。"""
|
||||
|
||||
id: int
|
||||
scheme_id: str
|
||||
scheme_axis: str
|
||||
feedback_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ExperienceHintItem(BaseModel):
|
||||
"""同指纹历史 hints 摘要(按 scheme_axis 聚合)。"""
|
||||
|
||||
scheme_axis: str
|
||||
adopted_count: int
|
||||
rejected_count: int
|
||||
adjust_count: int
|
||||
confidence: float
|
||||
weight: float
|
||||
sample_count: int
|
||||
|
||||
|
||||
class ExperienceHintsResponse(BaseModel):
|
||||
"""GET /experience-hints 响应。"""
|
||||
|
||||
task_id: str
|
||||
stp_file_id: int
|
||||
material_name: str
|
||||
is_foam: bool
|
||||
fingerprint: Dict[str, str]
|
||||
hints: List[ExperienceHintItem]
|
||||
|
||||
|
||||
# ── 端点 ──
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/experience-feedback",
|
||||
response_model=ExperienceFeedbackResponse,
|
||||
)
|
||||
async def submit_feedback(
|
||||
task_id: str,
|
||||
body: ExperienceFeedbackCreate,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""提交方案级反馈。
|
||||
|
||||
权限:登录用户 + 任务归属 + feedback_experience_hint。
|
||||
写入后由路由 commit(D9 边界)+ invalidate_task_view(task_view 60s TTL 失效)。
|
||||
"""
|
||||
# 1. 任务归属校验(与 task_router / design_router 同一约定)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
# 2. 权限校验:行内 has_permission(identity.py:38 全仓首次调用)
|
||||
if not current_user.has_permission("feedback_experience_hint"):
|
||||
raise HTTPException(403, "需要工艺工程师或管理员权限")
|
||||
|
||||
# 3. 写反馈(仅 flush,D9 边界由本路由 commit)
|
||||
feedback = await ExperienceFeedbackService().record_feedback(
|
||||
session=db_session,
|
||||
task_id=task_id,
|
||||
scheme_id=body.scheme_id,
|
||||
feedback_status=body.feedback_status,
|
||||
feedback_reason=body.feedback_reason,
|
||||
adjust_suggestion=body.adjust_suggestion,
|
||||
user=current_user,
|
||||
confidence_at_submit=body.confidence_at_submit,
|
||||
score_at_submit=body.score_at_submit,
|
||||
process_params_snapshot=None, # 路由不接管 process_params,由算法层填
|
||||
)
|
||||
|
||||
try:
|
||||
await db_session.commit()
|
||||
except Exception as exc:
|
||||
await db_session.rollback()
|
||||
logger.error(f"反馈提交失败: {exc}")
|
||||
raise HTTPException(500, "反馈提交失败")
|
||||
|
||||
# 4. 失效任务视图缓存(写反馈后 next view 立即反映 hints)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return ExperienceFeedbackResponse(
|
||||
id=feedback.id,
|
||||
scheme_id=feedback.scheme_id,
|
||||
scheme_axis=feedback.scheme_axis,
|
||||
feedback_status=feedback.feedback_status,
|
||||
created_at=feedback.created_at or datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}/experience-hints",
|
||||
response_model=ExperienceHintsResponse,
|
||||
)
|
||||
async def get_experience_hints(
|
||||
task_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""拉取该任务的同指纹历史 hints 摘要。
|
||||
|
||||
权限:登录用户 + 任务归属。组织知识对所有人可见(不要求工艺工程师权限)。
|
||||
"""
|
||||
# 1. 任务归属校验
|
||||
row = await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
_, stp_file = row
|
||||
|
||||
# 2. 取 material / is_foam
|
||||
pt_row = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
processing_task = pt_row.scalar_one_or_none()
|
||||
params = (processing_task.parameters if processing_task else None) or {}
|
||||
material_name = str(params.get("material") or "ABS")
|
||||
is_foam = bool(params.get("is_foam_material", False))
|
||||
|
||||
# 3. 计算 fingerprint(用于回显 + 与 record_feedback 用同一函数)
|
||||
geo_row = await db_session.execute(
|
||||
select(GeometryData).where(GeometryData.stp_file_id == stp_file.id)
|
||||
)
|
||||
geo = geo_row.scalar_one_or_none()
|
||||
geometry_summary: Dict[str, Any] = {}
|
||||
if geo is not None:
|
||||
geometry_summary = {
|
||||
"volume": geo.volume,
|
||||
"bounding_box": {
|
||||
"min": geo.bounding_box_min,
|
||||
"max": geo.bounding_box_max,
|
||||
},
|
||||
"topology_faces": geo.topology_faces,
|
||||
}
|
||||
fingerprint = compute_fingerprint(geometry_summary, material_name, is_foam)
|
||||
|
||||
# 4. 拉 hints 聚合
|
||||
hints = await ExperienceFeedbackService().list_hints_for_task(
|
||||
session=db_session,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
)
|
||||
|
||||
return ExperienceHintsResponse(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file.id,
|
||||
material_name=material_name,
|
||||
is_foam=is_foam,
|
||||
fingerprint=fingerprint,
|
||||
hints=[ExperienceHintItem(**h) for h in hints],
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
# api/export_router.py
|
||||
"""导出类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
导出产物清单(export_artifacts)的合并/校验辅助函数自原文件平移,
|
||||
行为不变;任务归属校验直接调用 TaskQueryService.ensure_task_access。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cad_exporter = CADExporter()
|
||||
|
||||
|
||||
class ExportMoldRequest(BaseModel):
|
||||
task_id: str
|
||||
scheme_id: Optional[str] = None
|
||||
formats: List[str] = Field(default_factory=lambda: ["step", "stl"])
|
||||
components: List[str] = Field(default_factory=lambda: ["cavity", "core"])
|
||||
|
||||
|
||||
# ---- 导出产物清单辅助(自原 advanced_router 平移) ----
|
||||
|
||||
def _get_export_artifacts(task_data: dict) -> dict:
|
||||
if not isinstance(task_data, dict):
|
||||
return {}
|
||||
direct = task_data.get("export_artifacts")
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
parameters = task_data.get("parameters")
|
||||
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
|
||||
return parameters.get("export_artifacts")
|
||||
return {}
|
||||
|
||||
|
||||
def _expand_components(components):
|
||||
requested = components or ["cavity", "core"]
|
||||
if "all" in requested:
|
||||
return ["cavity", "core", "parting_surface"]
|
||||
return list(dict.fromkeys(requested))
|
||||
|
||||
|
||||
def _augment_export_files(task_id: str, files):
|
||||
items = []
|
||||
for file in files or []:
|
||||
item = dict(file)
|
||||
relative_path = item.get("relative_path")
|
||||
if not relative_path and item.get("filepath"):
|
||||
relative_path = cad_exporter.get_relative_path(item["filepath"])
|
||||
if relative_path:
|
||||
relative_path = str(relative_path).replace("\\", "/").strip("/")
|
||||
item["relative_path"] = relative_path
|
||||
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
schemes = dict(merged.get("schemes") or {})
|
||||
scheme_id = export_result.get("scheme_id") or "default"
|
||||
previous = dict(schemes.get(scheme_id) or {})
|
||||
|
||||
file_map = {}
|
||||
for file in previous.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
for file in export_result.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
|
||||
schemes[scheme_id] = {
|
||||
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": sorted(
|
||||
file_map.values(),
|
||||
key=lambda item: (item.get("component", ""), item.get("format", "")),
|
||||
),
|
||||
"errors": export_result.get("errors", []),
|
||||
"total_files": len(file_map),
|
||||
"total_errors": len(export_result.get("errors", [])),
|
||||
}
|
||||
|
||||
merged["version"] = 1
|
||||
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
|
||||
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
|
||||
merged["schemes"] = schemes
|
||||
return merged
|
||||
|
||||
|
||||
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
|
||||
if not scheme_data:
|
||||
return None
|
||||
|
||||
component_list = _expand_components(components)
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
expected = {(component, fmt) for component in component_list for fmt in format_list}
|
||||
|
||||
available = []
|
||||
available_keys = set()
|
||||
for file in scheme_data.get("files", []):
|
||||
component = file.get("component")
|
||||
fmt = file.get("format")
|
||||
if component not in component_list or fmt not in format_list:
|
||||
continue
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if not relative_path:
|
||||
continue
|
||||
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
available.append(file)
|
||||
available_keys.add((component, fmt))
|
||||
|
||||
if expected and not expected.issubset(available_keys):
|
||||
return None
|
||||
|
||||
return _augment_export_files(task_id, available)
|
||||
|
||||
|
||||
# ---- 端点 ----
|
||||
|
||||
@router.post("/export-mold")
|
||||
async def export_mold_results(
|
||||
body: ExportMoldRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
task_id = body.task_id
|
||||
formats = body.formats
|
||||
components = body.components
|
||||
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
resolved_scheme_id = body.scheme_id or task_data.get("best_scheme_id") or "default"
|
||||
persisted_files = _select_persisted_files(
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=components,
|
||||
)
|
||||
if persisted_files:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
|
||||
"task_id": task_id,
|
||||
"scheme_id": resolved_scheme_id,
|
||||
"files": persisted_files,
|
||||
"errors": [],
|
||||
"total_files": len(persisted_files),
|
||||
"total_errors": 0,
|
||||
"source": "persisted",
|
||||
},
|
||||
}
|
||||
|
||||
# 方案 B 后主进程不再持有内存形状缓存(get_export_shapes 已删除):
|
||||
# 未命中持久化清单时,从持久化单组件 STEP 现场转换缺失格式,用户无需重新分析
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出文件不可用:任务未生成持久化导出清单,请重新分析后再导出",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
async def download_export_file(
|
||||
filepath: str,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
allowed_paths = set()
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
for scheme in (artifacts.get("schemes") or {}).values():
|
||||
for file in scheme.get("files", []):
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if relative_path:
|
||||
allowed_paths.add(relative_path)
|
||||
|
||||
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
|
||||
if normalized_path not in allowed_paths:
|
||||
raise HTTPException(403, "该文件不在任务允许下载清单中")
|
||||
|
||||
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "文件不存在")
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
|
||||
raise HTTPException(403, "禁止访问")
|
||||
media_types = {
|
||||
".step": "application/step", ".stp": "application/step",
|
||||
".iges": "application/iges", ".igs": "application/iges",
|
||||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||||
}
|
||||
ext = Path(full_path).suffix.lower()
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||||
|
||||
|
||||
@router.get("/export-recommendations")
|
||||
async def get_export_recommendations(
|
||||
target: str = "ug",
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = cad_exporter.get_export_recommendations(target)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -1,7 +1,11 @@
|
||||
# api/v1/health_router.py
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
from moldinsight.core.occ_availability import is_pythonocc_available
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -9,10 +13,19 @@ router = APIRouter()
|
||||
@router.get("/health")
|
||||
@router.post("/health")
|
||||
async def health():
|
||||
task_count = await redis_task_manager.get_task_count()
|
||||
# 首次调用会触发 PythonOCC 导入(可能耗时数秒),投放线程池避免阻塞事件循环
|
||||
pythonocc_available = await asyncio.to_thread(is_pythonocc_available)
|
||||
failed_routes = route_load_status["failed"]
|
||||
return {
|
||||
"status": "healthy",
|
||||
"pythonocc": True,
|
||||
"total_tasks": task_count,
|
||||
"redis_connected": redis_task_manager.is_connected
|
||||
# 有业务路由装载失败即 degraded:进程活着但功能残缺,监控必须可感知
|
||||
"status": "degraded" if failed_routes else "healthy",
|
||||
# 真实探测 PythonOCC(此前硬编码 True,与上传预检的诚实化同源)
|
||||
"pythonocc": pythonocc_available,
|
||||
"total_tasks": await redis_task_manager.get_task_count(),
|
||||
"redis_connected": redis_task_manager.is_connected,
|
||||
"routes": {
|
||||
"loaded": [m["label"] for m in route_load_status["loaded"]],
|
||||
"failed": failed_routes,
|
||||
"disabled": [m["label"] for m in route_load_status["disabled"]],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import urllib.parse
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.file_history_service import FileHistoryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from shared.utils.logger import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,7 +21,7 @@ async def get_file_history(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""获取当前用户按文件名分组的文件历史记录(支持多上传)"""
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = FileHistoryService()
|
||||
file_groups = await storage_service.get_all_file_groups(
|
||||
db_session, user_id=current_user.id
|
||||
)
|
||||
@@ -42,7 +42,7 @@ async def get_file_records(
|
||||
"""获取当前用户指定文件名的所有上传记录(支持多上传历史)"""
|
||||
decoded_filename = urllib.parse.unquote(filename)
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = FileHistoryService()
|
||||
file_records = await storage_service.get_file_history_by_filename(
|
||||
db_session,
|
||||
decoded_filename,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# api/html_report_router.py
|
||||
"""HTML 可视化报告读取代理(D11)。
|
||||
|
||||
报告产物唯一持久来源是 RustFS 报告键 html/reports/{filename}(文件名寻址),
|
||||
本路由以 GET /html/{filename} 提供读取,替代原节点本地 html_output 的
|
||||
StaticFiles 挂载——API 与 worker 容器文件系统不互通,本地盘从来不是
|
||||
可依赖的读取来源。
|
||||
|
||||
解析顺序(逐级兜底,每次未命中记日志):
|
||||
1. RustFS 报告键(新产物,裸 HTML / 裸 JSON)
|
||||
2. HTMLFile 表记录(遗留 html/{hash}.json JSON 包装 {'content'})
|
||||
3. 节点本地 html_output 目录(存量兜底,compose 共享卷;新产物不再写本地)
|
||||
|
||||
已知约束(沿用 StaticFiles 时代的既定姿态,非本次引入):本路由不做认证。
|
||||
iframe 加载报告时浏览器不会携带 Authorization 头,无法套用 API 鉴权。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.database.database import db_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
from moldinsight.models import HTMLFile
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# 存量兜底目录(模块常量,测试可替换)
|
||||
LOCAL_HTML_DIR = Path("html_output")
|
||||
|
||||
_MEDIA_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def include_into(app) -> None:
|
||||
"""挂载到应用根路径——不能进 /api 前缀聚合:URL 形状必须保持
|
||||
/html/{filename}(持久化 cavity JSON 与前端 iframe 均引用此形状)。
|
||||
|
||||
失败语义与 route_registry._safe_include 一致:非 DEBUG 记入
|
||||
route_load_status['failed'](/api/health 呈现 degraded),DEBUG 直接抛错。
|
||||
"""
|
||||
try:
|
||||
app.include_router(router)
|
||||
except Exception as exc:
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
route_load_status["failed"].append(
|
||||
{"label": "HTML报告", "module": __name__, "error": str(exc)}
|
||||
)
|
||||
logger.error(f"HTML 报告路由加载失败: {exc}")
|
||||
if settings.DEBUG:
|
||||
raise
|
||||
|
||||
|
||||
def _validate_filename(filename: str) -> None:
|
||||
"""防路径穿越:只允许单段文件名(报告键固定为 html/reports/ 一级平铺)。"""
|
||||
if not filename or filename.startswith(".") or Path(filename).name != filename:
|
||||
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
|
||||
|
||||
|
||||
def _media_type_for(filename: str) -> str:
|
||||
return _MEDIA_TYPES.get(Path(filename).suffix.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
async def _download_from_rustfs(filename: str) -> Optional[bytes]:
|
||||
"""新产物:html/reports/{filename} 裸文件直取。"""
|
||||
if not rustfs_manager.is_connected:
|
||||
return None
|
||||
try:
|
||||
return await rustfs_manager.download_report_artifact(filename)
|
||||
except Exception as exc:
|
||||
logger.debug(f"报告键未命中(继续遗留解析): {filename}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def _download_from_legacy_record(filename: str) -> Optional[bytes]:
|
||||
"""遗留 HTMLFile 记录:html/{hash}.json JSON 包装 {'content'}。"""
|
||||
if not rustfs_manager.is_connected:
|
||||
return None
|
||||
|
||||
async with db_manager.session() as session:
|
||||
result = await session.execute(
|
||||
select(HTMLFile)
|
||||
.where(HTMLFile.filename == filename)
|
||||
.order_by(HTMLFile.id.desc())
|
||||
)
|
||||
record = result.scalars().first()
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
data = await rustfs_manager.download_file("html_files", record.object_key)
|
||||
if record.object_key.startswith(rustfs_manager.report_prefix + "/"):
|
||||
# 新格式记录:报告键直取瞬时失败走到这里,裸文件原样返回
|
||||
return data
|
||||
wrapper = json.loads(data.decode("utf-8"))
|
||||
content = wrapper.get("content")
|
||||
if content is None:
|
||||
raise ValueError(f"遗留报告对象缺少 content 字段: {record.object_key}")
|
||||
return content.encode("utf-8")
|
||||
|
||||
|
||||
def _download_from_local(filename: str) -> Optional[bytes]:
|
||||
"""存量兜底:旧 worker 写入共享卷 html_output 的历史产物。"""
|
||||
path = LOCAL_HTML_DIR / filename
|
||||
if path.is_file():
|
||||
return path.read_bytes()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/html/{filename:path}", summary="读取 HTML 可视化报告")
|
||||
async def get_html_report(filename: str) -> Response:
|
||||
_validate_filename(filename)
|
||||
|
||||
data = await _download_from_rustfs(filename)
|
||||
source = "rustfs"
|
||||
if data is None:
|
||||
try:
|
||||
data = await _download_from_legacy_record(filename)
|
||||
except Exception as exc:
|
||||
logger.debug(f"遗留记录解析失败(继续本地兜底): {filename}: {exc}")
|
||||
source = "rustfs-legacy"
|
||||
if data is None:
|
||||
data = _download_from_local(filename)
|
||||
source = "local-fallback"
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
|
||||
|
||||
if source != "rustfs":
|
||||
# 存量链路命中留痕,便于评估遗留对象与本地卷的清理时机
|
||||
logger.info(f"报告经 {source} 链路命中: {filename}")
|
||||
return Response(content=data, media_type=_media_type_for(filename))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user