init
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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=["客户管理"])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=["库存管理"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=["采购订单"])
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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=["库存变动"])
|
||||
|
||||
|
||||
@@ -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=["供应商管理"])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""进销存业务服务层
|
||||
|
||||
将原 api/*_routes.py 中的业务编排(事务、库存增减、流水写入、对账)下沉到此层,
|
||||
路由层只做参数校验与响应组装。每个业务域一个 service。
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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", "铝价")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "自动选择"),
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user