Compare commits

...

2 Commits

Author SHA1 Message Date
cjw 8493f0df03 init 2026-07-13 17:44:50 +08:00
cjw 975fa1ffac init 2026-07-13 17:44:37 +08:00
37 changed files with 1050 additions and 802 deletions
-68
View File
@@ -1,68 +0,0 @@
# 服务配置
HOST=0.0.0.0
# ================================
# 端口配置 - 唯一修改端口的地方
# ================================
# 应用端口(容器内端口)
PORT=10003
# Docker映射到宿主机的端口(docker-compose使用)
HOST_PORT=10001
# ================================
DEBUG=false
# 文件处理配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600
ALLOWED_EXTENSIONS=.stp,.step,.stp.gz
# 几何处理配置
POINTCLOUD_SAMPLE_COUNT=10000
MESH_QUALITY=high
PARALLEL_PROCESSING=true
# 数据库配置
DB_HOST=szcjw
DB_PORT=5432
DB_NAME=moldinsight
DB_USER=moldinsight
DB_PASSWORD=Qqs1996*
# RustFS 对象存储配置 (S3v4 API)
RUSTFS_ENDPOINT=http://szcjw:8010
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
RUSTFS_TIMEOUT=30
RUSTFS_PRESIGNED_URL_EXPIRES=3600
# JWT认证配置
SECRET_KEY=your-secret-key-change-in-production-min-32-chars
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Redis配置
REDIS_HOST=szcjw
REDIS_PORT=6379
REDIS_PASSWORD=Qqs1996*
REDIS_DB=0
# 管理员配置
ADMIN_USERNAME=cjw
ADMIN_PASSWORD=Qqs1996*
ADMIN_EMAIL=792430652@qq.com
ADMIN_FULL_NAME=管理员
# LLM 增强分析配置(可选)
# 启用后自动生成模具设计评审报告和分模方向推荐
# 支持 OpenAI 兼容 API(OpenAI / DeepSeek / vLLM / Ollama 等)
LLM_ENABLED=true
LLM_API_URL=https://api.deepseek.com/v1
LLM_API_KEY=sk-509f968af3e2466bbef8b5949180782c
LLM_MODEL=deepseek-v4-flash
LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000
+2
View File
@@ -106,9 +106,11 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-moldinsight_password}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
RUSTFS_ENDPOINT: http://minio:9000
RUSTFS_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
RUSTFS_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
depends_on:
- moldinsight
restart: unless-stopped
+162
View File
@@ -0,0 +1,162 @@
# geMoldInsight 演进路线图
> 本文档是代码与功能演进的执行清单,基于 2026-07-13 的全量代码体检。每项含「现象 / 证据 / 修法 / 验证」,按 P0→P3 推进,完成后勾选。
## 诊断
代码已演进到「双应用模块化」形态(`entrypoints/` + `moldinsight/` + `inventory/` + `shared/`),但有 **三处结构性缺失** 让快速迭代变贵,外加 **一批静默 bug** 正在让功能"看起来在跑其实没跑":
- **缺中间层**:业务逻辑堆在路由/编排函数里(进销存无 service 层、`process_file_core` 280 行线性函数)
- **缺契约**:前后端靠手写类型,字段已大面积漂移(财务页整页是 0)
- **缺连接**:模具分析与进销存是两个孤立产品(`STPFile` 没有 `product_id`)
---
## P0 止血:正在静默失效的功能(1–2 周)
这些不是技术债,是**现在就在坏**的东西,先修。
### P0-1 Celery worker 不连 Redis/RustFS,异步任务全坏
- **现象**:任务进度写进 Celery 私有内存,web 端永远读不到;首次上传 RustFS 直接抛 `RuntimeError("RustFS 未连接")`。
- **证据**:`redis_task_manager.connect()` / `rustfs_manager.connect()` 只在 FastAPI startup 调用(`entrypoints/moldinsight.py:42,48`),Celery 进程不跑 startup;`processing_service.py` 在 celery 内调 `update_task` 时 `is_connected=False` 走 `_fallback_set`;`rustfs_storage.py:125-126` 未连接直接抛错。`deploy/docker-compose.yml` 的 `moldinsight-celery` 服务块缺 `REDIS_PASSWORD`。
- **修法**:`celery_tasks.py` 加 `@worker_process_init` 信号,显式 `connect()` redis 与 rustfs;补齐 celery 服务的 `REDIS_PASSWORD`/`SECRET_KEY` 等环境变量,与主应用对齐。
- **验证**:上传一个 STP,Celery 路径下任务进度能从 web 端 `/api/status/{task_id}` 读到;上传后 RustFS 中能看到对象。
- **状态**:- [ ]
### P0-2 LLM 设计报告 NameError,静默失效
- **现象**:`LLM_ENABLED=true` 时设计报告功能直接没有。
- **证据**:`llm_service.py:339` 用未定义变量 `trimmed`(应为 `features`,`trimmed` 只在 `_build_side_action_prompt` 中定义),外层 `try/except` 吞掉 `NameError` 返回 `None`。
- **修法**:`trimmed` -> `features`。
- **验证**:启用 LLM 后设计报告字段非空。
- **状态**:- [ ]
### P0-3 前端财务页全字段错配
- **现象**:FinanceTab 整页 0/空;用户管理菜单永不显示(`is_superuser` 后端不返回);dashboard 成品数恒 0。
- **证据**:16 处字段名对不上,如 `total_receivable` vs `receivable_total`(`finance_schemas.py:66`)、`order_no` vs `txn_no`(`finance_schemas.py:48`)等;`App.vue:91` 读 `is_superuser` 但 `UserResponse` 无此字段。
- **修法**:短期按映射手改前端字段;长期靠 P1-3 OpenAPI 契约生成根治。
- **验证**:财务页卡片与表格显示真实数据;用户管理菜单对管理员可见。
- **状态**:- [ ]
### P0-4 OCC 线程安全自相矛盾
- **现象**:偶发崩溃,外层 `max_workers=1` 保护形同虚设。
- **证据**:`processing_service.py:50-51` 用单线程池序列化 OCC,但 `geometry_analyzer._detect_features`(`geometry_analyzer.py:81`)内部又开 `ThreadPoolExecutor(max_workers=4)` 并行操作 OCC `TopoDS_Shape`。
- **修法**:特征检测器改串行;或预处理阶段把面特征抽成纯数值,检测器只处理数值不碰 OCC。
- **验证**:压测大模型反复分析无崩溃。
- **状态**:- [ ]
### P0-5 `.env` 进了 git 历史,真实密钥泄露
- **现象**:DB/Redis/SECRET_KEY/LLM key 已进入仓库历史。
- **证据**:`git ls-files --error-unmatch .env` 命中;`git log -- .env` 有 10+ 次提交;`.env` 内含真实凭据。
- **修法**:`git rm --cached .env`(停止跟踪,保留本地,后续不再提交);`SECRET_KEY` 从默认占位符轮换为强随机值。
- **用户决策(2026-07-13)**:私有仓库,不轮换其他密钥、不重写 git 历史。
- **验证**:`git status` 显示 `.env` 不再被跟踪(`D .env`)。
- **状态**:- [x]
### P0-6 铝价路由模块化部署后丢失
- **现象**:模块化部署后 `/api/aluminum-price/*` 直接 404。
- **证据**:单体 `main.py:47,155` 挂了 `aluminum_price_router`,但 `moldinsight/api/__init__.py` 的 `_safe_include` 列表不含 `aluminum_price_routes`。
- **修法**:把 `aluminum_price_routes` 加入 `_safe_include`。
- **验证**:模块化部署下 `/api/aluminum-price/*` 可访问。
- **状态**:- [ ]
### P0-7 导出缓存无持久化回退
- **现象**:多 worker 或重启后导出返回 409。
- **证据**:`processing_service._export_shapes_cache` 是进程内 dict,`get_export_shapes` 只查内存;`_persist_step_exports` 已写磁盘 manifest 但无回读逻辑。
- **修法**:`get_export_shapes` 缓存未命中时从磁盘 manifest 回读。
- **验证**:重启后导出仍可用。
- **状态**:- [ ]
---
### P0 执行结果(2026-07-13)
- ✅ **P0-1 Celery 连接**:`celery_tasks.py` 在任务内显式 `redis_task_manager.reconnect()` + `rustfs_manager.connect()`(Redis 客户端绑定事件循环,每任务 reconnect;RustFS 同步客户端连一次复用);`deploy/docker-compose.yml` celery 服务补 `REDIS_PASSWORD`/`RUSTFS_TIMEOUT`
- ✅ **P0-2 LLM NameError**:`llm_service.py:339` `trimmed` -> `features`
- ✅ **P0-3 前端字段错配**:FinanceTab 全字段对齐 schema(summary/statement/product-statement/transaction 共 16 处);`UserResponse` 加 `is_superuser` + 统一 `_build_user_response` 构造(修用户管理菜单不显示);DashboardTab `product_count`->`finished_product_count`;PurchaseOrdersTab `received_at/paid_at`->`received_date/paid_date`;后端 `FinanceTransactionResponse` 补 `partner_name` 并批量查询客户/供应商名称
- ✅ **P0-4 OCC 线程安全**:`geometry_analyzer._detect_features` `max_workers` 4->1
- ✅ **P0-5 .env 泄露**:`git rm --cached .env` 已取消跟踪(后续不再提交);`SECRET_KEY` 从默认占位符轮换为强随机值(现有登录 token 失效)。用户决策:私有仓库,不轮换其他密钥、不重写 git 历史
- ✅ **P0-6 铝价路由**:`moldinsight/api/__init__.py` `_safe_include` 加入 `aluminum_price_routes`
- ℹ️ **P0-7 导出缓存**:经排查**非 bug**——`export_artifacts` 已写 PG+Redis(`processing_service.py:342,361`),导出端点先走 `_select_persisted_files` 从 task_data 读取(`advanced_router.py:436`),重启后正常工作;409 仅在持久化也失败时出现,"请重新分析"提示为正确行为。内存 re-export 缓存的可靠性优化归入 P1-2
**未做验证**:前端未跑 vue-tsc 构建(字段重命名属机械改动,低风险);后端未跑 pytest(需 DB/Redis 环境)。建议下次在完整环境验证。
---
## P1 结构性地基:让后续迭代不再昂贵(持续)
### P1-1 进销存抽 service 层
- **现状**:`finance_routes.py` 751 行、`sales_order_routes.py` 777 行,事务编排/库存原子更新/流水写入全耦合在 endpoint;`shared/services/` 仅 auth+redis。
- **目标**:新建 `inventory/services/`,`PurchaseOrderService.receive()`、`SalesOrderService.issue_materials()`、`FinanceService.settle()`,route 只做校验+组装。
- **状态**:- [ ]
### P1-2 moldinsight 可插拔注册表 + Stage 流水线
- **现状**:模具类型硬编码 if-else(`multi_scheme_planner.py:40`);特征检测器硬编码 6 个(`geometry_analyzer.py:76-99`);`process_file_core` 280 行。
- **目标**:`FeatureDetectorRegistry` + `MoldGeneratorRegistry`(`@register` 装饰器);`process_file_core` 拆成 Stage 链。
- **解锁**:新增模具类型、IGES/BREP、批量分析。
- **状态**:- [ ]
### P1-3 前端 OpenAPI 契约生成
- **现状**:前端 40+ 处 `any`,字段全手写已大面积错配。
- **目标**:`openapi-typescript` 从 `/openapi.json` 生成 TS 类型替换 `any`;`api.ts` 加 baseURL/拦截器/超时,按域封装 `inventoryApi`/`moldinsightApi`/`authApi`。
- **状态**:- [ ]
### P1-4 引入 Alembic,废除裸 DDL
- **现状**:无 `alembic.ini`;`init_db.py` 22 条 `ALTER TABLE ADD COLUMN IF NOT EXISTS`,无版本/无回滚;`migrate_db.py` 是 `drop_all` 破坏性脚本;两应用 startup 并发跑 DDL 争锁。
- **目标**:`alembic init`,固化版本化迁移,启动只 `upgrade head`;删 `migrate_db.py`。
- **状态**:- [ ]
### P1-5 统一材料属性源
- **现状**:材料字典在 4 处重复定义且冲突(PE 收缩率 `material_service` 0.020 vs `aluminum_foam_mold.py:92` 0.025)。
- **目标**:`MaterialService` 作为唯一源,其他模块查询。
- **状态**:- [ ]
---
### P1 执行结果(进行中)
- 🚧 **P1-1 进销存抽 service 层**(进行中):
- ✅ 建立 `inventory/services/` 层,抽出 `FinanceService`(`finance_routes` 767→123 行,业务逻辑全下沉;路由仅做参数校验+响应组装)
- ✅ 将 `schemas/` 与 `utils.py` 从 `inventory/api/` 移至 `inventory/` 顶层,打破 service↔api 循环导入(schemas 是共享 DTO、utils 是纯函数,本不应嵌在 api 层;这是正确分层)
- ✅ import 测试通过:9 finance 路由 + 55 inventory 路由全部正常加载
- ⏳ 待办:抽 `sales_order_service` / `purchase_order_service` / `inventory`+`stock_movement` service;清理死代码 `api/utils.py`(待 `git rm`)
---
## P2 功能演进:把两个产品变成一个
### P2-1 打通模具分析 -> 进销存(最高产品价值)
- **现状**:`STPFile` 无 `product_id`,moldinsight 与 inventory 零数据关联。
- **目标**:`STPFile` 加 `product_id` 外键(可空),分析完成后一键创建 `Product(finished)` 并回写。
- **状态**:- [ ]
### P2-2 真 AI 落地,砍掉假 AI
- **现状**:`ai_mold_assistant.py` 209 行纯 stub 从未被调用;`ai_parting_detector.py` GNN 框架完整但无权重;`llm_service` 是唯一真接 AI(且有 P0-2 bug)。
- **目标**:聚焦一个能跑通的 AI 能力(LLM 扩到成本估算/工艺对话);GNN 要么真训权重,要么移除 stub。
- **状态**:- [ ]
### P2-3 模具成本估算 + 批量分析
- 依赖 P1-2 完成后才有性价比。
- **状态**:- [ ]
---
## P3 工程治理(穿插顺手做)
- [ ] `create_app()` 工厂消除两入口重复引导,废弃单体 `main.py`
- [ ] 删死依赖/死代码:Kafka(零引用)、`templates/` legacy、`ai_mold_assistant` stub、`ProcessingService.__init__` 3 个死实例
- [ ] `get_db_session` 统一事务边界(commit/rollback),废除路由手动 commit
- [ ] 连接池治理(3 进程峰值 150 > PG 默认 100),考虑 PgBouncer
- [ ] 进销存 state 从模块级单例迁回 Pinia,tab 改子路由
- [ ] 统一 `/health` 响应 schema;SPA fallback 排除 `/api` 前缀避免吞 404
- [ ] CORS 收敛(`allow_origins=["*"]` + `allow_credentials=True` 不安全)
---
## 执行进度
| 阶段 | 项数 | 已完成 | 进行中 |
|------|------|--------|--------|
| P0 | 7 | 6 修复 + 1 排查 | - |
| P1 | 5 | 0 | P1-1 进行中(finance 已抽) |
| P2 | 3 | 0 | - |
| P3 | 7 | 0 | - |
@@ -6,7 +6,7 @@ const { state, formatCurrency } = useInventory()
<template>
<t-row :gutter="16">
<t-col :span="8" v-for="card in [
{ icon: '📦', value: state.dashboard?.product_count || 0, label: '成品数量' },
{ icon: '📦', value: state.dashboard?.finished_product_count || 0, label: '成品数量' },
{ icon: '📊', value: state.dashboard?.total_stock || 0, label: '物料库存总量' },
{ icon: '💰', value: formatCurrency(state.dashboard?.total_value || 0), label: '库存价值' },
{ icon: '🏭', value: state.dashboard?.supplier_count || 0, label: '供应商' },
@@ -37,20 +37,22 @@ onMounted(() => {
refreshFinance()
})
// 字段名对齐后端 FinanceSummaryResponse:
// receivable_total / payable_total / period_receipt_total / period_payment_total
const totalReceivable = computed(() => {
return state.financeSummary?.total_receivable ?? 0
return state.financeSummary?.receivable_total ?? 0
})
const totalPayable = computed(() => {
return state.financeSummary?.total_payable ?? 0
return state.financeSummary?.payable_total ?? 0
})
const periodReceived = computed(() => {
return state.financeSummary?.period_received ?? 0
return state.financeSummary?.period_receipt_total ?? 0
})
const periodPaid = computed(() => {
return state.financeSummary?.period_paid ?? 0
return state.financeSummary?.period_payment_total ?? 0
})
function getTransactionTypeLabel(type: string): string {
@@ -136,6 +138,7 @@ function getTransactionStatusLabel(status: string): string {
</t-col>
</t-row>
<!-- 字段对齐 PartnerStatementItemResponse:order_total / settled_total / transaction_total / outstanding_total -->
<t-card style="margin-bottom: 16px;">
<template #header>
<span style="font-weight: 600;">客户账款(周期)</span>
@@ -145,16 +148,16 @@ function getTransactionStatusLabel(status: string): string {
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="transaction_count" label="流水数" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_amount) }}</template>
<template #default="{ row }">{{ formatCurrency(row.order_total) }}</template>
</t-table-column>
<t-table-column label="订单已收">
<template #default="{ row }">{{ formatCurrency(row.order_received) }}</template>
<template #default="{ row }">{{ formatCurrency(row.settled_total) }}</template>
</t-table-column>
<t-table-column label="实收流水">
<template #default="{ row }">{{ formatCurrency(row.transaction_received) }}</template>
<template #default="{ row }">{{ formatCurrency(row.transaction_total) }}</template>
</t-table-column>
<t-table-column label="应收余额">
<template #default="{ row }">{{ formatCurrency(row.receivable_balance) }}</template>
<template #default="{ row }">{{ formatCurrency(row.outstanding_total) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.customerFinanceStatement.length === 0" description="暂无数据" />
@@ -169,21 +172,22 @@ function getTransactionStatusLabel(status: string): string {
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="transaction_count" label="流水数" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_amount) }}</template>
<template #default="{ row }">{{ formatCurrency(row.order_total) }}</template>
</t-table-column>
<t-table-column label="订单已付">
<template #default="{ row }">{{ formatCurrency(row.order_paid) }}</template>
<template #default="{ row }">{{ formatCurrency(row.settled_total) }}</template>
</t-table-column>
<t-table-column label="实付流水">
<template #default="{ row }">{{ formatCurrency(row.transaction_paid) }}</template>
<template #default="{ row }">{{ formatCurrency(row.transaction_total) }}</template>
</t-table-column>
<t-table-column label="应付余额">
<template #default="{ row }">{{ formatCurrency(row.payable_balance) }}</template>
<template #default="{ row }">{{ formatCurrency(row.outstanding_total) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.supplierFinanceStatement.length === 0" description="暂无数据" />
</t-card>
<!-- 字段对齐 PartnerProductStatementItemResponse:order_quantity / order_amount / settled_amount / outstanding_amount -->
<t-card style="margin-bottom: 16px;">
<template #header>
<span style="font-weight: 600;">客户-商品追溯(周期)</span>
@@ -193,7 +197,7 @@ function getTransactionStatusLabel(status: string): string {
<t-table-column prop="product_sku" label="SKU" />
<t-table-column prop="product_name" label="商品" />
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="quantity" label="数量" />
<t-table-column prop="order_quantity" label="数量" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_amount) }}</template>
</t-table-column>
@@ -201,7 +205,7 @@ function getTransactionStatusLabel(status: string): string {
<template #default="{ row }">{{ formatCurrency(row.settled_amount) }}</template>
</t-table-column>
<t-table-column label="未结款">
<template #default="{ row }">{{ formatCurrency(row.unsettled_amount) }}</template>
<template #default="{ row }">{{ formatCurrency(row.outstanding_amount) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.customerProductStatement.length === 0" description="暂无数据" />
@@ -216,7 +220,7 @@ function getTransactionStatusLabel(status: string): string {
<t-table-column prop="product_sku" label="SKU" />
<t-table-column prop="product_name" label="商品" />
<t-table-column prop="order_count" label="订单数" />
<t-table-column prop="quantity" label="数量" />
<t-table-column prop="order_quantity" label="数量" />
<t-table-column label="订单金额">
<template #default="{ row }">{{ formatCurrency(row.order_amount) }}</template>
</t-table-column>
@@ -224,22 +228,24 @@ function getTransactionStatusLabel(status: string): string {
<template #default="{ row }">{{ formatCurrency(row.settled_amount) }}</template>
</t-table-column>
<t-table-column label="未结款">
<template #default="{ row }">{{ formatCurrency(row.unsettled_amount) }}</template>
<template #default="{ row }">{{ formatCurrency(row.outstanding_amount) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.supplierProductStatement.length === 0" description="暂无数据" />
</t-card>
<!-- 字段对齐 FinanceTransactionResponse:txn_no / txn_type / txn_date
TODO: 往来方列依赖 partner_name,后端 FinanceTransactionResponse 暂未返回,需补 -->
<t-card>
<template #header>
<span style="font-weight: 600;">最近财务流水</span>
</template>
<t-table :data="state.financeTransactions" :loading="state.loading" stripe size="small">
<t-table-column prop="order_no" label="单号" />
<t-table-column prop="txn_no" label="单号" />
<t-table-column label="类型">
<template #default="{ row }">
<t-tag :type="getTransactionTypeTag(row.type)" size="small">
{{ getTransactionTypeLabel(row.type) }}
<t-tag :type="getTransactionTypeTag(row.txn_type)" size="small">
{{ getTransactionTypeLabel(row.txn_type) }}
</t-tag>
</template>
</t-table-column>
@@ -255,7 +261,7 @@ function getTransactionStatusLabel(status: string): string {
</template>
</t-table-column>
<t-table-column label="日期">
<template #default="{ row }">{{ formatDate(row.transaction_date) }}</template>
<template #default="{ row }">{{ formatDate(row.txn_date) }}</template>
</t-table-column>
</t-table>
<t-empty v-if="!state.loading && state.financeTransactions.length === 0" description="暂无数据" />
@@ -325,10 +325,10 @@ onMounted(() => {
<template #default="{ row }">{{ formatDate(row.expected_date) }}</template>
</t-table-column>
<t-table-column label="实际到货">
<template #default="{ row }">{{ row.received_at ? formatDateTime(row.received_at) : '-' }}</template>
<template #default="{ row }">{{ row.received_date ? formatDateTime(row.received_date) : '-' }}</template>
</t-table-column>
<t-table-column label="实际付款">
<template #default="{ row }">{{ row.paid_at ? formatDateTime(row.paid_at) : '-' }}</template>
<template #default="{ row }">{{ row.paid_date ? formatDateTime(row.paid_date) : '-' }}</template>
</t-table-column>
<t-table-column label="总金额">
<template #default="{ row }">{{ formatCurrency(row.total_amount) }}</template>
+23 -3
View File
@@ -1,5 +1,6 @@
from celery_app import app
from moldinsight.services.processing_service import processing_service
from shared.config.settings import settings
from shared.utils.logger import get_logger
logger = get_logger(__name__)
@@ -11,11 +12,30 @@ def process_stp_task(self, task_id: str, file_path: str, stp_file_id: int,
"""Celery 任务:异步处理 STP 文件生成模具型腔"""
import asyncio
async def _run():
# Celery 进程不执行 FastAPI startup,必须在此处显式建立连接:
# - redis.asyncio 客户端绑定创建它的循环,而本任务经 asyncio.run 每次新建循环,
# 故每任务需 reconnect();
# - RustFS(Minio) 为同步客户端,不绑定循环,连一次后跨任务复用。
from shared.services.redis_task_manager import redis_task_manager
from moldinsight.storage.rustfs_storage import rustfs_manager
await redis_task_manager.reconnect()
if not rustfs_manager.is_connected:
await rustfs_manager.connect(
endpoint=settings.RUSTFS_ENDPOINT,
access_key=settings.RUSTFS_ACCESS_KEY,
secret_key=settings.RUSTFS_SECRET_KEY,
timeout=settings.RUSTFS_TIMEOUT,
)
await processing_service.process_file_with_storage(
task_id, file_path, stp_file_id, process_params
)
try:
logger.info(f"[celery] 开始处理: {task_id}")
asyncio.run(processing_service.process_file_with_storage(
task_id, file_path, stp_file_id, process_params
))
asyncio.run(_run())
logger.info(f"[celery] 处理完成: {task_id}")
return {"task_id": task_id, "status": "completed"}
except Exception as exc:
+1 -1
View File
@@ -18,7 +18,7 @@ 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 .schemas import CustomerCreate, CustomerResponse
from ..schemas import CustomerCreate, CustomerResponse
router = APIRouter(prefix="/customers", tags=["客户管理"])
+18 -646
View File
@@ -1,26 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, Query
"""财务路由层 - 薄路由
业务逻辑下沉至 inventory.services.finance_service,路由只做参数校验与响应组装。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from typing import Optional, List, Dict, Tuple
from datetime import datetime
from decimal import Decimal
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,
Customer,
Supplier,
Product,
SalesOrder,
SalesOrderItem,
PurchaseOrder,
PurchaseOrderItem,
FinanceTransaction,
FinanceAllocation,
)
from .schemas import (
from shared.models.database import User
from ..schemas import (
ReceiptCreate,
PaymentCreate,
FinanceTransactionResponse,
@@ -28,141 +17,21 @@ from .schemas import (
ReceivableItemResponse,
PayableItemResponse,
FinancePartnerStatementResponse,
PartnerStatementItemResponse,
FinancePartnerProductStatementResponse,
PartnerProductStatementItemResponse,
PaginatedResponse,
)
from .utils import generate_order_no
from shared.utils.logger import get_logger
logger = get_logger(__name__)
from ..services.finance_service import finance_service
router = APIRouter(prefix="/finance", tags=["财务管理"])
def _build_transaction_response(txn: FinanceTransaction) -> FinanceTransactionResponse:
allocations = [
{
"id": item.id,
"order_type": item.order_type,
"order_id": item.order_id,
"allocated_amount": item.allocated_amount,
}
for item in txn.allocations
]
return FinanceTransactionResponse(
id=txn.id,
txn_no=txn.txn_no,
txn_type=txn.txn_type,
partner_type=txn.partner_type,
partner_id=txn.partner_id,
amount=txn.amount,
txn_date=txn.txn_date,
method=txn.method,
account_name=txn.account_name,
status=txn.status,
remark=txn.remark,
created_at=txn.created_at,
allocations=allocations,
)
def _validate_allocation_total(transaction_amount: float, allocation_amounts: List[float]):
allocated_total = sum(allocation_amounts)
if allocated_total - transaction_amount > 1e-6:
raise HTTPException(status_code=400, detail="核销总额不能大于单据金额")
def _resolve_period_scope(year: Optional[int], quarter: Optional[int]) -> Tuple[int, Optional[int], str, datetime, datetime]:
now = datetime.now()
selected_year = year or now.year
if selected_year < 2000 or selected_year > 2100:
raise HTTPException(status_code=400, detail="年份超出支持范围")
if quarter is not None and quarter not in [1, 2, 3, 4]:
raise HTTPException(status_code=400, detail="季度必须是1-4")
if quarter is None:
period_start = datetime(selected_year, 1, 1)
period_end = datetime(selected_year + 1, 1, 1)
period_label = f"{selected_year}年"
else:
start_month = (quarter - 1) * 3 + 1
period_start = datetime(selected_year, start_month, 1)
if quarter == 4:
period_end = datetime(selected_year + 1, 1, 1)
else:
period_end = datetime(selected_year, start_month + 3, 1)
period_label = f"{selected_year}年Q{quarter}"
return selected_year, quarter, period_label, period_start, period_end
@router.post("/receipts", response_model=FinanceTransactionResponse, status_code=201)
async def create_receipt(
payload: ReceiptCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
customer_result = await db_session.execute(
select(Customer).where(Customer.id == payload.customer_id, Customer.is_active == True)
)
customer = customer_result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
_validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
txn = FinanceTransaction(
txn_no=generate_order_no("RC"),
txn_type="receipt",
partner_type="customer",
partner_id=payload.customer_id,
amount=payload.amount,
txn_date=payload.txn_date or datetime.now(),
method=payload.method,
account_name=payload.account_name,
status="confirmed",
remark=payload.remark,
operator_id=current_user.id,
)
db_session.add(txn)
await db_session.flush()
for allocation in payload.allocations:
if allocation.order_type != "sales":
raise HTTPException(status_code=400, detail="收款单只允许核销销售订单")
order_result = await db_session.execute(
select(SalesOrder).where(SalesOrder.id == allocation.order_id, SalesOrder.customer_id == payload.customer_id)
)
sales_order = order_result.scalar_one_or_none()
if not sales_order:
raise HTTPException(status_code=404, detail=f"销售订单不存在: {allocation.order_id}")
remaining = (sales_order.total_amount or 0) - (sales_order.received_amount or 0)
if allocation.allocated_amount - remaining > 1e-6:
raise HTTPException(status_code=400, detail=f"销售订单核销超额: {sales_order.order_no}")
db_session.add(
FinanceAllocation(
transaction_id=txn.id,
order_type="sales",
order_id=sales_order.id,
allocated_amount=allocation.allocated_amount,
)
)
sales_order.received_amount = (sales_order.received_amount or 0) + allocation.allocated_amount
await db_session.commit()
result = await db_session.execute(
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.where(FinanceTransaction.id == txn.id)
)
created = result.scalar_one()
return _build_transaction_response(created)
return await finance_service.create_receipt(db_session, payload, current_user)
@router.post("/payments", response_model=FinanceTransactionResponse, status_code=201)
@@ -171,64 +40,7 @@ async def create_payment(
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 == payload.supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
_validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
txn = FinanceTransaction(
txn_no=generate_order_no("PY"),
txn_type="payment",
partner_type="supplier",
partner_id=payload.supplier_id,
amount=payload.amount,
txn_date=payload.txn_date or datetime.now(),
method=payload.method,
account_name=payload.account_name,
status="confirmed",
remark=payload.remark,
operator_id=current_user.id,
)
db_session.add(txn)
await db_session.flush()
for allocation in payload.allocations:
if allocation.order_type != "purchase":
raise HTTPException(status_code=400, detail="付款单只允许核销采购订单")
order_result = await db_session.execute(
select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id, PurchaseOrder.supplier_id == payload.supplier_id)
)
purchase_order = order_result.scalar_one_or_none()
if not purchase_order:
raise HTTPException(status_code=404, detail=f"采购订单不存在: {allocation.order_id}")
remaining = (purchase_order.total_amount or 0) - (purchase_order.paid_amount or 0)
if allocation.allocated_amount - remaining > 1e-6:
raise HTTPException(status_code=400, detail=f"采购订单核销超额: {purchase_order.order_no}")
db_session.add(
FinanceAllocation(
transaction_id=txn.id,
order_type="purchase",
order_id=purchase_order.id,
allocated_amount=allocation.allocated_amount,
)
)
purchase_order.paid_amount = (purchase_order.paid_amount or 0) + allocation.allocated_amount
await db_session.commit()
result = await db_session.execute(
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.where(FinanceTransaction.id == txn.id)
)
created = result.scalar_one()
return _build_transaction_response(created)
return await finance_service.create_payment(db_session, payload, current_user)
@router.get("/transactions", response_model=PaginatedResponse[FinanceTransactionResponse])
@@ -242,29 +54,7 @@ async def list_transactions(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
base_query = (
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.order_by(FinanceTransaction.created_at.desc())
)
if txn_type:
base_query = base_query.where(FinanceTransaction.txn_type == txn_type)
if status:
base_query = base_query.where(FinanceTransaction.status == status)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
base_query = base_query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
rows = result.scalars().all()
return PaginatedResponse(
items=[_build_transaction_response(item) for item in rows],
total=total, skip=skip, limit=limit
)
return await finance_service.list_transactions(db_session, txn_type, status, year, quarter, skip, limit)
@router.post("/transactions/{transaction_id}/void")
@@ -273,36 +63,7 @@ async def void_transaction(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
result = await db_session.execute(
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.where(FinanceTransaction.id == transaction_id)
)
txn = result.scalar_one_or_none()
if not txn:
raise HTTPException(status_code=404, detail="财务单据不存在")
if txn.status == "voided":
return {"message": "单据已作废"}
for allocation in txn.allocations:
if allocation.order_type == "sales":
sales_result = await db_session.execute(select(SalesOrder).where(SalesOrder.id == allocation.order_id))
sales_order = sales_result.scalar_one_or_none()
if sales_order:
sales_order.received_amount = max((sales_order.received_amount or 0) - allocation.allocated_amount, 0)
elif allocation.order_type == "purchase":
purchase_result = await db_session.execute(select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id))
purchase_order = purchase_result.scalar_one_or_none()
if purchase_order:
purchase_order.paid_amount = max((purchase_order.paid_amount or 0) - allocation.allocated_amount, 0)
txn.status = "voided"
await db_session.commit()
logger.warning(
"财务单据已作废: txn_no=%s txn_type=%s amount=%s operator_id=%s",
txn.txn_no, txn.txn_type, txn.amount, current_user.id
)
return {"message": "单据已作废"}
return await finance_service.void_transaction(db_session, transaction_id, current_user)
@router.get("/receivables", response_model=List[ReceivableItemResponse])
@@ -314,33 +75,7 @@ async def list_receivables(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
query = (
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
.order_by(SalesOrder.created_at.desc())
)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
query = query.where(SalesOrder.order_date >= period_start).where(SalesOrder.order_date < period_end)
result = await db_session.execute(query.offset(skip).limit(limit))
rows = []
for order, customer in result.all():
receivable_amount = (order.total_amount or 0) - (order.received_amount or 0)
rows.append(
ReceivableItemResponse(
order_id=order.id,
order_no=order.order_no,
customer_id=customer.id,
customer_name=customer.name,
order_date=order.order_date,
total_amount=order.total_amount or 0,
received_amount=order.received_amount or 0,
receivable_amount=receivable_amount,
status=order.status,
)
)
return rows
return await finance_service.list_receivables(db_session, year, quarter, skip, limit)
@router.get("/payables", response_model=List[PayableItemResponse])
@@ -352,33 +87,7 @@ async def list_payables(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
query = (
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
.order_by(PurchaseOrder.created_at.desc())
)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
query = query.where(PurchaseOrder.order_date >= period_start).where(PurchaseOrder.order_date < period_end)
result = await db_session.execute(query.offset(skip).limit(limit))
rows = []
for order, supplier in result.all():
payable_amount = (order.total_amount or 0) - (order.paid_amount or 0)
rows.append(
PayableItemResponse(
order_id=order.id,
order_no=order.order_no,
supplier_id=supplier.id,
supplier_name=supplier.name,
order_date=order.order_date,
total_amount=order.total_amount or 0,
paid_amount=order.paid_amount or 0,
payable_amount=payable_amount,
status=order.status,
)
)
return rows
return await finance_service.list_payables(db_session, year, quarter, skip, limit)
@router.get("/summary", response_model=FinanceSummaryResponse)
@@ -388,60 +97,7 @@ async def get_finance_summary(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
receivable_total = await db_session.scalar(
select(func.coalesce(func.sum(SalesOrder.total_amount - SalesOrder.received_amount), 0))
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
) or 0
payable_total = await db_session.scalar(
select(func.coalesce(func.sum(PurchaseOrder.total_amount - PurchaseOrder.paid_amount), 0))
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
) or 0
now = datetime.now()
month_start = datetime(now.year, now.month, 1)
monthly_receipt_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= month_start)
) or 0
monthly_payment_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= month_start)
) or 0
selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter)
period_receipt_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
) or 0
period_payment_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
) or 0
return FinanceSummaryResponse(
receivable_total=Decimal(str(receivable_total)),
payable_total=Decimal(str(payable_total)),
monthly_receipt_total=Decimal(str(monthly_receipt_total)),
monthly_payment_total=Decimal(str(monthly_payment_total)),
selected_year=selected_year,
selected_quarter=selected_quarter,
period_label=period_label,
period_receipt_total=Decimal(str(period_receipt_total)),
period_payment_total=Decimal(str(period_payment_total)),
overdue_receivable_count=0,
overdue_payable_count=0,
)
return await finance_service.get_summary(db_session, year, quarter)
@router.get("/partner-statement/{partner_type}", response_model=FinancePartnerStatementResponse)
@@ -452,163 +108,7 @@ async def get_partner_statement(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
if partner_type not in ["customer", "supplier"]:
raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier")
selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter)
stats_map: Dict[int, Dict] = {}
if partner_type == "customer":
order_rows = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.order_date >= period_start)
.where(SalesOrder.order_date < period_end)
.where(Customer.is_active == True)
)
for order, customer in order_rows.all():
partner_stat = stats_map.setdefault(
customer.id,
{
"partner_id": customer.id,
"partner_name": customer.name,
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
total_amount = Decimal(str(order.total_amount or 0))
settled_amount = Decimal(str(order.received_amount or 0))
outstanding = max(total_amount - settled_amount, Decimal("0"))
partner_stat["order_count"] += 1
partner_stat["order_total"] += total_amount
partner_stat["settled_total"] += settled_amount
partner_stat["outstanding_total"] += outstanding
transaction_rows = await db_session.execute(
select(FinanceTransaction)
.where(FinanceTransaction.partner_type == "customer")
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
)
for txn in transaction_rows.scalars().all():
partner_stat = stats_map.setdefault(
txn.partner_id,
{
"partner_id": txn.partner_id,
"partner_name": f"客户#{txn.partner_id}",
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
partner_stat["transaction_count"] += 1
partner_stat["transaction_total"] += Decimal(str(txn.amount or 0))
else:
order_rows = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.order_date >= period_start)
.where(PurchaseOrder.order_date < period_end)
.where(Supplier.is_active == True)
)
for order, supplier in order_rows.all():
partner_stat = stats_map.setdefault(
supplier.id,
{
"partner_id": supplier.id,
"partner_name": supplier.name,
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
total_amount = Decimal(str(order.total_amount or 0))
settled_amount = Decimal(str(order.paid_amount or 0))
outstanding = max(total_amount - settled_amount, Decimal("0"))
partner_stat["order_count"] += 1
partner_stat["order_total"] += total_amount
partner_stat["settled_total"] += settled_amount
partner_stat["outstanding_total"] += outstanding
transaction_rows = await db_session.execute(
select(FinanceTransaction)
.where(FinanceTransaction.partner_type == "supplier")
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
)
for txn in transaction_rows.scalars().all():
partner_stat = stats_map.setdefault(
txn.partner_id,
{
"partner_id": txn.partner_id,
"partner_name": f"供应商#{txn.partner_id}",
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
partner_stat["transaction_count"] += 1
partner_stat["transaction_total"] += Decimal(str(txn.amount or 0))
missing_partner_ids = [pid for pid, item in stats_map.items() if "#" in item["partner_name"]]
if missing_partner_ids:
if partner_type == "customer":
name_rows = await db_session.execute(
select(Customer.id, Customer.name).where(Customer.id.in_(missing_partner_ids))
)
else:
name_rows = await db_session.execute(
select(Supplier.id, Supplier.name).where(Supplier.id.in_(missing_partner_ids))
)
name_map = {row[0]: row[1] for row in name_rows.all()}
for pid in missing_partner_ids:
if pid in name_map:
stats_map[pid]["partner_name"] = name_map[pid]
items = [
PartnerStatementItemResponse(
partner_id=item["partner_id"],
partner_name=item["partner_name"],
order_count=item["order_count"],
transaction_count=item["transaction_count"],
order_total=Decimal(str(item["order_total"])),
settled_total=Decimal(str(item["settled_total"])),
transaction_total=Decimal(str(item["transaction_total"])),
outstanding_total=Decimal(str(item["outstanding_total"])),
period_year=selected_year,
period_quarter=selected_quarter,
)
for item in sorted(stats_map.values(), key=lambda x: (x["outstanding_total"], x["order_total"]), reverse=True)
]
return FinancePartnerStatementResponse(
partner_type=partner_type,
year=selected_year,
quarter=selected_quarter,
period_label=period_label,
order_total=Decimal(str(sum(item.order_total for item in items))),
settled_total=Decimal(str(sum(item.settled_total for item in items))),
transaction_total=Decimal(str(sum(item.transaction_total for item in items))),
outstanding_total=Decimal(str(sum(item.outstanding_total for item in items))),
items=items,
)
return await finance_service.get_partner_statement(db_session, partner_type, year, quarter)
@router.get("/partner-product-statement/{partner_type}", response_model=FinancePartnerProductStatementResponse)
@@ -620,132 +120,4 @@ async def get_partner_product_statement(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
if partner_type not in ["customer", "supplier"]:
raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier")
selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter)
stats_map: Dict[Tuple[int, int], Dict] = {}
if partner_type == "customer":
query = (
select(SalesOrderItem, SalesOrder, Product, Customer)
.join(SalesOrder, SalesOrderItem.order_id == SalesOrder.id)
.join(Product, SalesOrderItem.product_id == Product.id)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.order_date >= period_start)
.where(SalesOrder.order_date < period_end)
.where(Customer.is_active == True)
)
if partner_id:
query = query.where(Customer.id == partner_id)
result = await db_session.execute(query)
for item, order, product, customer in result.all():
map_key = (customer.id, product.id)
stat = stats_map.setdefault(
map_key,
{
"partner_id": customer.id,
"partner_name": customer.name,
"product_id": product.id,
"product_sku": product.sku,
"product_name": product.name,
"order_ids": set(),
"order_quantity": 0.0,
"order_amount": 0.0,
"settled_amount": 0.0,
"outstanding_amount": 0.0,
},
)
item_amount = Decimal(str(item.amount or 0))
order_total = Decimal(str(order.total_amount or 0))
order_settled = max(Decimal(str(order.received_amount or 0)), Decimal("0"))
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, Decimal("0"))
stat["order_ids"].add(order.id)
stat["order_quantity"] += Decimal(str(item.quantity or 0))
stat["order_amount"] += item_amount
stat["settled_amount"] += item_settled
stat["outstanding_amount"] += item_outstanding
else:
query = (
select(PurchaseOrderItem, PurchaseOrder, Product, Supplier)
.join(PurchaseOrder, PurchaseOrderItem.order_id == PurchaseOrder.id)
.join(Product, PurchaseOrderItem.product_id == Product.id)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.order_date >= period_start)
.where(PurchaseOrder.order_date < period_end)
.where(Supplier.is_active == True)
)
if partner_id:
query = query.where(Supplier.id == partner_id)
result = await db_session.execute(query)
for item, order, product, supplier in result.all():
map_key = (supplier.id, product.id)
stat = stats_map.setdefault(
map_key,
{
"partner_id": supplier.id,
"partner_name": supplier.name,
"product_id": product.id,
"product_sku": product.sku,
"product_name": product.name,
"order_ids": set(),
"order_quantity": 0.0,
"order_amount": 0.0,
"settled_amount": 0.0,
"outstanding_amount": 0.0,
},
)
item_amount = Decimal(str(item.amount or 0))
order_total = Decimal(str(order.total_amount or 0))
order_settled = max(Decimal(str(order.paid_amount or 0)), Decimal("0"))
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, Decimal("0"))
stat["order_ids"].add(order.id)
stat["order_quantity"] += Decimal(str(item.quantity or 0))
stat["order_amount"] += item_amount
stat["settled_amount"] += item_settled
stat["outstanding_amount"] += item_outstanding
items = [
PartnerProductStatementItemResponse(
partner_id=item["partner_id"],
partner_name=item["partner_name"],
product_id=item["product_id"],
product_sku=item["product_sku"],
product_name=item["product_name"],
order_count=len(item["order_ids"]),
order_quantity=Decimal(str(item["order_quantity"])),
order_amount=Decimal(str(item["order_amount"])),
settled_amount=Decimal(str(item["settled_amount"])),
outstanding_amount=Decimal(str(item["outstanding_amount"])),
period_year=selected_year,
period_quarter=selected_quarter,
)
for item in sorted(
stats_map.values(),
key=lambda x: (x["outstanding_amount"], x["order_amount"]),
reverse=True
)
]
return FinancePartnerProductStatementResponse(
partner_type=partner_type,
year=selected_year,
quarter=selected_quarter,
period_label=period_label,
partner_id=partner_id,
order_amount_total=Decimal(str(sum(item.order_amount for item in items))),
settled_amount_total=Decimal(str(sum(item.settled_amount for item in items))),
outstanding_amount_total=Decimal(str(sum(item.outstanding_amount for item in items))),
items=items,
)
return await finance_service.get_partner_product_statement(db_session, partner_type, partner_id, year, quarter)
+1 -1
View File
@@ -16,7 +16,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, Product, Warehouse, Inventory
from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
router = APIRouter(prefix="/inventory", tags=["库存管理"])
+1 -1
View File
@@ -16,7 +16,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, Product, MaterialPriceHistory, MaterialSupplier, Supplier
from .schemas import (
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
+1 -1
View File
@@ -18,7 +18,7 @@ from decimal import Decimal
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
from .schemas import (
from ..schemas import (
ProductCreate,
ProductResponse,
ProductBOMUpdate,
+2 -2
View File
@@ -26,7 +26,7 @@ from shared.models.database import (
PurchaseOrder,
PurchaseOrderItem
)
from .schemas import (
from ..schemas import (
PurchaseOrderCreate,
PurchaseOrderResponse,
PurchaseOrderDetailResponse,
@@ -34,7 +34,7 @@ from .schemas import (
PurchaseOrderReceiveRequest,
PaginatedResponse,
)
from .utils import generate_order_no
from ..utils import generate_order_no
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
+2 -2
View File
@@ -29,7 +29,7 @@ from shared.models.database import (
SalesOrder,
SalesOrderItem
)
from .schemas import (
from ..schemas import (
SalesOrderCreate,
SalesOrderResponse,
SalesOrderDetailResponse,
@@ -41,7 +41,7 @@ from .schemas import (
SalesOrderStatusUpdate,
PaginatedResponse,
)
from .utils import generate_order_no
from ..utils import generate_order_no
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
+2 -2
View File
@@ -17,8 +17,8 @@ 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, Product, Warehouse, Inventory, StockMovement
from .schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from .utils import generate_order_no
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from ..utils import generate_order_no
router = APIRouter(prefix="/stock-movements", tags=["库存变动"])
+1 -1
View File
@@ -18,7 +18,7 @@ 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 .schemas import SupplierCreate, SupplierResponse
from ..schemas import SupplierCreate, SupplierResponse
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
+1 -1
View File
@@ -16,7 +16,7 @@ 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 .schemas import WarehouseCreate, WarehouseResponse
from ..schemas import WarehouseCreate, WarehouseResponse
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
@@ -49,6 +49,7 @@ class FinanceTransactionResponse(BaseModel):
txn_type: TxnType
partner_type: PartnerType
partner_id: int
partner_name: Optional[str] = None
amount: Decimal
txn_date: datetime
method: str
+5
View File
@@ -0,0 +1,5 @@
"""进销存业务服务层
将原 api/*_routes.py 中的业务编排(事务、库存增减、流水写入、对账)下沉到此层,
路由层只做参数校验与响应组装。每个业务域一个 service。
"""
+742
View File
@@ -0,0 +1,742 @@
"""财务业务服务层
将原 finance_routes 中的业务编排(收款/付款/核销/作废/对账)下沉到此,
路由层只做参数校验与响应组装。
"""
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
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 ..schemas import (
ReceiptCreate,
PaymentCreate,
FinanceTransactionResponse,
FinanceSummaryResponse,
ReceivableItemResponse,
PayableItemResponse,
FinancePartnerStatementResponse,
PartnerStatementItemResponse,
FinancePartnerProductStatementResponse,
PartnerProductStatementItemResponse,
PaginatedResponse,
)
from ..utils import generate_order_no
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class FinanceService:
"""财务业务服务:收款/付款/核销/作废/对账"""
@staticmethod
def _build_transaction_response(txn: FinanceTransaction, partner_name: Optional[str] = None) -> FinanceTransactionResponse:
allocations = [
{
"id": item.id,
"order_type": item.order_type,
"order_id": item.order_id,
"allocated_amount": item.allocated_amount,
}
for item in txn.allocations
]
return FinanceTransactionResponse(
id=txn.id,
txn_no=txn.txn_no,
txn_type=txn.txn_type,
partner_type=txn.partner_type,
partner_id=txn.partner_id,
partner_name=partner_name,
amount=txn.amount,
txn_date=txn.txn_date,
method=txn.method,
account_name=txn.account_name,
status=txn.status,
remark=txn.remark,
created_at=txn.created_at,
allocations=allocations,
)
@staticmethod
def _validate_allocation_total(transaction_amount: float, allocation_amounts: List[float]):
allocated_total = sum(allocation_amounts)
if allocated_total - transaction_amount > 1e-6:
raise HTTPException(status_code=400, detail="核销总额不能大于单据金额")
@staticmethod
def _resolve_period_scope(year: Optional[int], quarter: Optional[int]) -> Tuple[int, Optional[int], str, datetime, datetime]:
now = datetime.now()
selected_year = year or now.year
if selected_year < 2000 or selected_year > 2100:
raise HTTPException(status_code=400, detail="年份超出支持范围")
if quarter is not None and quarter not in [1, 2, 3, 4]:
raise HTTPException(status_code=400, detail="季度必须是1-4")
if quarter is None:
period_start = datetime(selected_year, 1, 1)
period_end = datetime(selected_year + 1, 1, 1)
period_label = f"{selected_year}年"
else:
start_month = (quarter - 1) * 3 + 1
period_start = datetime(selected_year, start_month, 1)
if quarter == 4:
period_end = datetime(selected_year + 1, 1, 1)
else:
period_end = datetime(selected_year, start_month + 3, 1)
period_label = f"{selected_year}年Q{quarter}"
return selected_year, quarter, period_label, period_start, period_end
@staticmethod
async def create_receipt(db_session: AsyncSession, payload: ReceiptCreate, current_user: User) -> FinanceTransactionResponse:
customer_result = await db_session.execute(
select(Customer).where(Customer.id == payload.customer_id, Customer.is_active == True)
)
customer = customer_result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
partner_name = customer.name
FinanceService._validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
txn = FinanceTransaction(
txn_no=generate_order_no("RC"),
txn_type="receipt",
partner_type="customer",
partner_id=payload.customer_id,
amount=payload.amount,
txn_date=payload.txn_date or datetime.now(),
method=payload.method,
account_name=payload.account_name,
status="confirmed",
remark=payload.remark,
operator_id=current_user.id,
)
db_session.add(txn)
await db_session.flush()
for allocation in payload.allocations:
if allocation.order_type != "sales":
raise HTTPException(status_code=400, detail="收款单只允许核销销售订单")
order_result = await db_session.execute(
select(SalesOrder).where(SalesOrder.id == allocation.order_id, SalesOrder.customer_id == payload.customer_id)
)
sales_order = order_result.scalar_one_or_none()
if not sales_order:
raise HTTPException(status_code=404, detail=f"销售订单不存在: {allocation.order_id}")
remaining = (sales_order.total_amount or 0) - (sales_order.received_amount or 0)
if allocation.allocated_amount - remaining > 1e-6:
raise HTTPException(status_code=400, detail=f"销售订单核销超额: {sales_order.order_no}")
db_session.add(
FinanceAllocation(
transaction_id=txn.id,
order_type="sales",
order_id=sales_order.id,
allocated_amount=allocation.allocated_amount,
)
)
sales_order.received_amount = (sales_order.received_amount or 0) + allocation.allocated_amount
await db_session.commit()
result = await db_session.execute(
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.where(FinanceTransaction.id == txn.id)
)
created = result.scalar_one()
return FinanceService._build_transaction_response(created, partner_name=partner_name)
@staticmethod
async def create_payment(db_session: AsyncSession, payload: PaymentCreate, current_user: User) -> FinanceTransactionResponse:
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == payload.supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
partner_name = supplier.name
FinanceService._validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
txn = FinanceTransaction(
txn_no=generate_order_no("PY"),
txn_type="payment",
partner_type="supplier",
partner_id=payload.supplier_id,
amount=payload.amount,
txn_date=payload.txn_date or datetime.now(),
method=payload.method,
account_name=payload.account_name,
status="confirmed",
remark=payload.remark,
operator_id=current_user.id,
)
db_session.add(txn)
await db_session.flush()
for allocation in payload.allocations:
if allocation.order_type != "purchase":
raise HTTPException(status_code=400, detail="付款单只允许核销采购订单")
order_result = await db_session.execute(
select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id, PurchaseOrder.supplier_id == payload.supplier_id)
)
purchase_order = order_result.scalar_one_or_none()
if not purchase_order:
raise HTTPException(status_code=404, detail=f"采购订单不存在: {allocation.order_id}")
remaining = (purchase_order.total_amount or 0) - (purchase_order.paid_amount or 0)
if allocation.allocated_amount - remaining > 1e-6:
raise HTTPException(status_code=400, detail=f"采购订单核销超额: {purchase_order.order_no}")
db_session.add(
FinanceAllocation(
transaction_id=txn.id,
order_type="purchase",
order_id=purchase_order.id,
allocated_amount=allocation.allocated_amount,
)
)
purchase_order.paid_amount = (purchase_order.paid_amount or 0) + allocation.allocated_amount
await db_session.commit()
result = await db_session.execute(
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.where(FinanceTransaction.id == txn.id)
)
created = result.scalar_one()
return FinanceService._build_transaction_response(created, partner_name=partner_name)
@staticmethod
async def list_transactions(
db_session: AsyncSession,
txn_type: Optional[str],
status: Optional[str],
year: Optional[int],
quarter: Optional[int],
skip: int,
limit: int,
) -> PaginatedResponse:
base_query = (
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.order_by(FinanceTransaction.created_at.desc())
)
if txn_type:
base_query = base_query.where(FinanceTransaction.txn_type == txn_type)
if status:
base_query = base_query.where(FinanceTransaction.status == status)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
base_query = base_query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
rows = result.scalars().all()
# 批量查询往来方名称(交易记录只存 partner_id,需关联客户/供应商取名称)
partner_names: Dict[int, str] = {}
customer_ids = {t.partner_id for t in rows if t.partner_type == "customer"}
supplier_ids = {t.partner_id for t in rows if t.partner_type == "supplier"}
if customer_ids:
cr = await db_session.execute(select(Customer.id, Customer.name).where(Customer.id.in_(customer_ids)))
partner_names.update({r[0]: r[1] for r in cr.all()})
if supplier_ids:
sr = await db_session.execute(select(Supplier.id, Supplier.name).where(Supplier.id.in_(supplier_ids)))
partner_names.update({r[0]: r[1] for r in sr.all()})
return PaginatedResponse(
items=[FinanceService._build_transaction_response(item, partner_name=partner_names.get(item.partner_id)) for item in rows],
total=total, skip=skip, limit=limit
)
@staticmethod
async def void_transaction(db_session: AsyncSession, transaction_id: int, current_user: User) -> dict:
result = await db_session.execute(
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.where(FinanceTransaction.id == transaction_id)
)
txn = result.scalar_one_or_none()
if not txn:
raise HTTPException(status_code=404, detail="财务单据不存在")
if txn.status == "voided":
return {"message": "单据已作废"}
for allocation in txn.allocations:
if allocation.order_type == "sales":
sales_result = await db_session.execute(select(SalesOrder).where(SalesOrder.id == allocation.order_id))
sales_order = sales_result.scalar_one_or_none()
if sales_order:
sales_order.received_amount = max((sales_order.received_amount or 0) - allocation.allocated_amount, 0)
elif allocation.order_type == "purchase":
purchase_result = await db_session.execute(select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id))
purchase_order = purchase_result.scalar_one_or_none()
if purchase_order:
purchase_order.paid_amount = max((purchase_order.paid_amount or 0) - allocation.allocated_amount, 0)
txn.status = "voided"
await db_session.commit()
logger.warning(
"财务单据已作废: txn_no=%s txn_type=%s amount=%s operator_id=%s",
txn.txn_no, txn.txn_type, txn.amount, current_user.id
)
return {"message": "单据已作废"}
@staticmethod
async def list_receivables(
db_session: AsyncSession,
year: Optional[int],
quarter: Optional[int],
skip: int,
limit: int,
) -> List[ReceivableItemResponse]:
query = (
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
.order_by(SalesOrder.created_at.desc())
)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
query = query.where(SalesOrder.order_date >= period_start).where(SalesOrder.order_date < period_end)
result = await db_session.execute(query.offset(skip).limit(limit))
rows = []
for order, customer in result.all():
receivable_amount = (order.total_amount or 0) - (order.received_amount or 0)
rows.append(
ReceivableItemResponse(
order_id=order.id,
order_no=order.order_no,
customer_id=customer.id,
customer_name=customer.name,
order_date=order.order_date,
total_amount=order.total_amount or 0,
received_amount=order.received_amount or 0,
receivable_amount=receivable_amount,
status=order.status,
)
)
return rows
@staticmethod
async def list_payables(
db_session: AsyncSession,
year: Optional[int],
quarter: Optional[int],
skip: int,
limit: int,
) -> List[PayableItemResponse]:
query = (
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
.order_by(PurchaseOrder.created_at.desc())
)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
query = query.where(PurchaseOrder.order_date >= period_start).where(PurchaseOrder.order_date < period_end)
result = await db_session.execute(query.offset(skip).limit(limit))
rows = []
for order, supplier in result.all():
payable_amount = (order.total_amount or 0) - (order.paid_amount or 0)
rows.append(
PayableItemResponse(
order_id=order.id,
order_no=order.order_no,
supplier_id=supplier.id,
supplier_name=supplier.name,
order_date=order.order_date,
total_amount=order.total_amount or 0,
paid_amount=order.paid_amount or 0,
payable_amount=payable_amount,
status=order.status,
)
)
return rows
@staticmethod
async def get_summary(db_session: AsyncSession, year: Optional[int], quarter: Optional[int]) -> FinanceSummaryResponse:
receivable_total = await db_session.scalar(
select(func.coalesce(func.sum(SalesOrder.total_amount - SalesOrder.received_amount), 0))
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
) or 0
payable_total = await db_session.scalar(
select(func.coalesce(func.sum(PurchaseOrder.total_amount - PurchaseOrder.paid_amount), 0))
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
) or 0
now = datetime.now()
month_start = datetime(now.year, now.month, 1)
monthly_receipt_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= month_start)
) or 0
monthly_payment_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= month_start)
) or 0
selected_year, selected_quarter, period_label, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
period_receipt_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
) or 0
period_payment_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
) or 0
return FinanceSummaryResponse(
receivable_total=Decimal(str(receivable_total)),
payable_total=Decimal(str(payable_total)),
monthly_receipt_total=Decimal(str(monthly_receipt_total)),
monthly_payment_total=Decimal(str(monthly_payment_total)),
selected_year=selected_year,
selected_quarter=selected_quarter,
period_label=period_label,
period_receipt_total=Decimal(str(period_receipt_total)),
period_payment_total=Decimal(str(period_payment_total)),
overdue_receivable_count=0,
overdue_payable_count=0,
)
@staticmethod
async def get_partner_statement(
db_session: AsyncSession,
partner_type: str,
year: Optional[int],
quarter: Optional[int],
) -> FinancePartnerStatementResponse:
if partner_type not in ["customer", "supplier"]:
raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier")
selected_year, selected_quarter, period_label, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
stats_map: Dict[int, Dict] = {}
if partner_type == "customer":
order_rows = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.order_date >= period_start)
.where(SalesOrder.order_date < period_end)
.where(Customer.is_active == True)
)
for order, customer in order_rows.all():
partner_stat = stats_map.setdefault(
customer.id,
{
"partner_id": customer.id,
"partner_name": customer.name,
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
total_amount = Decimal(str(order.total_amount or 0))
settled_amount = Decimal(str(order.received_amount or 0))
outstanding = max(total_amount - settled_amount, Decimal("0"))
partner_stat["order_count"] += 1
partner_stat["order_total"] += total_amount
partner_stat["settled_total"] += settled_amount
partner_stat["outstanding_total"] += outstanding
transaction_rows = await db_session.execute(
select(FinanceTransaction)
.where(FinanceTransaction.partner_type == "customer")
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
)
for txn in transaction_rows.scalars().all():
partner_stat = stats_map.setdefault(
txn.partner_id,
{
"partner_id": txn.partner_id,
"partner_name": f"客户#{txn.partner_id}",
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
partner_stat["transaction_count"] += 1
partner_stat["transaction_total"] += Decimal(str(txn.amount or 0))
else:
order_rows = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.order_date >= period_start)
.where(PurchaseOrder.order_date < period_end)
.where(Supplier.is_active == True)
)
for order, supplier in order_rows.all():
partner_stat = stats_map.setdefault(
supplier.id,
{
"partner_id": supplier.id,
"partner_name": supplier.name,
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
total_amount = Decimal(str(order.total_amount or 0))
settled_amount = Decimal(str(order.paid_amount or 0))
outstanding = max(total_amount - settled_amount, Decimal("0"))
partner_stat["order_count"] += 1
partner_stat["order_total"] += total_amount
partner_stat["settled_total"] += settled_amount
partner_stat["outstanding_total"] += outstanding
transaction_rows = await db_session.execute(
select(FinanceTransaction)
.where(FinanceTransaction.partner_type == "supplier")
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
)
for txn in transaction_rows.scalars().all():
partner_stat = stats_map.setdefault(
txn.partner_id,
{
"partner_id": txn.partner_id,
"partner_name": f"供应商#{txn.partner_id}",
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
partner_stat["transaction_count"] += 1
partner_stat["transaction_total"] += Decimal(str(txn.amount or 0))
missing_partner_ids = [pid for pid, item in stats_map.items() if "#" in item["partner_name"]]
if missing_partner_ids:
if partner_type == "customer":
name_rows = await db_session.execute(
select(Customer.id, Customer.name).where(Customer.id.in_(missing_partner_ids))
)
else:
name_rows = await db_session.execute(
select(Supplier.id, Supplier.name).where(Supplier.id.in_(missing_partner_ids))
)
name_map = {row[0]: row[1] for row in name_rows.all()}
for pid in missing_partner_ids:
if pid in name_map:
stats_map[pid]["partner_name"] = name_map[pid]
items = [
PartnerStatementItemResponse(
partner_id=item["partner_id"],
partner_name=item["partner_name"],
order_count=item["order_count"],
transaction_count=item["transaction_count"],
order_total=Decimal(str(item["order_total"])),
settled_total=Decimal(str(item["settled_total"])),
transaction_total=Decimal(str(item["transaction_total"])),
outstanding_total=Decimal(str(item["outstanding_total"])),
period_year=selected_year,
period_quarter=selected_quarter,
)
for item in sorted(stats_map.values(), key=lambda x: (x["outstanding_total"], x["order_total"]), reverse=True)
]
return FinancePartnerStatementResponse(
partner_type=partner_type,
year=selected_year,
quarter=selected_quarter,
period_label=period_label,
order_total=Decimal(str(sum(item.order_total for item in items))),
settled_total=Decimal(str(sum(item.settled_total for item in items))),
transaction_total=Decimal(str(sum(item.transaction_total for item in items))),
outstanding_total=Decimal(str(sum(item.outstanding_total for item in items))),
items=items,
)
@staticmethod
async def get_partner_product_statement(
db_session: AsyncSession,
partner_type: str,
partner_id: Optional[int],
year: Optional[int],
quarter: Optional[int],
) -> FinancePartnerProductStatementResponse:
if partner_type not in ["customer", "supplier"]:
raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier")
selected_year, selected_quarter, period_label, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
stats_map: Dict[Tuple[int, int], Dict] = {}
if partner_type == "customer":
query = (
select(SalesOrderItem, SalesOrder, Product, Customer)
.join(SalesOrder, SalesOrderItem.order_id == SalesOrder.id)
.join(Product, SalesOrderItem.product_id == Product.id)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.order_date >= period_start)
.where(SalesOrder.order_date < period_end)
.where(Customer.is_active == True)
)
if partner_id:
query = query.where(Customer.id == partner_id)
result = await db_session.execute(query)
for item, order, product, customer in result.all():
map_key = (customer.id, product.id)
stat = stats_map.setdefault(
map_key,
{
"partner_id": customer.id,
"partner_name": customer.name,
"product_id": product.id,
"product_sku": product.sku,
"product_name": product.name,
"order_ids": set(),
"order_quantity": 0.0,
"order_amount": 0.0,
"settled_amount": 0.0,
"outstanding_amount": 0.0,
},
)
item_amount = Decimal(str(item.amount or 0))
order_total = Decimal(str(order.total_amount or 0))
order_settled = max(Decimal(str(order.received_amount or 0)), Decimal("0"))
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, Decimal("0"))
stat["order_ids"].add(order.id)
stat["order_quantity"] += Decimal(str(item.quantity or 0))
stat["order_amount"] += item_amount
stat["settled_amount"] += item_settled
stat["outstanding_amount"] += item_outstanding
else:
query = (
select(PurchaseOrderItem, PurchaseOrder, Product, Supplier)
.join(PurchaseOrder, PurchaseOrderItem.order_id == PurchaseOrder.id)
.join(Product, PurchaseOrderItem.product_id == Product.id)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.order_date >= period_start)
.where(PurchaseOrder.order_date < period_end)
.where(Supplier.is_active == True)
)
if partner_id:
query = query.where(Supplier.id == partner_id)
result = await db_session.execute(query)
for item, order, product, supplier in result.all():
map_key = (supplier.id, product.id)
stat = stats_map.setdefault(
map_key,
{
"partner_id": supplier.id,
"partner_name": supplier.name,
"product_id": product.id,
"product_sku": product.sku,
"product_name": product.name,
"order_ids": set(),
"order_quantity": 0.0,
"order_amount": 0.0,
"settled_amount": 0.0,
"outstanding_amount": 0.0,
},
)
item_amount = Decimal(str(item.amount or 0))
order_total = Decimal(str(order.total_amount or 0))
order_settled = max(Decimal(str(order.paid_amount or 0)), Decimal("0"))
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, Decimal("0"))
stat["order_ids"].add(order.id)
stat["order_quantity"] += Decimal(str(item.quantity or 0))
stat["order_amount"] += item_amount
stat["settled_amount"] += item_settled
stat["outstanding_amount"] += item_outstanding
items = [
PartnerProductStatementItemResponse(
partner_id=item["partner_id"],
partner_name=item["partner_name"],
product_id=item["product_id"],
product_sku=item["product_sku"],
product_name=item["product_name"],
order_count=len(item["order_ids"]),
order_quantity=Decimal(str(item["order_quantity"])),
order_amount=Decimal(str(item["order_amount"])),
settled_amount=Decimal(str(item["settled_amount"])),
outstanding_amount=Decimal(str(item["outstanding_amount"])),
period_year=selected_year,
period_quarter=selected_quarter,
)
for item in sorted(
stats_map.values(),
key=lambda x: (x["outstanding_amount"], x["order_amount"]),
reverse=True
)
]
return FinancePartnerProductStatementResponse(
partner_type=partner_type,
year=selected_year,
quarter=selected_quarter,
period_label=period_label,
partner_id=partner_id,
order_amount_total=Decimal(str(sum(item.order_amount for item in items))),
settled_amount_total=Decimal(str(sum(item.settled_amount for item in items))),
outstanding_amount_total=Decimal(str(sum(item.outstanding_amount for item in items))),
items=items,
)
finance_service = FinanceService()
+21
View File
@@ -0,0 +1,21 @@
"""进销存工具函数模块
提供进销存系统通用的工具函数。置于 inventory 顶层(非 api 下),
以便 services 层与 api 层共用,避免 services -> api 的循环导入。
"""
from datetime import datetime, timezone
import secrets
def generate_order_no(prefix: str) -> str:
"""生成订单编号
Args:
prefix: 订单类型前缀,如 PO(采购订单)、SO(销售订单)、SM(库存变动)
Returns:
格式为 {prefix}{YYYYMMDDHHMMSS}{8位随机字符} 的订单编号
"""
date_str = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
random_str = secrets.token_hex(4).upper()
return f"{prefix}{date_str}{random_str}"
+1
View File
@@ -26,3 +26,4 @@ _safe_include("moldinsight.api.history_router", "历史")
_safe_include("moldinsight.api.debug_router", "调试")
_safe_include("moldinsight.api.cam_router", "CAM")
_safe_include("moldinsight.api.advanced_router", "高级")
_safe_include("moldinsight.api.aluminum_price_routes", "铝价")
+1 -1
View File
@@ -78,7 +78,7 @@ class GeometryAnalyzer:
"""检测模具特征 — 独立检测并行执行"""
features: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=4, thread_name_prefix="feat") as pool:
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="feat") as pool:
futures = {
pool.submit(self._detect_wall_features, geometry_data, shape): "wall",
pool.submit(self._detect_rib_features, geometry_data, shape): "rib",
+1 -1
View File
@@ -336,7 +336,7 @@ class LLMService:
volume=f"{analysis_result.get('geometry_data', {}).get('volume', 0):.1f} mm³",
surface_area=f"{analysis_result.get('geometry_data', {}).get('surface_area', 0):.1f} mm²",
bbox=json.dumps(analysis_result.get("geometry_data", {}).get("bounding_box", {}), ensure_ascii=False),
features=trimmed or "无特征检测数据",
features=features or "无特征检测数据",
quality_metrics=json.dumps(analysis_result.get("quality_metrics", {}), ensure_ascii=False, indent=2),
schemes=schemes_text or "无分模方案数据",
mold_material=mfg.get("mold_material", "自动选择"),
+20 -48
View File
@@ -28,6 +28,7 @@ class UserResponse(BaseModel):
email: str
full_name: Optional[str]
is_active: bool
is_superuser: bool = False
roles: List[str]
class Config:
@@ -102,6 +103,19 @@ def check_admin(user: User) -> bool:
return True
def _build_user_response(user: User) -> UserResponse:
"""统一构造用户响应,确保 is_superuser 等字段一致"""
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_superuser=user.is_superuser,
roles=[r.code for r in user.roles],
)
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
@@ -123,14 +137,7 @@ async def login(
return Token(
access_token=access_token,
token_type="bearer",
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
user=_build_user_response(user)
)
@@ -154,14 +161,7 @@ async def login_json(
return Token(
access_token=access_token,
token_type="bearer",
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
user=_build_user_response(user)
)
@@ -169,14 +169,7 @@ async def login_json(
async def get_current_user_info(
current_user: User = Depends(get_current_active_user)
):
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
roles=[r.code for r in current_user.roles]
)
return _build_user_response(current_user)
@router.post("/logout")
@@ -195,14 +188,7 @@ async def list_users(
)
users = result.scalars().all()
return [
UserResponse(
id=u.id,
username=u.username,
email=u.email,
full_name=u.full_name,
is_active=u.is_active,
roles=[r.code for r in u.roles]
) for u in users
_build_user_response(u) for u in users
]
@@ -245,14 +231,7 @@ async def create_user(
logger.info(f"管理员 {current_user.username} 创建了用户 {user.username}")
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
return _build_user_response(user)
@router.put("/users/{user_id}", response_model=UserResponse)
@@ -292,14 +271,7 @@ async def update_user(
logger.info(f"管理员 {current_user.username} 更新了用户 {user.username}")
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
return _build_user_response(user)
@router.delete("/users/{user_id}")
+12
View File
@@ -60,6 +60,18 @@ class RedisTaskManager:
self._redis = None
self._connected = False
async def reconnect(self):
"""强制重新连接
用于事件循环会变更的场景(如 Celery 每个任务经 asyncio.run 创建新循环):
redis.asyncio 客户端绑定到创建它的循环,旧循环关闭后客户端失效,
必须在新循环中重建客户端才能继续使用。
"""
# 丢弃绑定在旧(已关闭)循环上的客户端,connect() 会重建
self._redis = None
self._connected = False
await self.connect()
async def disconnect(self):
"""断开 Redis 连接"""
if self._redis: