v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Variables:
{{‘ATTACHED_PROJECT_CODE’, ‘APP_USE_CASE’, ‘STEP_BY_STEP_REASONING', 'USER_TASK', 'ERROR’, 'DEBUG_INSTRUCTIONS', 'PROBLEMATIC_CODE', 'PREDICTIONS', 'EXPLANATION’, ‘SCRATCHPAD’}}
|
||||
|
||||
************************
|
||||
|
||||
Role:
You are an intelligent, thorough, and helpful software developer debugging AI. Your task is to
Conduct a thorough analysis of a specific ‘ERROR’ encountered when performing the ‘USER_TASK’ when running the program/script displayed in the ‘ATTACHED_PROJECT_CODE’.
|
||||
|
||||
Generate 'PREDICTIONS' for possible causes for the error.
|
||||
|
||||
Perform a meticulous investigation of the ‘ATTACHED_PROJECT_CODE’ to find the ‘PROBLEMATIC_CODE’, with the goal of narrowing the predictions to the most likely one.
|
||||
|
||||
You will show your work in the ‘SCRATCHPAD’.
Brainstorm and develop the plan to identify and correct the error through ‘STEP_BY_STEP_REASONING’. Then provide a detailed ‘EXPLANATION’ with comprehensive step-by-step ‘DEBUG_INSTRUCTIONS’.
|
||||
|
||||
Remember to focus on providing a clear, well-explained solution and debugging guide, ensuring the error is not only resolved but also understood in the context of the app's operation and development. Then write corrected code snippets and then the code you are replacing with that. You must ensure there are paragraph breaks after each XML tags and numbered lists have proper formatting in your response. Ensure response is around 8000 tokens and are comprehensive and detailed.
|
||||
|
||||
<attached_project_code>
|
||||
|
||||
<YOUR_FILE_1.py>
|
||||
{{Attached to convo}}
|
||||
</YOUR_FILE_1.py>
|
||||
|
||||
<YOUR_FILE_2.py>
|
||||
{{Attached to convo}}
|
||||
</YOUR_FILE_2.py>
|
||||
|
||||
<YOUR_FILE_3.log>
|
||||
{{Attached to convo}}
|
||||
</YOUR_FILE_3.log>
|
||||
|
||||
</attached_project_code>
|
||||
|
||||
<app_use_case>
|
||||
{{WRITE YOUR APP/PROGRAM USE CASE}}
|
||||
</app_use_case>
|
||||
|
||||
<script_explanations>
|
||||
{{EXPLAIN ALL OF YOUR ATTACHED SCRIPTS/FILES}}
|
||||
</script_explanations>
|
||||
|
||||
Here is the error:
|
||||
|
||||
<error>
|
||||
{{INPUT TERMINAL ERROR OR ERROR MESSAGE}}
|
||||
</error>
|
||||
|
||||
The error occurred while the user was performing the following task:
|
||||
|
||||
<user_task>
|
||||
{{EXPLAIN WHAT YOU OR THE PROGRAM WAS DOING WHEN ERROR/CRASH HAPPENED}}
|
||||
</user_task>
|
||||
|
||||
Begin by examining the error code and user task. Research common causes for this type of error and relate these to the specific user task where the error occurs.
Based on your initial assessment, generate five educated predictions regarding potential causes of the error. These predictions should consider various aspects, such as coding mistakes, dependency issues, or resource constraints.
|
||||
|
||||
<predictions>
|
||||
{{PREDICTIONS}}
|
||||
</predictions>
|
||||
|
||||
Dive into the ‘ATTACHED_PROJECT_CODE’ with your predictions in mind. Methodically review the code segments related to the user task where the ‘ERROR’ was reported. Pay special attention to recent changes or updates that might have introduced the error.
Analyze the code in the context of each prediction. Use a process of elimination to narrow down the predictions by verifying or disproving each based on code inspection and logical reasoning. Document your rationale behind retaining or discarding each prediction in the scratchpad.
|
||||
|
||||
<scratchpad>
|
||||
{{SCRATCHPAD}}
|
||||
</scratchpad>
|
||||
|
||||
Here is the problematic code segment:
|
||||
|
||||
<problematic_code>
|
||||
{{PROBLEMATIC_CODE}}
|
||||
</problematic_code>
|
||||
|
||||
Document your entire thought process from initial error assessment through prediction formulation, code analysis, and debugging strategy. This narrative should highlight logical deductions, investigative methods, and the rationale for key decisions.
|
||||
|
||||
<step_by_step_reasoning>
|
||||
{{STEP_BY_STEP_REASONING}}
|
||||
</step_by_step_reasoning>
|
||||
|
||||
Select the most likely cause of the error from your remaining predictions. Provide a detailed explanation of why this particular issue is the root cause, referencing specific aspects of the problematic code and how they relate to the error manifestation.
|
||||
|
||||
<explanation>
|
||||
{{EXPLANATION}}
|
||||
</explanation>
|
||||
|
||||
Develop comprehensive, step-by-step instructions for resolving the identified issue. These instructions should be clear and actionable, suitable for a developer with knowledge of software development but possibly unfamiliar with the specific project.
|
||||
|
||||
<debug_instructions>
|
||||
{{DEBUG_INSTRUCTIONS}}
|
||||
</debug_instructions>
|
||||
|
||||
Remember to focus on providing a clear, well-explained solution and debugging guide, ensuring the error is not only resolved but also understood in the context of the app's operation and development. Then write corrected code snippets and then the code you are replacing with that.
|
||||
+71
-6
@@ -1,29 +1,94 @@
|
||||
# ========================
|
||||
# 环境变量(含密码和密钥)
|
||||
# ========================
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
server/.env
|
||||
backend/.env
|
||||
|
||||
# ========================
|
||||
# Python
|
||||
# ========================
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
|
||||
# Node
|
||||
# ========================
|
||||
# Node.js
|
||||
# ========================
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 上传文件
|
||||
# ========================
|
||||
# 上传文件 & 数据
|
||||
# ========================
|
||||
server/uploads/
|
||||
uploads/
|
||||
data/
|
||||
|
||||
# Docker
|
||||
# ========================
|
||||
# 数据库文件
|
||||
# ========================
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# ========================
|
||||
# 日志
|
||||
# ========================
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# ========================
|
||||
# 临时文件 & 缓存
|
||||
# ========================
|
||||
tmp/
|
||||
.cache/
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# ========================
|
||||
# IDE
|
||||
# ========================
|
||||
.vscode/
|
||||
.idea/
|
||||
.gemini/
|
||||
.agent/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.swp
|
||||
# ========================
|
||||
# OS
|
||||
# ========================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ========================
|
||||
# 构建产物 & 运行时
|
||||
# ========================
|
||||
node-v*/
|
||||
node.tar.xz
|
||||
|
||||
# ========================
|
||||
# Playwright 测试产物
|
||||
# ========================
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/e2e/.auth/
|
||||
|
||||
# ========================
|
||||
# Alembic bytecode
|
||||
# ========================
|
||||
server/alembic/versions/__pycache__/
|
||||
|
||||
# ========================
|
||||
# 旧版 backend(如不再使用)
|
||||
# ========================
|
||||
backend/.pytest_cache/
|
||||
backend/alembic/versions/__pycache__/
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
# 润滑油 CRM/ERP 系统 — 阶段性全栈复盘与架构白皮书
|
||||
|
||||
**项目代号**:天津硕博霖业财一体化 ERP(SHBL-ERP)
|
||||
**当前阶段**:Phase 2.0(多租户隔离 + AI 交互优化 / 内测上线态)
|
||||
**报告日期**:2026 年 3 月 19 日
|
||||
**系统版本**:v0.2.0
|
||||
**部署地址**:`http://192.168.1.100`(内网单节点)
|
||||
**变更基线**:基于 v0.1.0 (Phase 1.5) 增量更新
|
||||
|
||||
---
|
||||
|
||||
## 一、系统技术基座 (Tech Stack & Dependencies)
|
||||
|
||||
本系统采用现代化前后端分离架构,AI 能力通过 Dify + Ollama 私有化部署实现,全栈可控。
|
||||
|
||||
### 1. 前端架构 (Frontend)
|
||||
|
||||
| 技术 | 说明 |
|
||||
|------|------|
|
||||
| **Vue 3** (Composition API) + TypeScript | 核心框架,14 个业务页面组件 |
|
||||
| **Vite** | 构建工具,极速冷启动与 HMR |
|
||||
| **Element Plus** | 企业级 UI,深度定制了报销单 `@media print` A4 打印样式 |
|
||||
| **Pinia** | 全局状态管理(UserInfo、Token、RBAC 权限、**当前公司 ID**) |
|
||||
| **Axios** | 封装全局 Request/Response 拦截器,自动处理 401 登出、业务异常及 **`X-Company-Id` 头部注入** |
|
||||
| **SSE (EventSource)** | AI 复盘报告实时流式输出 |
|
||||
|
||||
### 2. 后端架构 (Backend)
|
||||
|
||||
| 技术 | 说明 |
|
||||
|------|------|
|
||||
| **Python 3.10+ & FastAPI** | 核心框架,**17 个 API 路由模块**,自带 OpenAPI/Swagger 文档 |
|
||||
| **Uvicorn** | ASGI 异步服务器 |
|
||||
| **SQLAlchemy 2.0 + Asyncpg** | 全异步 ORM,**24 张业务表** |
|
||||
| **Alembic** | **数据库 Schema 版本管理**(4 个迁移脚本),async 模式 |
|
||||
| **JWT + Passlib (Bcrypt)** | 认证体系(Token + 密码哈希) |
|
||||
| **httpx** | 异步 HTTP 客户端,用于调用 Dify API 和 Ollama |
|
||||
|
||||
### 3. AI 能力层
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **Dify** (192.168.1.88) | AI 编排平台,承载 Workflow(复盘报告生成)和 Chat(AI 悬浮球对话) |
|
||||
| **Ollama — RTX 3090** (192.168.1.88:11434) | 运行 `qwen3.5:27b`,用于复盘报告、企业画像等重任务 |
|
||||
| **Ollama — RTX 4060** (192.168.1.88:11435) | 运行 `qwen3.5:4b`,用于发票 OCR 解析等轻任务 |
|
||||
|
||||
### 4. 数据持久层 (Database)
|
||||
|
||||
- **核心数据库**:PostgreSQL(宿主机直连,asyncpg 驱动)
|
||||
- **24 张业务表**,覆盖 CRM、ERP、财务、AI、系统五大域 + **多租户域**
|
||||
- **Alembic 迁移管理**:4 个版本化迁移脚本(baseline → 多租户 → 新宇公司 → sales_logs 业务标签)
|
||||
- **亮点特性**:
|
||||
- `JSONB` 承载 AI OCR 提取数据 (`ai_extracted_data`)
|
||||
- `ARRAY(UUID)` 承载 `sales_logs.involved_company_ids`(多公司业务标签)
|
||||
- 强事务并发控制(库存/发货防重)
|
||||
- **行级多租户隔离**(`company_id` 外键 + GIN 索引)
|
||||
|
||||
### 5. 部署架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ 服务器 192.168.1.100 (Ubuntu, 61GB 磁盘) │
|
||||
│ │
|
||||
│ Docker Compose │
|
||||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ frontend │ │ backend │ │
|
||||
│ │ Nginx:80 (95MB) │ │ Uvicorn:8000 (889MB) │ │
|
||||
│ │ bridge 网络 │ │ host 网络 │ │
|
||||
│ └──────┬───────────┘ └──────────────────────┘ │
|
||||
│ │ proxy_pass ↓ │
|
||||
│ └── host.docker.internal:8000 │
|
||||
│ │
|
||||
│ PostgreSQL (宿主机 :5432) ← 直连 │
|
||||
│ │
|
||||
│ Alembic (宿主机 venv) ← 迁移管理 │
|
||||
│ ↕ 127.0.0.1:5432 (env.py 自动替换) │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ Dify + Ollama │ ← 192.168.1.88
|
||||
│ (RTX 3090/4060)│
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、多租户架构(v0.2.0 新增)
|
||||
|
||||
### 设计原则
|
||||
|
||||
采用**行级数据隔离**(Row-Level Isolation),同一数据库、同一张表,通过 `company_id` 列区分不同公司数据。
|
||||
|
||||
### 已注册公司
|
||||
|
||||
| 公司 | UUID | 说明 |
|
||||
|------|------|------|
|
||||
| 天津硕博霖 | `aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0001` | 默认公司 |
|
||||
| 新宇润滑油 | `aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0002` | 第二家公司 |
|
||||
|
||||
### 隔离矩阵
|
||||
|
||||
| 表 | 隔离方式 | 说明 |
|
||||
|----|----------|------|
|
||||
| `erp_orders` | `company_id` FK | 订单按公司隔离 |
|
||||
| `erp_shipping_records` | `company_id` FK | 发货单按公司隔离 |
|
||||
| `erp_inventory_flows` | `company_id` FK | 库存流水按公司隔离 |
|
||||
| `erp_sku_inventory` | `company_id` FK | 库存快照按公司隔离 |
|
||||
| `fin_invoice_pool` | `company_id` FK | 报销发票池按公司隔离 |
|
||||
| `fin_expense_records` | `company_id` FK | 报销单按公司隔离 |
|
||||
| `finance_sales_invoices` | `company_id` FK | 销项发票按公司隔离 |
|
||||
| `sales_logs` | `involved_company_ids` ARRAY(UUID) | **业务标签**:一条日志可关联多个公司 |
|
||||
| `crm_customers` | **全局共享** | 客户资源跨公司共享 |
|
||||
| `erp_product_categories` | **全局共享** | 产品分类跨公司共享 |
|
||||
| `erp_product_skus` | **全局共享** | 产品 SKU 跨公司共享 |
|
||||
|
||||
### 技术实现链路
|
||||
|
||||
```
|
||||
前端 Pinia(currentCompanyId)
|
||||
↓ Axios 拦截器自动注入
|
||||
请求 Header: X-Company-Id: <uuid>
|
||||
↓
|
||||
后端 Depends(get_current_company_id)
|
||||
↓ IDOR 校验(sys_user_companies 验证归属)
|
||||
service 层 .where(Model.company_id == company_id)
|
||||
```
|
||||
|
||||
- **IDOR 防护**:`get_current_company_id` 依赖会查 `sys_user_companies` 表验证当前用户确实关联了该公司,防止伪造 Header 越权
|
||||
- **前端公司切换**:登录页公司选择 + 首页顶部 Header 下拉切换
|
||||
- **sales_logs 特殊处理**:使用 `ARRAY.any(company_id)` 包含查询 + GIN 索引加速
|
||||
|
||||
---
|
||||
|
||||
## 三、九大核心业务模块全景剖析
|
||||
|
||||
系统已点亮 **9 大核心模块 + 2 个 AI 模块**,实现从"线索"到"现金"的业财链路闭环,并接入大模型智能辅助。
|
||||
|
||||
### 模块 1:RBAC 权限与系统基座
|
||||
|
||||
- **功能**:用户登录、改密、角色管理、员工管理、**公司管理与切换**
|
||||
- **核心亮点**:
|
||||
1. **`data_scope` 数据横向隔离** — `self` / `dept_and_sub` / `all` 三维度
|
||||
2. **多租户公司隔离** — `sys_companies` + `sys_user_companies` 多对多关联,支持用户归属多个公司
|
||||
3. **`X-Company-Id` 依赖注入** — 所有私有数据查询均受公司视角拦截,双层校验(JWT + 公司归属)
|
||||
- **关键表**:`sys_users`、`sys_roles`、`sys_departments`、`sys_companies`、`sys_user_companies`
|
||||
|
||||
### 模块 2:CRM 客户管理
|
||||
|
||||
- **功能**:客户全生命周期管理(新增、编辑、归档、搜索筛选、导入/导出、**转移负责人**)
|
||||
- **核心亮点**:
|
||||
1. 创建时自动绑定 `owner_id`,融入 `data_scope` 防线
|
||||
2. **联系人管理** — 独立 `crm_contacts` 表,支持多联系人关联
|
||||
3. **AI 企业画像** — 调用 Ollama 大模型生成企业分析卡片(Dify Workflow 回调双轨写入)
|
||||
4. **AI 客情健康度评分** — 基于交互频率自动打分
|
||||
5. **批量 Excel 导入/导出** — 支持模板下载 + openpyxl 解析
|
||||
6. **客户转移** — 管理员可跨人员重新分配客户负责人
|
||||
7. **全局共享** — 客户数据跨公司共享,不受 `company_id` 隔离
|
||||
- **关键表**:`crm_customers`、`crm_contacts`、`crm_follow_up_logs`
|
||||
|
||||
### 模块 3:供应链与货品库存
|
||||
|
||||
- **功能**:左树(无限级分类)右表(SKU)经典布局
|
||||
- **核心亮点**:
|
||||
1. **无限级分类树**:`erp_product_categories` 自关联支持 N 级嵌套
|
||||
2. **安全库存变更**:一切库存变动必须通过 `POST /api/products/inventory/flow` 提交出入库流水,后端强事务加减库存,留存 `erp_inventory_flows` 审计轨迹
|
||||
3. **库存预警**:`stock_qty <= warning_threshold` 自动标红提示(`erp_sku_inventory` 表)
|
||||
4. **批量 SKU 导入**:Excel 模板 + SKU 编码防重
|
||||
5. **产品/分类全局共享** — SKU 和分类跨公司共享,库存快照 (`erp_sku_inventory`) 按公司隔离
|
||||
|
||||
### 模块 4:订单交易枢纽
|
||||
|
||||
- **功能**:主子表嵌套的沉浸式开单体验,支持多 SKU 明细行
|
||||
- **核心亮点**:
|
||||
1. **B2B 智能动态定价(一客一价)** — 选中 SKU 时静默调用 `/api/orders/price/calculate`,自动调取该客户历史专属拿货价
|
||||
2. **按公司隔离** — 订单数据通过 `company_id` 实现多租户隔离
|
||||
|
||||
### 模块 5:物流与发货执行
|
||||
|
||||
- **功能**:发货单管理,指导仓库拣货出库
|
||||
- **核心亮点**:
|
||||
1. **五步原子事务防超发**:校验 `发货量 ≤ 订单总量 - 已发量`,订单状态更新 + 库存扣减 + 发货单生成毫秒级级联
|
||||
2. **数据级联预加载**:发货单透出客户名称、包装规格等
|
||||
|
||||
### 模块 6:财务报销与票据中心
|
||||
|
||||
- **功能**:三 Tab 一站式财务管理 — 统一票据池 / 新建报销 / 报销大盘
|
||||
- **核心亮点**:
|
||||
1. **发票 AI 智能解析**:拖拽上传 PDF/JPG/PNG/MD → 调用 Ollama 4060 (qwen3.5:4b) 自动提取发票号、金额、日期等字段 → 存入 `fin_invoice_pool.ai_extracted_data` (JSONB)
|
||||
2. **批量上传队列**:支持多文件并行处理,逐一显示解析进度(**不再自动清空**,手动关闭)
|
||||
3. **发票状态机**:发票被报销单选中瞬间 `is_used` 锁定,驳回自动释放
|
||||
4. **高级科目定制**:硬编码专属原始种类(招待费、交通费)和冲顶类型(税务费用)
|
||||
5. **A4 打印**:CSS `@media print` 标准黑白审批单,带四大签字占位符
|
||||
|
||||
### 模块 7:销项发票管理
|
||||
|
||||
- **功能**:独立的销项发票(开票)管理模块
|
||||
- **核心亮点**:
|
||||
1. 客户名称/发票号搜索 + 回款状态筛选 + 开票日期范围
|
||||
2. **AI 发票 OCR 识别** — 调用 Ollama 视觉模型提取 `merchant`/`buyer`/`amount`/`date` 等字段
|
||||
3. **批量 OCR → 待确认列表**(v0.2.0 新增):批量上传后不直接创建,OCR 结果暂存可编辑表格,不完整字段标黄高亮,用户审核修正后"一键全部创建"批量入库
|
||||
4. **单文件模式**:OCR 结果预填到新增表单,`buyer` 字段自动匹配 CRM 客户
|
||||
5. 独立 `finance_sales_invoices` 表
|
||||
|
||||
### 模块 8:销售日志
|
||||
|
||||
- **功能**:销售人员每日拜访/沟通记录管理
|
||||
- **核心亮点**:
|
||||
1. 独立 `sales_logs` 表,支持关键字搜索 + 日期筛选
|
||||
2. **AI 智能审阅** — 调用大模型对日志内容进行质量评分和建议
|
||||
3. 数据同时作为 AI 复盘报告的原始素材
|
||||
4. **多公司业务标签**(v0.2.0 新增):`involved_company_ids` (ARRAY UUID) — 一篇日志可关联1~N个公司,切换公司视角时通过 `ARRAY.any()` 包含过滤,GIN 索引加速
|
||||
|
||||
### 模块 9:Dashboard 工作台
|
||||
|
||||
- **功能**:首页 KPI 总览 + 快捷操作入口 + **公司切换**
|
||||
- **核心亮点**:
|
||||
1. **四大 KPI 卡片**:本月订单数 / 待出库发货 / 库存预警 SKU / 本月营收 — 后端 `GET /api/dashboard/stats` 聚合查询实时计算
|
||||
2. 快捷按钮一键跳转至订单、发货、库存、日志页面
|
||||
3. **顶部公司切换下拉** — 支持在不同公司视角间即时切换
|
||||
|
||||
---
|
||||
|
||||
## 四、AI 中枢模块
|
||||
|
||||
### AI 模块 A:智能复盘报告(Dify Workflow + SSE)
|
||||
|
||||
- **架构**:`前端 → 后端 SSE → Dify Workflow → Ollama 3090 (27B)`
|
||||
- **流程**:
|
||||
1. 前端选择时间范围,后端查询 `sales_logs` 并序列化(**按 `involved_company_ids` 过滤当前公司**)
|
||||
2. 通过 httpx 异步调用 Dify Workflow API(streaming 模式)
|
||||
3. Workflow 内部:HTTP 获取日志 → LLM 分析生成 → HTTP 回调存档
|
||||
4. 后端 SSE 实时推送 `text_chunk` 到前端逐字渲染
|
||||
5. 生成完毕自动保存至 `ai_report_drafts`,支持历史加载和再编辑
|
||||
- **关键配置**:httpx 600s 超时 + nginx `proxy_buffering off` + 600s 读超时
|
||||
- **多租户感知**(v0.2.0):`generate_report` 端点注入 `company_id` 依赖,仅提取涉及当前公司的日志,防止跨公司分析幻觉
|
||||
|
||||
### AI 模块 B:全局 AI 助手(浮球对话)
|
||||
|
||||
- **架构**:`前端 FloatingChat → 后端 /api/chat → Dify Chat API`
|
||||
- **功能**:全页面右下角悬浮球,随时与 AI 对话,支持上下文记忆
|
||||
- **底层引擎**:Dify Chat 应用,对接 Ollama
|
||||
|
||||
---
|
||||
|
||||
## 五、核心数据模型约束
|
||||
|
||||
所有核心表均遵守以下企业级约束:
|
||||
|
||||
| 约束 | 说明 |
|
||||
|------|------|
|
||||
| **主键策略** | 全局 UUID (`uuid4`),防爬虫遍历 |
|
||||
| **软删除** | 所有表包含 `is_deleted` (Boolean),严禁物理 DELETE |
|
||||
| **时间戳** | `created_at` / `updated_at`,SQLAlchemy 监听器自动维护 |
|
||||
| **数据隔离** | 业务查询均受 `data_scope` + `owner_id` + **`company_id`** 三重拦截 |
|
||||
| **迁移管理** | Alembic async 模式,所有 DDL 变更必须通过版本化迁移脚本 |
|
||||
|
||||
### 数据库全表清单(24 张)
|
||||
|
||||
| 域 | 表名 | 用途 | 隔离方式 |
|
||||
|----|------|------|----------|
|
||||
| CRM | `crm_customers` | 客户主表 | 全局共享 |
|
||||
| CRM | `crm_contacts` | 客户联系人 | 全局共享 |
|
||||
| CRM | `crm_follow_up_logs` | 跟进记录 | 全局共享 |
|
||||
| ERP | `erp_product_categories` | 产品分类树 | 全局共享 |
|
||||
| ERP | `erp_product_skus` | 产品 SKU | 全局共享 |
|
||||
| ERP | `erp_sku_inventory` | 库存快照(stock_qty + warning_threshold) | `company_id` |
|
||||
| ERP | `erp_inventory_flows` | 库存变动流水 | `company_id` |
|
||||
| ERP | `erp_orders` | 订单主表 | `company_id` |
|
||||
| ERP | `erp_order_items` | 订单明细行 | — (随主表) |
|
||||
| ERP | `erp_shipping_records` | 发货单主表 | `company_id` |
|
||||
| ERP | `erp_shipping_items` | 发货明细行 | — (随主表) |
|
||||
| 财务 | `fin_invoice_pool` | 报销发票池 | `company_id` |
|
||||
| 财务 | `fin_expense_records` | 报销单主表 | `company_id` |
|
||||
| 财务 | `fin_expense_details` | 报销明细行 | — (随主表) |
|
||||
| 财务 | `finance_sales_invoices` | 销项发票 | `company_id` |
|
||||
| 协同 | `sales_logs` | 销售日志 | `involved_company_ids` (ARRAY) |
|
||||
| AI | `ai_chat_sessions` | AI 对话记录 | — |
|
||||
| AI | `ai_report_drafts` | AI 复盘报告草稿 | — |
|
||||
| 系统 | `sys_users` | 系统用户 | — |
|
||||
| 系统 | `sys_roles` | 角色权限 | — |
|
||||
| 系统 | `sys_departments` | 部门组织 | — |
|
||||
| 系统 | `sys_companies` | **公司/租户主表** | — |
|
||||
| 系统 | `sys_user_companies` | **用户-公司多对多** (含 `is_default`) | — |
|
||||
| 系统 | `alembic_version` | Alembic 迁移版本号 | — |
|
||||
|
||||
### Alembic 迁移清单
|
||||
|
||||
| 序号 | Revision | 说明 |
|
||||
|------|----------|------|
|
||||
| 1 | `03d8dcc2d72a` | v0.1.0 baseline(19 张表) |
|
||||
| 2 | `a1b2c3d4e5f6` | 多租户隔离:新增 `sys_companies` / `sys_user_companies`,8 张表添加 `company_id` |
|
||||
| 3 | `b2c3d4e5f6a7` | 新增新宇润滑油公司 + 全员关联 |
|
||||
| 4 | `c3d4e5f6a7b8` | `sales_logs.company_id` → `involved_company_ids` (ARRAY UUID) + GIN 索引 |
|
||||
|
||||
---
|
||||
|
||||
## 六、API 接口全景(17 个路由模块)
|
||||
|
||||
| 模块 | 前缀 | 核心端点示例 | 多租户 |
|
||||
|------|------|-------------|--------|
|
||||
| auth | `/api/auth` | login, me, password, **user-info (含公司列表)** | — |
|
||||
| customers | `/api/customers` | CRUD + 归档 + AI 画像 + **转移** | 共享 |
|
||||
| contacts | `/api/contacts` | 客户联系人 CRUD | 共享 |
|
||||
| **companies** | `/api/companies` | **公司列表、用户关联公司查询** | — |
|
||||
| orders | `/api/orders` | CRUD + 动态定价 | ✅ |
|
||||
| shipping | `/api/shipping` | 发货 + 原子事务 | ✅ |
|
||||
| products | `/api/products` | SKU + 分类树 + 库存变更 | 部分 |
|
||||
| finance | `/api/finance` | 票据池 + 报销 + 审批 | ✅ |
|
||||
| sales_invoice | `/api/sales-invoices` | 销项发票 CRUD | ✅ |
|
||||
| sales_logs | `/api/sales-logs` | 销售日志 CRUD + AI 审阅 (**company_ids** 数组) | ✅ ARRAY |
|
||||
| reports | `/api/reports` | AI 复盘 SSE 生成 + 历史 (**公司过滤**) | ✅ |
|
||||
| dashboard | `/api/dashboard` | KPI 聚合统计 | ✅ |
|
||||
| chat | `/api/chat` | AI 浮球对话 | — |
|
||||
| dify_tools | `/api/dify-tools` | Dify 回调工具端点 | — |
|
||||
| import_export | `/api` | 模板下载 + Excel 导入导出 | — |
|
||||
| sys_settings | `/api/settings` | 用户/角色/部门管理 | — |
|
||||
| deps | — | 鉴权依赖注入 + **`get_current_company_id`** | — |
|
||||
|
||||
---
|
||||
|
||||
## 七、v0.1.0 → v0.2.0 变更日志
|
||||
|
||||
### 已完成增量(Phase 1.5 → 2.0)
|
||||
|
||||
| 项目 | v0.1.0 状态 | v0.2.0 状态 |
|
||||
|------|-------------|-------------|
|
||||
| Alembic 迁移 | ❌ 手工 DDL | ✅ 4 个版本化迁移脚本,async 模式 |
|
||||
| 多租户 | ❌ 仅天津硕博霖 | ✅ 行级 `company_id` 隔离,IDOR 防护 |
|
||||
| 公司切换 | ❌ | ✅ 登录页选择 + 首页下拉切换 |
|
||||
| 新宇润滑油 | ❌ | ✅ 第二家公司,全员关联 |
|
||||
| 客户转移 | ❌ | ✅ 管理员可跨人员重新分配 |
|
||||
| sales_logs 多公司 | 无 company 概念 | ✅ `involved_company_ids` ARRAY + GIN 索引 |
|
||||
| AI 复盘公司过滤 | 提取所有日志 | ✅ 仅提取涉及当前公司的日志 |
|
||||
| 发票 OCR buyer 字段 | ❌ 映射缺失 | ✅ 修复 `buyer`/`buyer_name`/`customer_name` 链 |
|
||||
| 发票批量交互 | 自动创建 + 5s 消失 | ✅ 待确认列表 + 字段标黄 + 一键创建 |
|
||||
| 报销上传队列 | 3s 自动清空 | ✅ 手动关闭 |
|
||||
| Dashboard 库存预警 | `erp_product_skus.warning_threshold` | ✅ 修正为 `erp_sku_inventory` 表 |
|
||||
| 数据库表数量 | 19 张 | 24 张(+5) |
|
||||
| API 路由模块 | 16 个 | 17 个(+companies) |
|
||||
|
||||
### Phase 3 规划
|
||||
|
||||
| 优先级 | 方向 | 说明 |
|
||||
|--------|------|------|
|
||||
| **P0** | HTTPS | 内网 CA 自签证书,保护 JWT Token 传输安全 |
|
||||
| **P1** | 数据可视化 | ECharts 接入 Dashboard — 月度趋势折线图、报销占比饼图 |
|
||||
| **P1** | 操作审计日志 | 记录关键操作(删除、审批、密码重置)的完整审计轨迹 |
|
||||
| **P1** | 文件存储优化 | 对接 MinIO / 阿里云 OSS,替代本地 uploads 目录 |
|
||||
| **P1** | 通知公告模块 | 实现系统内通知与公告发布功能 |
|
||||
| **P2** | 移动端适配 | 响应式布局或微信小程序,支持外出销售人员使用 |
|
||||
| **P2** | 外网访问 | VPN / Cloudflare Tunnel 实现远程办公访问 |
|
||||
| **P2** | 监控告警 | Prometheus + Grafana 仪表板 + 钉钉/微信告警推送 |
|
||||
| **P2** | AI 复盘页修复 | 排查 SSE 流 / 前端组件加载问题 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键架构决策记录 (ADR)
|
||||
|
||||
| # | 决策 | 理由 |
|
||||
|---|------|------|
|
||||
| ADR-001 | 后端 Docker 使用 `network_mode: host` | 直连宿主机 PostgreSQL,免改 `pg_hba.conf`,简化内网部署 |
|
||||
| ADR-002 | 前端 Docker 使用 `bridge + extra_hosts` | 通过 `host.docker.internal` 解析宿主机,隔离前端网络 |
|
||||
| ADR-003 | AI 引擎选择 Dify + Ollama 私有化 | 数据不出内网,满足工业企业信息安全合规要求 |
|
||||
| ADR-004 | 复盘报告采用 SSE 而非 WebSocket | 单向推送场景 SSE 更轻量,nginx 配置更简单 |
|
||||
| ADR-005 | 全表 UUID 主键 | 防止 ID 遍历攻击,适配分布式未来扩展 |
|
||||
| ADR-006 | 库存变更仅允许流水模式 | 杜绝直接篡改库存,确保账实一致 + 审计可追溯 |
|
||||
| **ADR-007** | **行级多租户而非 Schema 隔离** | 公司数量有限(2~5 家),行级隔离实现简单,共享表无需冗余 |
|
||||
| **ADR-008** | **`sales_logs` 使用 ARRAY UUID 而非单 `company_id`** | CRM 日志需保持"以客户为中心"完整性,一篇日志可能涉及多个公司业务 |
|
||||
| **ADR-009** | **Alembic async 模式 + env.py 地址替换** | Docker 内 `.env` 用 `host.docker.internal`,Alembic 在宿主机执行时自动替换为 `127.0.0.1` |
|
||||
| **ADR-010** | **`X-Company-Id` Header + IDOR 校验** | 比 Cookie/Session 更适配 SPA 前后端分离,校验 `sys_user_companies` 防止伪造 |
|
||||
|
||||
---
|
||||
|
||||
*本文档为 SHBL-ERP CRM 系统 Phase 2.0 阶段性存档,随系统迭代持续更新。*
|
||||
@@ -0,0 +1,78 @@
|
||||
# 需求1
|
||||
|
||||
模块2客户管理,新增表单项目“客户开票信息”,方便后续合同管理和开票过程提供给财务;
|
||||
|
||||
# 需求2
|
||||
|
||||
在模块2下方增加模块10:合同管理
|
||||
|
||||
合同管理模块内容如下:
|
||||
|
||||
功能:客户合作合同管理(新增、编辑、删除、执行进度显示和标记、导出、打印)
|
||||
|
||||
主要实现目标:
|
||||
|
||||
一、基于标准合同模板的情况下,特定字段选择或填入,自动生成合同。初步考虑字段如下:
|
||||
|
||||
1.买方:(从客户列表中选择客户,自动带入客户名称)
|
||||
|
||||
2.卖方:(根据当前所在为天津硕博霖或者新宇润滑油自动带入完整公司名称,需要考虑现有内容中没有这两个公司完整信息,需要硬编码还是允许在设置中手动编辑)
|
||||
|
||||
3.合同明细:
|
||||
|
||||
参考订单明细字段 选择产品 自动带出规格 单价 数量 小计
|
||||
|
||||
允许添加多条产品和价格
|
||||
|
||||
下方自动生成合计行(不含税金额,含税金额)
|
||||
|
||||
自动生成对应的大写合计
|
||||
|
||||
4.付款条件为下拉选框
|
||||
|
||||
主要考虑以下几种:预付全款订货;预付30%订货,到货前付清;预付50%订货,到货前付清;货到付全款;开具发票后30天内付款;开具发票45天付款;开具发票60天付款;开具发票90天付款。
|
||||
|
||||
5.运费条款为下拉选框
|
||||
|
||||
主要考虑以下几种:买方自提;卖方免费送达天津指定地点;卖方免费送达指定地点;物流发货,运费买方承担
|
||||
|
||||
6.买方信息为从1选择的客户的‘客户开票信息’拉取,下方自动补充联系人签字: 日期(自动获取当天日期)
|
||||
|
||||
7.卖方信息,从2选择的卖方公司信息中自动拉取,下方同样
|
||||
|
||||
自动补充联系人签字: 日期(自动获取当天日期)
|
||||
|
||||
二、合同生成之后应该允许根据相关字段一键生成订单,在合同上添加一键生成订单按钮,自动拉取关键字段一键生成订单,过程中允许编辑修改
|
||||
|
||||
三、合同详情应该有子标签显示相关执行进度,应该提供接口要求上传双签盖章版本存档,进度包括是否双签,是否生成订单,是否发货,是否回款,需要和其他模块充分联动
|
||||
|
||||
# 需求3
|
||||
|
||||
模块4订单交易枢纽功能强化,需要支持从合同创建订单,手动创建订单
|
||||
|
||||
将模块5功能整合进订单交易枢纽的订单详情页,采用全页面显示,不使用侧面弹出,方便进行截图和打印
|
||||
|
||||
将模块7中销项发票也关联到订单,允许点击订单整单开票或者根据发货记录开票,允许多选一起开具,选中后生成带客户名称和货物名称的开票明细单方便员工开票
|
||||
|
||||
员工开票之后允许上传开具的发票,系统自动和开票明细比对数量金额等,有差异人工确认允许修改,确认后生成销项发票明细表,自动关联到客户,根据合同约定的付款条件字段生成回款截止时间,允许人工修改并做回款标注,保留原有模块7主要功能和亮点;
|
||||
|
||||
# 需求4
|
||||
|
||||
客户管理目前反馈客户未做隔离,正常应该是业务员只能看本人名下客户,部门经理看本部门客户,管理员查看所有客户
|
||||
|
||||
|
||||
|
||||
# 需求5
|
||||
|
||||
采购管理模块
|
||||
|
||||
作为模块3的附属子模块,扩充原有增加库存操作表单,通常需要录入产品增加库存输入产品单价,用于后续利润核算版块计算利润;允许特殊产品做特殊入库,允许采购单价为0
|
||||
|
||||
# 需求6
|
||||
|
||||
利润核算版块
|
||||
|
||||
自动拉取订单模块订单,获取对应的产品入库单价,自动计算单订单利润总额,利润率等数据,允许选定时间段,自动核算该时间段内所有订单的利润总额和变化趋势
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# SHBL-ERP 升级 PRD:AI 原生销售攻坚教练引擎 (Phase 3.0)
|
||||
|
||||
**项目代号**:天津硕博霖销售攻坚智能体改造 **关联基础版本**:v0.2.0 **核心目标**:实现 AI 从“被动问答”向“业务流主动拦截与教练辅导”的跨越,提升针对燃气轮机、透平压缩机等大型工业设备的大客户攻坚成功率。
|
||||
|
||||
## 一、 数据库层改造 (Database Schema Updates)
|
||||
|
||||
目前核心数据持久层基于 PostgreSQL。本次需要引入向量检索能力并扩展现有业务表,以支持结构化的 AI 评估数据。
|
||||
|
||||
- **扩展 1:引入 `pgvector` 插件**
|
||||
- **需求**:在当前的 PostgreSQL 宿主机实例中安装并启用 `pgvector` 扩展。
|
||||
- **目的**:构建原生向量表(如 `kb_obsidian_vectors`),用于将平时沉淀的设备工况数据、润滑油 TDS/MSDS 及历史技术方案进行向量化存储,供大模型进行检索增强 (RAG)。
|
||||
- **扩展 2:修改 `sales_logs` 表**
|
||||
- **变更**:新增字段 `ai_coaching_feedback` (类型:`JSONB`,可空)。
|
||||
- **目的**:存储 AI 异步评估后返回的结构化教练反馈(含 MEDDIC 扫描、SPIN 提问建议)。
|
||||
- **扩展 3:修改 `crm_customers` 表**
|
||||
- **变更 1**:新增字段 `health_score` (类型:`Integer`,默认 100)。
|
||||
- **变更 2**:新增字段 `meddic_status` (类型:`JSONB`,可空)。
|
||||
- **目的**:固化大客户的交易健康度,便于王 X 等管理层直观把控高净值客户的推进阶段。
|
||||
- **迁移任务**:通过 Alembic 生成对应的 `async` 迁移脚本。
|
||||
|
||||
## 二、 后端接口层改造 (FastAPI Backend Updates)
|
||||
|
||||
需要改变目前 AI 模块仅依靠 SSE 实时流式输出的单向模式。
|
||||
|
||||
- **改造 1:重构 `POST /api/sales-logs` 接口**
|
||||
- **主逻辑不变**:接收前端提交的销售日志,校验 `involved_company_ids`,执行常规的快速数据库 `INSERT`,立刻向前端返回 200 OK,确保业务流程不卡顿。
|
||||
- **新增逻辑**:挂载 FastAPI 原生的 `BackgroundTasks`。在后台异步触发 `httpx` 请求,调用 Dify 的 Workflow API,将新生成的 `log_id`、客户信息与日志内容发送给底层部署在公司机架上的 R740-A 服务器进行深度思考。
|
||||
- **新增 1:内部回调接口 `POST /api/dify-tools/log-feedback`**
|
||||
- **目的**:接收 Dify Workflow 执行完毕后返回的标准化 JSON 数据,反向执行 `UPDATE sales_logs SET ai_coaching_feedback = ? WHERE id = ?`。
|
||||
- **新增 2:SSE 通知系统 `GET /api/notifications/stream`**
|
||||
- **目的**:当后台 `ai_coaching_feedback` 字段更新完毕时,向对应的销售人员(`owner_id`)推送一条前端消息提示:“💡 您的最新拜访日志已生成 AI 战术诊断,请查阅。”
|
||||
|
||||
## 三、 前端交互层改造 (Vue 3 + Element Plus)
|
||||
|
||||
- **改造 1:全局悬浮球 (FloatingChat) 上下文嗅探**
|
||||
- **需求**:修改前端逻辑,拦截悬浮球的开启事件。读取 Vue Router 当前路径。如果处于 `/customers/detail/:id` 路由下,隐式提取当前客户画像与近期沟通历史,随 Chat API 的 Payload 自动发送。
|
||||
- **改造 2:`sales_logs` 详情视图扩展**
|
||||
- **需求**:在日志详情页增加一个“教练评估面板 (Coaching Board)”。
|
||||
- **渲染逻辑**:读取 `ai_coaching_feedback` (JSONB) 字段。如果为空,显示“🧠 AI 战术推演中...”的骨架屏;如果有数据,使用 Element Plus 的折叠面板或卡片组件,结构化渲染“MEDDIC 盲区警告”、“Solution 诊断需求”和“SPIN 提问剧本”。
|
||||
|
||||
------
|
||||
|
||||
## 四、 Dify 编排层配置指南 (Workflow API)
|
||||
|
||||
此阶段的算力将严格依赖 R740-A 节点。其中 RTX 3090 (`qwen3.5:27b`) 负责深度逻辑剖析,RTX 4060 (`bge-m3`) 负责高频知识库向量检索。
|
||||
|
||||
**配置模式**:在 Dify 中新建应用,**必须选择“工作流 (Workflow)”类型**,而非传统的“聊天助手”。
|
||||
|
||||
### 1. 变量定义节点 (Start Node)
|
||||
|
||||
定义明确的系统级输入变量,确保接口调用规范:
|
||||
|
||||
- `log_id` (String):系统日志主键。
|
||||
- `customer_context` (String):由 FastAPI 拼装的客户基础信息(行业、设备规模等)。
|
||||
- `log_content` (String):业务员刚提交的散乱日记文本。
|
||||
|
||||
### 2. 知识检索节点 (Knowledge Retrieval)
|
||||
|
||||
- **配置**:挂载基于 Obsidian 笔记构建的技术参数知识库。
|
||||
- **作用**:根据 `log_content` 中的关键词(如特定型号的伺服阀、透平压缩机),从向量库中抽取出相应的油样分析标准或故障排查手册。
|
||||
|
||||
### 3. LLM 处理节点 (Core Engine)
|
||||
|
||||
- **模型选择**:`qwen3.5:27b` (位于 192.168.1.88:11434)。
|
||||
|
||||
- **System Prompt**:植入我们上一轮确定的“工业大客户攻坚教练” Markdown 提示词。
|
||||
|
||||
- **关键指令**:在 Prompt 末尾增加 JSON 约束指令:
|
||||
|
||||
> "你必须严格输出合法的 JSON 格式,不要包含任何 Markdown 代码块包裹(即不要带有 ```json)。结构必须如下:" `{"meddic_alerts": ["..."], "solution_diagnostics": "...", "spin_questions": [{"type": "Situation", "question": "..."}, ...]}`
|
||||
|
||||
### 4. HTTP 请求节点 (HTTP Request - End Node)
|
||||
|
||||
- **配置**:不使用默认的结束节点。在 LLM 节点后,直接拼接一个 HTTP Request 节点。
|
||||
- **动作**:将 LLM 生成的 JSON 结果,加上 `log_id`,以 POST 形式发送回你后端的 `/api/dify-tools/log-feedback` 接口完成闭环。
|
||||
@@ -1,21 +0,0 @@
|
||||
APP_NAME=SHBL-CRM
|
||||
APP_VERSION=2.0.0
|
||||
DEBUG=true
|
||||
|
||||
# PostgreSQL 连接
|
||||
DB_HOST=192.168.1.85
|
||||
DB_PORT=5432
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=admin_password_2026
|
||||
DB_NAME=lubrication_crm
|
||||
|
||||
# JWT 密钥
|
||||
SECRET_KEY=dev_secret_key_replace_in_production_64chars_minimum_length_ok
|
||||
|
||||
# CORS 白名单
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:8080"]
|
||||
|
||||
# Dify BaaS 平台
|
||||
DIFY_BASE_URL=http://192.168.1.88/v1
|
||||
DIFY_LOG_APP_API_KEY=app-gMi1uhkJXjteZk1Qc27Ve8Jw
|
||||
DIFY_REPORT_APP_API_KEY=app-dhEK3cgt7iqksRMaviNqUu9W
|
||||
BIN
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
# 开发环境 API 基础路径
|
||||
# Vite 开发服务器通过 proxy 转发 /api → http://127.0.0.1:8000
|
||||
VITE_API_BASE_URL=
|
||||
@@ -1,3 +0,0 @@
|
||||
# 生产环境 API 基础路径
|
||||
# Nginx 统一代理,前端和后端同域,无需跨域
|
||||
VITE_API_BASE_URL=
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 前端路由守卫与权限 E2E 测试
|
||||
* 覆盖: 未登录重定向 / 已登录访问 /login / Token 失效后跳转
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// 不使用全局 auth
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe('路由守卫', () => {
|
||||
test('未登录访问受保护页面被重定向到登录页', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await page.waitForURL(/\/login/);
|
||||
await expect(page.locator('.login-title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('未登录访问订单页被重定向', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForURL(/\/login/);
|
||||
});
|
||||
|
||||
test('未登录访问设置页被重定向', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await page.waitForURL(/\/login/);
|
||||
});
|
||||
|
||||
test('登录页直接可访问', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
// 不会重定向,直接显示登录页
|
||||
await expect(page.locator('.login-title')).toContainText('CRM');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token 销毁后行为', () => {
|
||||
test('清除 Token 后访问受保护页面被重定向', async ({ page }) => {
|
||||
// 先登录
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123456');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// 清除 localStorage(模拟 Token 销毁)
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
||||
// 访问受保护页面
|
||||
await page.goto('/customers');
|
||||
// 应被重定向到登录页
|
||||
await page.waitForURL(/\/login/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 全局认证 Setup
|
||||
* 通过 UI 登录后持久化 storageState,后续所有测试复用登录态
|
||||
*/
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'e2e/.auth/user.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// 1. 访问登录页
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('.login-title')).toContainText('天津硕博霖 CRM 系统');
|
||||
|
||||
// 2. 填写表单
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123456');
|
||||
|
||||
// 3. 点击登录
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// 4. 等待跳转到首页 (工作台)
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
await expect(page.locator('.el-menu--vertical').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 5. 持久化认证状态 (localStorage token + cookies)
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 业务闭环 E2E 测试 (v4)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe.serial('业务闭环: 客户 → 订单 → 发货', () => {
|
||||
test('Step 1: 新增客户', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: '新增客户' }).click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
await page.getByPlaceholder('请输入客户公司名称').fill(`闭环_${Date.now()}`);
|
||||
await page.getByPlaceholder('联系人姓名').fill('测试联系人');
|
||||
await page.getByPlaceholder('所属行业').fill('测试行业');
|
||||
|
||||
await page.locator('.el-dialog__footer').getByRole('button', { name: /确|保存|提交/ }).click();
|
||||
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Step 2: 创建订单', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: '新建订单' }).click();
|
||||
// el-drawer 打开
|
||||
await expect(page.getByLabel('新建订单')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('订单明细')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Step 3: 验证订单列表', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 合同管理 E2E 测试 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('合同管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/contracts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('合同列表正确加载', async ({ page }) => {
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
// 用表头列来验证
|
||||
await expect(page.getByRole('columnheader', { name: '合同编号' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('新增合同弹窗可打开', async ({ page }) => {
|
||||
const addBtn = page.getByRole('button', { name: /新增|新建|创建/ });
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
// 可能是 dialog 或全屏
|
||||
await expect(page.getByLabel('新增合同').first()).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('合同详情页导航', async ({ page }) => {
|
||||
const viewBtn = page.locator('.el-table').getByRole('button', { name: /详情|查看/ }).first();
|
||||
if (await viewBtn.isVisible({ timeout: 3000 })) {
|
||||
await viewBtn.click();
|
||||
await page.waitForURL(/contracts\/detail/, { timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 客户管理 E2E 测试 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('客户管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('客户列表正确加载', async ({ page }) => {
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('button', { name: '新增客户' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('新增客户完整流程', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '新增客户' }).click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
const timestamp = Date.now();
|
||||
await page.getByPlaceholder('请输入客户公司名称').fill(`E2E测试客户_${timestamp}`);
|
||||
await page.getByPlaceholder('所属行业').fill('自动化测试');
|
||||
await page.getByPlaceholder('联系人姓名').fill('张测试');
|
||||
|
||||
// 选择客户级别
|
||||
await page.locator('.el-dialog .el-select').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
// 选项: A级重点 / B级普通 / C级长尾
|
||||
await page.getByRole('option').first().click();
|
||||
|
||||
// 提交
|
||||
await page.locator('.el-dialog__footer').getByRole('button', { name: /确|保存|提交/ }).click();
|
||||
await expect(page.locator('.el-dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('关键词搜索客户', async ({ page }) => {
|
||||
const searchInput = page.getByPlaceholder(/搜索|客户名称|关键词/);
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('中石化');
|
||||
await page.getByRole('button', { name: '搜索' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.el-table').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('查看客户档案详情', async ({ page }) => {
|
||||
const viewBtn = page.locator('.el-table').getByRole('button', { name: '查看档案' }).first();
|
||||
if (await viewBtn.isVisible({ timeout: 3000 })) {
|
||||
await viewBtn.click();
|
||||
await page.waitForURL(/customers\/detail/);
|
||||
}
|
||||
});
|
||||
|
||||
test('编辑客户信息', async ({ page }) => {
|
||||
const editBtn = page.locator('.el-table').getByRole('button', { name: '编辑' }).first();
|
||||
if (await editBtn.isVisible({ timeout: 3000 })) {
|
||||
await editBtn.click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('分页翻页', async ({ page }) => {
|
||||
const pagination = page.locator('.el-pagination');
|
||||
if (await pagination.isVisible()) {
|
||||
const nextBtn = pagination.locator('.btn-next');
|
||||
if (await nextBtn.isEnabled()) {
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.el-table').first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 工作台 Dashboard 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('工作台', () => {
|
||||
test('统计卡片正确显示', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 实际卡片标题
|
||||
await expect(page.getByText('本月新增订单')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('待出库发货')).toBeVisible();
|
||||
await expect(page.getByText('库存预警 SKU')).toBeVisible();
|
||||
await expect(page.getByText('本月预计营收')).toBeVisible();
|
||||
});
|
||||
|
||||
test('侧边栏菜单可点击导航', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 菜单在子菜单组中,需先展开 "业务线"
|
||||
await page.getByText('业务线').click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('menuitem', { name: '客户管理' }).click();
|
||||
await page.waitForURL('/customers');
|
||||
await expect(page).toHaveURL('/customers');
|
||||
});
|
||||
|
||||
test('快捷操作按钮可见', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByRole('button', { name: '新建订单' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '安排发货' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '库存入库' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 财务模块 E2E 测试 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('报销大盘', () => {
|
||||
test('报销大盘页面加载', async ({ page }) => {
|
||||
await page.goto('/finance');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/票据|发票|报销|费用/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('票据相关内容可见', async ({ page }) => {
|
||||
await page.goto('/finance');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// 只要页面有相关内容加载
|
||||
await expect(page.locator('body')).toContainText(/报销|票据|费用|支出/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('销项发票', () => {
|
||||
test('销项发票页面加载', async ({ page }) => {
|
||||
await page.goto('/finance/sales-invoices');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/销项发票|发票/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('销项发票列表', async ({ page }) => {
|
||||
await page.goto('/finance/sales-invoices');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const table = page.locator('.el-table').first();
|
||||
if (await table.isVisible({ timeout: 5000 })) {
|
||||
await expect(table).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 登录页面测试
|
||||
* 覆盖: 页面渲染 / 正确登录 / 错误密码 / 空字段校验 / 跳转
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// 登录测试不使用全局 auth,独立登录
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe('登录页面', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('页面正确渲染', async ({ page }) => {
|
||||
// 标题
|
||||
await expect(page.locator('.login-title')).toContainText('天津硕博霖 CRM 系统');
|
||||
await expect(page.locator('.login-subtitle')).toContainText('v2.0');
|
||||
|
||||
// 表单元素
|
||||
await expect(page.getByPlaceholder('用户名')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('密码')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '登 录' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('正确账密登录后跳转工作台', async ({ page }) => {
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123456');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// 等待跳转到首页
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
// 侧边栏菜单应出现
|
||||
await expect(page.locator('.el-menu--vertical').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('错误密码弹出错误提示', async ({ page }) => {
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('wrong123');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// Element Plus 的 ElMessage 错误提示
|
||||
await expect(page.locator('.el-message--error')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('空字段显示校验提示', async ({ page }) => {
|
||||
// 直接点登录
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// el-form 校验提示
|
||||
await expect(page.locator('.el-form-item__error')).toHaveCount(2);
|
||||
await expect(page.locator('.el-form-item__error').first()).toContainText('请输入用户名');
|
||||
});
|
||||
|
||||
test('密码少于6位提示校验', async ({ page }) => {
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
await expect(page.locator('.el-form-item__error')).toContainText('密码至少 6 位');
|
||||
});
|
||||
|
||||
test('未登录访问首页被重定向到登录页', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForURL(/\/login/);
|
||||
await expect(page.locator('.login-title')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 发货、利润、日志、复盘页面 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('发货记录', () => {
|
||||
test('发货页面加载', async ({ page }) => {
|
||||
await page.goto('/shipping');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// 验证页面加载了发货相关内容
|
||||
await expect(page.locator('body')).toContainText(/发货|物流|订单/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('利润核算', () => {
|
||||
test('利润核算页面加载', async ({ page }) => {
|
||||
await page.goto('/profit');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/利润|核算|成本/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('销售日志', () => {
|
||||
test('销售日志列表加载', async ({ page }) => {
|
||||
await page.goto('/logs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/销售日志|日志/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('新增日志入口', async ({ page }) => {
|
||||
await page.goto('/logs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const addBtn = page.getByRole('button', { name: /新增|新建|撰写|写/ });
|
||||
if (await addBtn.isVisible({ timeout: 3000 })) {
|
||||
await expect(addBtn).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('AI 智能复盘', () => {
|
||||
test('复盘页面加载', async ({ page }) => {
|
||||
await page.goto('/reports');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/复盘|报告|AI/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 订单管理 E2E 测试 (v4 - 基于源码修正)
|
||||
* 新建订单是 el-drawer size=85%
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('订单管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('订单列表正确加载', async ({ page }) => {
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('button', { name: '新建订单' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('打开新建订单抽屉', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '新建订单' }).click();
|
||||
// el-drawer 打开
|
||||
await expect(page.getByLabel('新建订单')).toBeVisible({ timeout: 5000 });
|
||||
// 验证关键元素
|
||||
await expect(page.getByText('订单明细')).toBeVisible();
|
||||
});
|
||||
|
||||
test('新建订单页面元素验证', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '新建订单' }).click();
|
||||
await expect(page.getByLabel('新建订单')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await expect(page.getByRole('button', { name: /确认开单/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '取消' })).toBeVisible();
|
||||
await expect(page.getByText('添加产品行')).toBeVisible();
|
||||
});
|
||||
|
||||
test('筛选订单', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '检索' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.el-table').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('查看订单详情', async ({ page }) => {
|
||||
const detailBtn = page.locator('.el-table').getByRole('button', { name: '详情' }).first();
|
||||
if (await detailBtn.isVisible({ timeout: 3000 })) {
|
||||
await detailBtn.click();
|
||||
// 详情也是 el-drawer
|
||||
await expect(page.getByLabel('订单全景详情')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('发货操作入口', async ({ page }) => {
|
||||
const shipBtn = page.locator('.el-table').getByRole('button', { name: '发货' }).first();
|
||||
if (await shipBtn.isVisible({ timeout: 3000 }) && await shipBtn.isEnabled()) {
|
||||
await expect(shipBtn).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 产品与库存 E2E 测试
|
||||
* 覆盖: 分类树 / SKU 列表 / 新增 SKU / 库存入库 / 流水查看
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('产品与库存', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('页面正确加载', async ({ page }) => {
|
||||
// 表格和操作按钮可见
|
||||
await expect(page.locator('.el-table')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('分类树可见', async ({ page }) => {
|
||||
// 左侧分类树
|
||||
const tree = page.locator('.el-tree');
|
||||
if (await tree.isVisible({ timeout: 3000 })) {
|
||||
await expect(tree).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('新增 SKU 弹窗', async ({ page }) => {
|
||||
const addBtn = page.getByRole('button', { name: /新增|新建.*SKU|新建产品/ });
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('SKU 搜索', async ({ page }) => {
|
||||
const searchInput = page.getByPlaceholder(/搜索|产品|SKU|关键词/);
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('壳牌');
|
||||
await page.waitForTimeout(1000);
|
||||
// 表格应刷新
|
||||
await expect(page.locator('.el-table')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('库存操作入口', async ({ page }) => {
|
||||
// 表格中应有库存/入库相关按钮
|
||||
const inventoryBtn = page.locator('.el-table').getByRole('button', { name: /入库|库存|编辑/ }).first();
|
||||
if (await inventoryBtn.isVisible({ timeout: 3000 })) {
|
||||
await expect(inventoryBtn).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 系统设置 E2E 测试 (根据真实截图修正)
|
||||
* 三个 Tab:
|
||||
* - 部门与员工管理
|
||||
* - 角色与权限控制 (RBAC)
|
||||
* - 企业账号配置
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('系统设置', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('设置页面正确加载', async ({ page }) => {
|
||||
// 三个 Tab 标题
|
||||
await expect(page.getByText('部门与员工管理')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('角色与权限控制')).toBeVisible();
|
||||
await expect(page.getByText('企业账号配置')).toBeVisible();
|
||||
});
|
||||
|
||||
test('部门与员工管理 tab 内容', async ({ page }) => {
|
||||
const deptTab = page.getByText('部门与员工管理').first();
|
||||
await deptTab.click();
|
||||
await page.waitForTimeout(1000);
|
||||
// 应显示部门树或员工列表
|
||||
await expect(page.locator('body')).toContainText(/部门|员工/, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('角色与权限控制 tab', async ({ page }) => {
|
||||
await page.getByText('角色与权限控制').first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
// 左侧应有角色列表区域
|
||||
await expect(page.getByText('平台运营角色')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('新增角色')).toBeVisible();
|
||||
});
|
||||
|
||||
test('新增角色按钮', async ({ page }) => {
|
||||
await page.getByText('角色与权限控制').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByText('新增角色')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('企业账号配置 tab', async ({ page }) => {
|
||||
await page.getByText('企业账号配置').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('body')).toContainText(/企业|账号/, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
Generated
+69
@@ -12,10 +12,12 @@
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"pinia": "^2.2.0",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
@@ -559,6 +561,21 @@
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
@@ -1758,6 +1775,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia-plugin-persistedstate": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.3.tgz",
|
||||
"integrity": "sha512-Cm819WBj/s5K5DGw55EwbXDtx+EZzM0YR5AZbq9XE3u0xvXwvX2JnWoFpWIcdzISBHqy9H1UiSIUmXyXqWsQRQ==",
|
||||
"peerDependencies": {
|
||||
"pinia": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx"
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
@@ -20,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* SHBL-ERP CRM Playwright E2E 配置
|
||||
*
|
||||
* 运行方式:
|
||||
* npx playwright test # 全量
|
||||
* npx playwright test --headed # 有头模式
|
||||
* npx playwright test --ui # UI 模式
|
||||
* npx playwright test tests/login.spec.ts # 单文件
|
||||
*
|
||||
* 前置:
|
||||
* 1. 确保后端 uvicorn 已启动 (http://localhost:8000)
|
||||
* 2. 确保前端 vite dev 已启动 (http://localhost:5173)
|
||||
* 3. 数据库已有可用的管理员账号 (admin / admin123)
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false, // CRM 测试有数据依赖,串行执行
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never' }],
|
||||
],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:80',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
// 中文字体 viewport
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
|
||||
projects: [
|
||||
// ── 先执行全局登录,保存认证状态 ──
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
|
||||
// ── 主测试套件 ──
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: './e2e/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
|
||||
/* 注释掉可选的自动启动 dev servers:
|
||||
webServer: [
|
||||
{
|
||||
command: 'cd ../server && venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000',
|
||||
port: 8000,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
],
|
||||
*/
|
||||
});
|
||||
@@ -34,6 +34,9 @@ request.interceptors.request.use(
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
if (userStore.currentCompanyId) {
|
||||
config.headers['X-Company-Id'] = userStore.currentCompanyId
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ChatDotRound, Select, Close } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
@@ -7,6 +8,20 @@ import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const chatStore = useChatStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 路由感知:嗅探当前页面上下文
|
||||
const currentContext = computed(() => {
|
||||
const path = route.path
|
||||
const query = route.query
|
||||
if (path.includes('/customers/detail') && query.id) {
|
||||
return { context_type: 'customer', customer_id: query.id as string }
|
||||
}
|
||||
if (path.includes('/contracts/detail') && query.id) {
|
||||
return { context_type: 'contract', contract_id: query.id as string }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 悬浮球状态
|
||||
const isChatOpen = ref(false)
|
||||
@@ -119,7 +134,11 @@ const sendMessage = async () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ message: userMsg, conversation_id: chatStore.conversationId })
|
||||
body: JSON.stringify({
|
||||
message: userMsg,
|
||||
conversation_id: chatStore.conversationId,
|
||||
...(currentContext.value ? { context: currentContext.value } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
Setting,
|
||||
Fold,
|
||||
Expand,
|
||||
ArrowDown
|
||||
ArrowDown,
|
||||
OfficeBuilding,
|
||||
Stamp,
|
||||
TrendCharts
|
||||
} from '@element-plus/icons-vue'
|
||||
import FloatingChat from '@/components/FloatingChat.vue'
|
||||
|
||||
@@ -95,6 +98,15 @@ const handleCommand = (command: string) => {
|
||||
openPwdDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换公司 */
|
||||
const handleSwitchCompany = (companyId: string) => {
|
||||
if (companyId === userStore.currentCompanyId) return
|
||||
userStore.switchCompany(companyId)
|
||||
ElMessage.success(`已切换至:${userStore.currentCompanyName}`)
|
||||
// 刷新当前路由数据
|
||||
router.replace({ path: route.fullPath, query: { ...route.query, _t: Date.now().toString() } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,6 +145,10 @@ const handleCommand = (command: string) => {
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<template #title>订单管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/contracts">
|
||||
<el-icon><Stamp /></el-icon>
|
||||
<template #title>合同管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/shipping">
|
||||
<el-icon><Van /></el-icon>
|
||||
<template #title>发货记录</template>
|
||||
@@ -157,6 +173,10 @@ const handleCommand = (command: string) => {
|
||||
</template>
|
||||
<el-menu-item index="/finance/sales-invoices">销项发票</el-menu-item>
|
||||
<el-menu-item index="/finance">报销管理</el-menu-item>
|
||||
<el-menu-item index="/profit">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<template #title>利润核算</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="oa">
|
||||
@@ -202,6 +222,34 @@ const handleCommand = (command: string) => {
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 公司视角切换 -->
|
||||
<el-dropdown
|
||||
v-if="userStore.companies.length > 0"
|
||||
trigger="click"
|
||||
@command="handleSwitchCompany"
|
||||
style="margin-right: 20px;"
|
||||
>
|
||||
<span class="company-dropdown">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span class="company-name">{{ userStore.currentCompanyName }}</span>
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="c in userStore.companies"
|
||||
:key="c.id"
|
||||
:command="c.id"
|
||||
:class="{ 'is-active-company': c.id === userStore.currentCompanyId }"
|
||||
>
|
||||
{{ c.name }}
|
||||
<el-tag v-if="c.id === userStore.currentCompanyId" size="small" type="success" style="margin-left: 8px;">当前</el-tag>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 用户头像 -->
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="user-dropdown">
|
||||
<el-avatar :size="30" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
|
||||
@@ -339,6 +387,24 @@ const handleCommand = (command: string) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.company-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.company-dropdown:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.company-name {
|
||||
margin: 0 4px;
|
||||
}
|
||||
.is-active-company {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
|
||||
@@ -46,6 +46,24 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/orders/index.vue'),
|
||||
meta: { title: '订单管理' },
|
||||
},
|
||||
{
|
||||
path: 'orders/detail',
|
||||
name: 'OrderDetail',
|
||||
component: () => import('@/views/orders/OrderDetail.vue'),
|
||||
meta: { title: '订单详情' },
|
||||
},
|
||||
{
|
||||
path: 'contracts',
|
||||
name: 'Contracts',
|
||||
component: () => import('@/views/contracts/index.vue'),
|
||||
meta: { title: '合同管理' },
|
||||
},
|
||||
{
|
||||
path: 'contracts/detail',
|
||||
name: 'ContractDetail',
|
||||
component: () => import('@/views/contracts/ContractDetail.vue'),
|
||||
meta: { title: '合同详情' },
|
||||
},
|
||||
{
|
||||
path: 'shipping',
|
||||
name: 'Shipping',
|
||||
@@ -58,6 +76,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/products/index.vue'),
|
||||
meta: { title: '产品与库存' },
|
||||
},
|
||||
{
|
||||
path: 'profit',
|
||||
name: 'Profit',
|
||||
component: () => import('@/views/profit/index.vue'),
|
||||
meta: { title: '利润核算' },
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
name: 'Finance',
|
||||
@@ -116,10 +140,12 @@ router.beforeEach(async (to: any, _from: any, next: any) => {
|
||||
if (!userStore.userInfo) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
await userStore.fetchCompanies()
|
||||
console.log('[Auth Guard] 用户信息已获取:', {
|
||||
username: userStore.username,
|
||||
dataScope: userStore.dataScope,
|
||||
menuKeys: userStore.menuKeys,
|
||||
currentCompanyId: userStore.currentCompanyId,
|
||||
})
|
||||
next({ ...to, replace: true })
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 用户状态管理 (Pinia)
|
||||
* Token 持久化 + /auth/me 获取完整用户上下文(含 data_scope, menu_keys)
|
||||
* 公司视角切换 + localStorage 持久化
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -20,10 +21,20 @@ interface UserInfo {
|
||||
menu_keys: string[]
|
||||
}
|
||||
|
||||
// 公司信息类型
|
||||
interface CompanyInfo {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// ---- State ----
|
||||
const token = ref<string>(localStorage.getItem('crm_token') || '')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const currentCompanyId = ref<string>(localStorage.getItem('crm_company_id') || '')
|
||||
const companies = ref<CompanyInfo[]>([])
|
||||
|
||||
// ---- Getters ----
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
@@ -31,6 +42,14 @@ export const useUserStore = defineStore('user', () => {
|
||||
const realName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '')
|
||||
const dataScope = computed(() => userInfo.value?.data_scope || 'self')
|
||||
const menuKeys = computed(() => userInfo.value?.menu_keys || [])
|
||||
const currentCompanyName = computed(() => {
|
||||
const co = companies.value.find((item: CompanyInfo) => item.id === currentCompanyId.value)
|
||||
return co?.name || '选择公司'
|
||||
})
|
||||
const currentCompanyCode = computed(() => {
|
||||
const co = companies.value.find((item: CompanyInfo) => item.id === currentCompanyId.value)
|
||||
return co?.code || ''
|
||||
})
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
@@ -51,23 +70,58 @@ export const useUserStore = defineStore('user', () => {
|
||||
userInfo.value = data
|
||||
}
|
||||
|
||||
/** 获取公司列表并设置默认 */
|
||||
async function fetchCompanies() {
|
||||
try {
|
||||
const data = await request.get('/api/companies') as any
|
||||
companies.value = data.companies || []
|
||||
const defaultId = data.default_company_id
|
||||
|
||||
// 如果当前没有选中公司,或选中的公司不在列表中,设为默认
|
||||
if (
|
||||
!currentCompanyId.value ||
|
||||
!companies.value.find((item: CompanyInfo) => item.id === currentCompanyId.value)
|
||||
) {
|
||||
const fallbackId = defaultId || (companies.value[0]?.id || '')
|
||||
switchCompany(fallbackId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[fetchCompanies] 获取公司列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换公司视角 */
|
||||
function switchCompany(companyId: string) {
|
||||
currentCompanyId.value = companyId
|
||||
localStorage.setItem('crm_company_id', companyId)
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
currentCompanyId.value = ''
|
||||
companies.value = []
|
||||
localStorage.removeItem('crm_token')
|
||||
localStorage.removeItem('crm_company_id')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
currentCompanyId,
|
||||
companies,
|
||||
isLoggedIn,
|
||||
username,
|
||||
realName,
|
||||
dataScope,
|
||||
menuKeys,
|
||||
currentCompanyName,
|
||||
currentCompanyCode,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
fetchCompanies,
|
||||
switchCompany,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ async function handleLogin() {
|
||||
await userStore.login(form.username, form.password)
|
||||
// 2. 拉取用户信息(data_scope, menu_keys 等)
|
||||
await userStore.fetchUserInfo()
|
||||
// 3. 拉取公司列表并设置默认
|
||||
await userStore.fetchCompanies()
|
||||
|
||||
ElMessage.success(`欢迎回来,${userStore.realName}`)
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合同详情页 —— 含执行进度面板 + 一键推单 + 双签盖章上传
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Upload, ShoppingCart, Document } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contractId = computed(() => route.query.id as string)
|
||||
const loading = ref(false)
|
||||
const contract = ref<any>({})
|
||||
|
||||
const fetchContract = async () => {
|
||||
if (!contractId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/contracts/${contractId.value}`)
|
||||
contract.value = data
|
||||
} catch { /* */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
// --- 一键生成订单 ---
|
||||
const generating = ref(false)
|
||||
const handleGenerateOrder = () => {
|
||||
ElMessageBox.confirm('确定要从此合同一键生成订单吗?', '生成订单', {
|
||||
confirmButtonText: '确定生成', cancelButtonText: '取消', type: 'info',
|
||||
}).then(async () => {
|
||||
generating.value = true
|
||||
try {
|
||||
const result: any = await request.post(`/api/contracts/${contractId.value}/generate-order`)
|
||||
ElMessage.success(`订单生成成功:${result.order_no}`)
|
||||
fetchContract()
|
||||
} catch { /* */ } finally { generating.value = false }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// --- 上传双签盖章版 ---
|
||||
const getUploadHeaders = () => ({
|
||||
Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}`,
|
||||
'X-Company-Id': localStorage.getItem('crm_company_id') || '',
|
||||
})
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('双签盖章版上传成功')
|
||||
fetchContract()
|
||||
} else {
|
||||
ElMessage.error(response.message || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// --- 辅助 ---
|
||||
const getStatusLabel = (s: string) => {
|
||||
const map: Record<string, string> = { draft: '草稿', active: '执行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
// --- 下载合同 Word ---
|
||||
const downloadingDoc = ref(false)
|
||||
const handleDownloadContract = async () => {
|
||||
downloadingDoc.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/contracts/${contractId.value}/generate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}`,
|
||||
'X-Company-Id': localStorage.getItem('crm_company_id') || '',
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `合同_${contract.value.contract_no || contractId.value}.docx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { ElMessage.error('合同文档生成失败') }
|
||||
finally { downloadingDoc.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => fetchContract())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-detail-container" v-loading="loading">
|
||||
<el-page-header @back="router.back()" :title="'返回'" :content="`合同 ${contract.contract_no || ''}`" />
|
||||
|
||||
<div class="detail-grid" v-if="contract.id">
|
||||
<!-- 基本信息卡片 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">合同基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="合同编号">{{ contract.contract_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag>{{ getStatusLabel(contract.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="买方客户">{{ contract.buyer_customer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="卖方公司">{{ contract.seller_company_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="付款条件">{{ contract.payment_terms }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运费条款">{{ contract.shipping_terms }}</el-descriptions-item>
|
||||
<el-descriptions-item label="不含税金额">¥{{ (contract.total_amount_excl_tax || 0).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="含税金额">¥{{ (contract.total_amount_incl_tax || 0).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="大写金额" :span="2">{{ contract.total_amount_cn || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="签约日期">{{ contract.sign_date || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ contract.salesperson_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ contract.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 合同明细 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">合同明细</span></template>
|
||||
<el-table :data="contract.items || []" stripe border size="small">
|
||||
<el-table-column prop="sku_code" label="产品编码" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="200" />
|
||||
<el-table-column prop="spec" label="规格" width="120" />
|
||||
<el-table-column prop="unit" label="单位" width="80" />
|
||||
<el-table-column prop="qty" label="数量" width="100" align="right" />
|
||||
<el-table-column label="单价" width="120" align="right">
|
||||
<template #default="scope">¥{{ (scope.row.unit_price || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="140" align="right">
|
||||
<template #default="scope">¥{{ (scope.row.sub_total || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 执行进度 -->
|
||||
<el-card shadow="never" v-if="contract.progress">
|
||||
<template #header><span style="font-weight: bold;">执行进度</span></template>
|
||||
<el-steps :active="progressStep" finish-status="success" align-center>
|
||||
<el-step title="双签" :description="contract.progress.is_signed ? '已完成' : '待签署'" />
|
||||
<el-step title="生成订单" :description="contract.progress.has_order ? '已生成' : '待生成'" />
|
||||
<el-step title="发货" :description="contract.progress.has_shipped ? '已发货' : '待发货'" />
|
||||
<el-step title="开票" :description="contract.progress.has_invoice ? '已开票' : '待开票'" />
|
||||
<el-step title="回款" :description="contract.progress.is_paid ? '已回款' : '待回款'" />
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">操作</span></template>
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<el-button type="success" :icon="Document" :loading="downloadingDoc" @click="handleDownloadContract">下载合同</el-button>
|
||||
|
||||
<el-button type="primary" :icon="ShoppingCart" :loading="generating"
|
||||
:disabled="!!(contract.linked_order_id)" @click="handleGenerateOrder">
|
||||
{{ contract.linked_order_id ? '已生成订单' : '一键生成订单' }}
|
||||
</el-button>
|
||||
|
||||
<el-upload
|
||||
:action="`/api/contracts/${contractId}/upload-signed`"
|
||||
:headers="getUploadHeaders()"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadSuccess"
|
||||
accept=".pdf,.jpg,.png"
|
||||
>
|
||||
<el-button type="warning" :icon="Upload">上传双签盖章版</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
computed: {
|
||||
progressStep() {
|
||||
const p = (this as any).contract?.progress
|
||||
if (!p) return 0
|
||||
if (p.is_paid) return 5
|
||||
if (p.has_invoice) return 4
|
||||
if (p.has_shipped) return 3
|
||||
if (p.has_order) return 2
|
||||
if (p.is_signed) return 1
|
||||
return 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-detail-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.detail-grid { display: flex; flex-direction: column; gap: 20px; margin-top: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合同管理列表页
|
||||
* GET /api/contracts (分页 + 搜索 + 状态筛选)
|
||||
* POST /api/contracts (新增)
|
||||
* DELETE /api/contracts/{id} (软删除)
|
||||
*/
|
||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus, View, Delete, Document } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// --- 数据 ---
|
||||
const contractData = ref<any[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
|
||||
// --- 搜索/分页 ---
|
||||
const fetchContracts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: currentPage.value, size: pageSize.value }
|
||||
if (searchForm.keyword) params.keyword = searchForm.keyword
|
||||
if (searchForm.status) params.status = searchForm.status
|
||||
const data: any = await request.get('/api/contracts', { params })
|
||||
contractData.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch { /* 统一处理 */ } finally { loading.value = false }
|
||||
}
|
||||
const handleSearch = () => { currentPage.value = 1; fetchContracts() }
|
||||
const handlePageChange = () => fetchContracts()
|
||||
|
||||
// --- 新增合同弹窗 ---
|
||||
const addDialogVisible = ref(false)
|
||||
const addFormRef = ref<FormInstance>()
|
||||
const addSubmitting = ref(false)
|
||||
const skuOptions = ref<any[]>([])
|
||||
const customerOptions = ref<any[]>([])
|
||||
|
||||
const addForm = reactive({
|
||||
buyer_customer_id: '',
|
||||
payment_terms: '货到付全款',
|
||||
shipping_terms: '买方自提',
|
||||
delivery_terms: '',
|
||||
remark: '',
|
||||
items: [{ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 }] as any[],
|
||||
})
|
||||
|
||||
const addFormRules = reactive<FormRules>({
|
||||
buyer_customer_id: [{ required: true, message: '请选择买方客户', trigger: 'change' }],
|
||||
})
|
||||
|
||||
const paymentTermsOptions = [
|
||||
'预付全款订货', '预付30%订货,到货前付清', '预付50%订货,到货前付清',
|
||||
'货到付全款', '开具发票后30天内付款', '开具发票45天付款',
|
||||
'开具发票60天付款', '开具发票90天付款',
|
||||
]
|
||||
const shippingTermsOptions = [
|
||||
'买方自提', '卖方免费送达天津指定地点', '卖方免费送达指定地点', '物流发货,运费买方承担',
|
||||
]
|
||||
|
||||
const addContractItem = () => {
|
||||
addForm.items.push({ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 })
|
||||
}
|
||||
const removeContractItem = (index: number) => {
|
||||
if (addForm.items.length > 1) addForm.items.splice(index, 1)
|
||||
}
|
||||
const calcSubTotal = (item: any) => {
|
||||
item.sub_total = +(item.qty * item.unit_price).toFixed(2)
|
||||
}
|
||||
const contractTotal = computed(() => addForm.items.reduce((s: number, i: any) => s + (i.sub_total || 0), 0))
|
||||
|
||||
const handleSkuChange = (item: any) => {
|
||||
const sku = skuOptions.value.find((s: any) => s.id === item.sku_id)
|
||||
if (sku) {
|
||||
item.unit_price = sku.standard_price || 0
|
||||
calcSubTotal(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddContract = async () => {
|
||||
addForm.buyer_customer_id = ''
|
||||
addForm.payment_terms = '货到付全款'
|
||||
addForm.shipping_terms = '买方自提'
|
||||
addForm.delivery_terms = ''
|
||||
addForm.remark = ''
|
||||
addForm.items = [{ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 }]
|
||||
addDialogVisible.value = true
|
||||
// 拉取客户、SKU 和卖方公司信息
|
||||
try {
|
||||
const [custRes, skuRes, companyRes] = await Promise.all([
|
||||
request.get('/api/customers/search', { params: { q: '%' } }),
|
||||
request.get('/api/products/skus', { params: { page: 1, size: 100 } }),
|
||||
request.get('/api/companies/current'),
|
||||
])
|
||||
customerOptions.value = (custRes as any) || []
|
||||
skuOptions.value = ((skuRes as any)?.items || skuRes) || []
|
||||
sellerCompanyName.value = (companyRes as any)?.full_info?.company_name || (companyRes as any)?.name || '当前公司'
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const sellerCompanyName = ref('当前公司')
|
||||
|
||||
const submitAddContract = async () => {
|
||||
addSubmitting.value = true
|
||||
try {
|
||||
await request.post('/api/contracts', {
|
||||
buyer_customer_id: addForm.buyer_customer_id,
|
||||
payment_terms: addForm.payment_terms,
|
||||
shipping_terms: addForm.shipping_terms,
|
||||
delivery_terms: addForm.delivery_terms || null,
|
||||
remark: addForm.remark,
|
||||
items: addForm.items.filter((i: any) => i.sku_id),
|
||||
})
|
||||
ElMessage.success('合同创建成功')
|
||||
addDialogVisible.value = false
|
||||
fetchContracts()
|
||||
} catch { /* */ } finally { addSubmitting.value = false }
|
||||
}
|
||||
|
||||
// --- 查看详情 ---
|
||||
const viewDetail = (row: any) => {
|
||||
router.push({ path: '/contracts/detail', query: { id: row.id } })
|
||||
}
|
||||
|
||||
// --- 删除 ---
|
||||
const deleteContract = (row: any) => {
|
||||
ElMessageBox.confirm(`确定要删除合同 "${row.contract_no}" 吗?`, '确认删除', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning',
|
||||
}).then(async () => {
|
||||
await request.delete(`/api/contracts/${row.id}`)
|
||||
ElMessage.success('合同已删除')
|
||||
fetchContracts()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// --- 辅助 ---
|
||||
const getStatusType = (s: string) => {
|
||||
const map: Record<string, string> = { draft: 'info', active: '', completed: 'success', cancelled: 'danger' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
const getStatusLabel = (s: string) => {
|
||||
const map: Record<string, string> = { draft: '草稿', active: '执行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
onMounted(() => fetchContracts())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-list-container">
|
||||
<!-- 搜索区 -->
|
||||
<el-card shadow="never" class="filter-section">
|
||||
<div class="filter-wrapper">
|
||||
<el-form :inline="true" :model="searchForm" class="filter-form">
|
||||
<el-form-item label="合同编号">
|
||||
<el-input v-model="searchForm.keyword" placeholder="搜索合同编号" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="执行中" value="active" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="handleAddContract">新增合同</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never" class="table-section">
|
||||
<el-table :data="contractData" stripe border v-loading="loading">
|
||||
<el-table-column prop="contract_no" label="合同编号" width="200" />
|
||||
<el-table-column prop="buyer_customer_name" label="买方客户" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="seller_company_name" label="卖方公司" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="合同金额" width="140" align="right">
|
||||
<template #default="scope">
|
||||
¥{{ (scope.row.total_amount_incl_tax || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payment_terms" label="付款条件" width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)" effect="light">{{ getStatusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="双签" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_signed" type="success" effect="plain">已签</el-tag>
|
||||
<el-tag v-else type="info" effect="plain">未签</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link :icon="View" @click="viewDetail(scope.row)">详情</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="deleteContract(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-section">
|
||||
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]"
|
||||
background layout="total, prev, pager, next, jumper" :total="total"
|
||||
@current-change="handlePageChange" @size-change="handlePageChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增合同弹窗 -->
|
||||
<el-dialog v-model="addDialogVisible" title="新增合同" width="780px" destroy-on-close>
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
|
||||
<el-form-item label="买方客户" prop="buyer_customer_id">
|
||||
<el-select v-model="addForm.buyer_customer_id" placeholder="请选择客户" filterable style="width: 100%">
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="卖方公司">
|
||||
<el-input :model-value="sellerCompanyName" disabled />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">卖方信息由系统设置中的企业账号配置自动填充</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="付款条件">
|
||||
<el-select v-model="addForm.payment_terms" style="width: 100%">
|
||||
<el-option v-for="t in paymentTermsOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费条款">
|
||||
<el-select v-model="addForm.shipping_terms" style="width: 100%">
|
||||
<el-option v-for="t in shippingTermsOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="货期">
|
||||
<el-input v-model="addForm.delivery_terms" placeholder="如:下单后15个工作日发货" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="addForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">合同明细</el-divider>
|
||||
<div v-for="(item, idx) in addForm.items" :key="idx" style="display: flex; gap: 8px; margin-bottom: 12px; align-items: center;">
|
||||
<el-select v-model="item.sku_id" placeholder="选择产品" filterable style="flex: 2" @change="handleSkuChange(item)">
|
||||
<el-option v-for="s in skuOptions" :key="s.id" :label="`${s.name} (${s.sku_code})`" :value="s.id" />
|
||||
</el-select>
|
||||
<el-input-number v-model="item.qty" :min="0.01" :precision="2" style="flex: 1" @change="calcSubTotal(item)" />
|
||||
<el-input-number v-model="item.unit_price" :min="0" :precision="2" style="flex: 1" @change="calcSubTotal(item)" />
|
||||
<span style="min-width: 80px; text-align: right;">¥{{ item.sub_total.toFixed(2) }}</span>
|
||||
<el-button type="danger" link @click="removeContractItem(idx)" :disabled="addForm.items.length <= 1">删除</el-button>
|
||||
</div>
|
||||
<el-button type="primary" link @click="addContractItem">+ 添加明细行</el-button>
|
||||
<div style="margin-top: 12px; text-align: right; font-weight: bold;">合计:¥{{ contractTotal.toFixed(2) }}</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addSubmitting" @click="submitAddContract">确定提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contract-list-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.filter-section { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
|
||||
.filter-wrapper { display: flex; justify-content: space-between; flex-wrap: wrap; align-items: flex-start; }
|
||||
.filter-form .el-form-item { margin-bottom: 0; margin-right: 20px; }
|
||||
.action-buttons { display: flex; gap: 10px; }
|
||||
.table-section { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
|
||||
.pagination-section { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -43,6 +43,7 @@ const customer = reactive({
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
ai_persona: null as any,
|
||||
billing_info: null as any,
|
||||
})
|
||||
|
||||
const fetchCustomer = async () => {
|
||||
@@ -148,6 +149,67 @@ const fetchProducts = async (customerId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 开票信息管理 (Billing Info) ---
|
||||
const billingDialogVisible = ref(false)
|
||||
const billingSubmitting = ref(false)
|
||||
const billingFormRef = ref<FormInstance>()
|
||||
const billingForm = reactive({
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const openBillingDialog = () => {
|
||||
if (customer.billing_info) {
|
||||
Object.assign(billingForm, {
|
||||
company_name: customer.billing_info.company_name || '',
|
||||
tax_id: customer.billing_info.tax_id || '',
|
||||
bank_name: customer.billing_info.bank_name || '',
|
||||
bank_account: customer.billing_info.bank_account || '',
|
||||
address: customer.billing_info.address || '',
|
||||
phone: customer.billing_info.phone || '',
|
||||
})
|
||||
} else {
|
||||
Object.assign(billingForm, {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
})
|
||||
}
|
||||
billingDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitBilling = async () => {
|
||||
if (!billingFormRef.value) return
|
||||
billingSubmitting.value = true
|
||||
try {
|
||||
await request.put(`/api/customers/${customer.id}`, { billing_info: billingForm })
|
||||
ElMessage.success('开票信息已保存')
|
||||
billingDialogVisible.value = false
|
||||
fetchCustomer()
|
||||
} catch {
|
||||
} finally {
|
||||
billingSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBilling = () => {
|
||||
ElMessageBox.confirm('确定要删除此客户的开票信息吗?', '警告', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await request.put(`/api/customers/${customer.id}`, { billing_info: null })
|
||||
ElMessage.success('开票信息已删除')
|
||||
fetchCustomer()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// --- 级别显示映射 ---
|
||||
const levelMap: Record<string, { text: string; type: string }> = {
|
||||
A: { text: 'A级 · 核心客户', type: 'danger' },
|
||||
@@ -227,8 +289,40 @@ onMounted(fetchCustomer)
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 1.5 开票信息卡片 -->
|
||||
<el-card shadow="never" class="profile-header mt-20" v-if="customer.id && customer.billing_info && Object.keys(customer.billing_info).length > 0">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: bold; color: #303133;">🏦 开票信息 (Billing Info)</span>
|
||||
<div>
|
||||
<el-button type="primary" link @click="openBillingDialog">编辑</el-button>
|
||||
<el-button type="danger" link @click="deleteBilling">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" class="desc-box" border>
|
||||
<el-descriptions-item label="企业全称">{{ customer.billing_info.company_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="纳税人识别号">{{ customer.billing_info.tax_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开户银行">{{ customer.billing_info.bank_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="银行账号">{{ customer.billing_info.bank_account || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票地址" :span="2">{{ customer.billing_info.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票电话" :span="2">{{ customer.billing_info.phone || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="profile-header mt-20" v-else-if="customer.id">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: bold; color: #303133;">🏦 开票信息 (Billing Info)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="暂无开票信息" :image-size="60">
|
||||
<el-button type="primary" @click="openBillingDialog">新增开票信息</el-button>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
|
||||
<!-- 2. Tabs 面板 -->
|
||||
<el-card shadow="never" class="tabs-card" v-if="customer.id">
|
||||
<el-card shadow="never" class="tabs-card mt-20" v-if="customer.id">
|
||||
<el-tabs>
|
||||
<el-tab-pane label="AI 企业画像" name="persona">
|
||||
<div v-if="customer.ai_persona && Object.keys(customer.ai_persona).length > 0" class="persona-container">
|
||||
@@ -380,6 +474,34 @@ onMounted(fetchCustomer)
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 开票信息表单弹窗 -->
|
||||
<el-dialog v-model="billingDialogVisible" title="编辑开票信息" width="500px" destroy-on-close>
|
||||
<el-form ref="billingFormRef" :model="billingForm" label-width="110px">
|
||||
<el-form-item label="企业全称">
|
||||
<el-input v-model="billingForm.company_name" placeholder="请输入开票抬头" />
|
||||
</el-form-item>
|
||||
<el-form-item label="纳税人识别号">
|
||||
<el-input v-model="billingForm.tax_id" placeholder="请输入统一社会信用代码/税号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户银行">
|
||||
<el-input v-model="billingForm.bank_name" placeholder="请输入开户行名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="billingForm.bank_account" placeholder="请输入银行账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票地址">
|
||||
<el-input v-model="billingForm.address" placeholder="请输入注册地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业电话">
|
||||
<el-input v-model="billingForm.phone" placeholder="请输入企业电话" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="billingDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="billingSubmitting" @click="submitBilling">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 悬浮按钮 - 写新日志 -->
|
||||
<el-tooltip content="写新跟进记录" placement="left">
|
||||
<el-button v-if="customer.id" type="primary" circle size="large" class="floating-btn" @click="handleAddLog" :icon="CirclePlus" />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus, View, Edit, Box, Upload, Download } from '@element-plus/icons-vue'
|
||||
import { Search, Plus, View, Edit, Box, Upload, Download, Sort } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@@ -77,13 +77,21 @@ const addForm = reactive({
|
||||
phone: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
billing_info: {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
},
|
||||
})
|
||||
const addFormRules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
const handleAddCustomer = () => {
|
||||
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '' })
|
||||
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '', billing_info: { company_name: '', tax_id: '', address: '', phone: '', bank_name: '', bank_account: '' } })
|
||||
addDialogVisible.value = true
|
||||
nextTick(() => addFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -117,12 +125,21 @@ const editForm = reactive({
|
||||
phone: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
billing_info: {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
},
|
||||
})
|
||||
const editFormRules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
const editCustomer = (row: any) => {
|
||||
const bi = row.billing_info || {}
|
||||
Object.assign(editForm, {
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
@@ -132,6 +149,14 @@ const editCustomer = (row: any) => {
|
||||
phone: row.phone || '',
|
||||
address: row.address || '',
|
||||
remark: row.remark || '',
|
||||
billing_info: {
|
||||
company_name: bi.company_name || '',
|
||||
tax_id: bi.tax_id || '',
|
||||
address: bi.address || '',
|
||||
phone: bi.phone || '',
|
||||
bank_name: bi.bank_name || '',
|
||||
bank_account: bi.bank_account || '',
|
||||
},
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
nextTick(() => editFormRef.value?.clearValidate())
|
||||
@@ -247,6 +272,46 @@ const handleExport = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 客户转移 ---
|
||||
const transferDialogVisible = ref(false)
|
||||
const transferForm = reactive({ customerId: '', customerName: '', newOwnerId: '' })
|
||||
const transferSubmitting = ref(false)
|
||||
const userOptions = ref<any[]>([])
|
||||
|
||||
const openTransferDialog = async (row: any) => {
|
||||
transferForm.customerId = row.id
|
||||
transferForm.customerName = row.name
|
||||
transferForm.newOwnerId = ''
|
||||
transferDialogVisible.value = true
|
||||
// 拉取员工列表
|
||||
try {
|
||||
const data: any = await request.get('/api/settings/users', { params: { page: 1, size: 100 } })
|
||||
userOptions.value = (data.items || []).filter((u: any) => u.status === 1)
|
||||
} catch {
|
||||
ElMessage.error('获取员工列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitTransfer = async () => {
|
||||
if (!transferForm.newOwnerId) {
|
||||
ElMessage.warning('请选择目标负责人')
|
||||
return
|
||||
}
|
||||
transferSubmitting.value = true
|
||||
try {
|
||||
await request.put(`/api/customers/${transferForm.customerId}/transfer`, {
|
||||
new_owner_id: transferForm.newOwnerId,
|
||||
})
|
||||
ElMessage.success('客户转移成功')
|
||||
transferDialogVisible.value = false
|
||||
fetchCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '转移失败')
|
||||
} finally {
|
||||
transferSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 初始化 ---
|
||||
onMounted(() => {
|
||||
fetchCustomers()
|
||||
@@ -308,11 +373,12 @@ onMounted(() => {
|
||||
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
|
||||
<template v-if="!scope.row.is_deleted">
|
||||
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" type="warning" link :icon="Sort" @click="openTransferDialog(scope.row)">转移</el-button>
|
||||
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
|
||||
</template>
|
||||
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
|
||||
@@ -363,6 +429,38 @@ onMounted(() => {
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="addForm.remark" type="textarea" :rows="2" placeholder="备注" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">客户开票信息(选填)</el-divider>
|
||||
<el-form-item label="开票公司">
|
||||
<el-input v-model="addForm.billing_info.company_name" placeholder="开票公司全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号">
|
||||
<el-input v-model="addForm.billing_info.tax_id" placeholder="纳税人识别号" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="addForm.billing_info.address" placeholder="地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="addForm.billing_info.phone" placeholder="电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开户行">
|
||||
<el-input v-model="addForm.billing_info.bank_name" placeholder="开户行" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="addForm.billing_info.bank_account" placeholder="银行账号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
@@ -398,6 +496,38 @@ onMounted(() => {
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">客户开票信息(选填)</el-divider>
|
||||
<el-form-item label="开票公司">
|
||||
<el-input v-model="editForm.billing_info.company_name" placeholder="开票公司全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号">
|
||||
<el-input v-model="editForm.billing_info.tax_id" placeholder="纳税人识别号" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="editForm.billing_info.address" placeholder="地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="editForm.billing_info.phone" placeholder="电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开户行">
|
||||
<el-input v-model="editForm.billing_info.bank_name" placeholder="开户行" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="editForm.billing_info.bank_account" placeholder="银行账号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
@@ -429,6 +559,29 @@ onMounted(() => {
|
||||
<div class="el-upload__text">将文件拖到此处,或 <em>点击上传</em></div>
|
||||
</el-upload>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 7. 客户转移弹窗 -->
|
||||
<el-dialog v-model="transferDialogVisible" title="客户转移" width="440px" destroy-on-close>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="客户名称">
|
||||
<el-input :value="transferForm.customerName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标负责人" required>
|
||||
<el-select v-model="transferForm.newOwnerId" placeholder="请选择负责人" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
:label="`${u.real_name} (${u.username})`"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="transferSubmitting" @click="submitTransfer">确认转移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -92,11 +92,13 @@ const handleGenerate = async () => {
|
||||
|
||||
try {
|
||||
const token = userStore.token || localStorage.getItem('crm_token') || ''
|
||||
const companyId = userStore.currentCompanyId || localStorage.getItem('crm_company_id') || ''
|
||||
const res = await fetch('/api/reports/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-Company-Id': companyId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_date: dateRange.value[0],
|
||||
|
||||
@@ -134,11 +134,65 @@ interface OcrQueueItem {
|
||||
}
|
||||
const ocrQueue = ref<OcrQueueItem[]>([])
|
||||
|
||||
// --- 待确认列表(方案A:AI预填 + 人工审核) ---
|
||||
interface PendingInvoice {
|
||||
issuer: string
|
||||
receiver_customer_id: string
|
||||
invoice_number: string
|
||||
amount: number
|
||||
billing_date: string
|
||||
remark: string
|
||||
_buyer_text: string // OCR 识别的购买方原文(辅助展示)
|
||||
_complete: boolean // 数据是否完整
|
||||
}
|
||||
const pendingInvoices = ref<PendingInvoice[]>([])
|
||||
const pendingSubmitting = ref(false)
|
||||
|
||||
const submitAllPending = async () => {
|
||||
const valid = pendingInvoices.value.filter(p => p.issuer && p.invoice_number && p.amount > 0 && p.receiver_customer_id)
|
||||
if (!valid.length) {
|
||||
ElMessage.warning('没有可提交的完整发票,请补全必填字段')
|
||||
return
|
||||
}
|
||||
pendingSubmitting.value = true
|
||||
let ok = 0
|
||||
for (const inv of valid) {
|
||||
try {
|
||||
await request.post('/api/finance/sales-invoices', {
|
||||
issuer: inv.issuer,
|
||||
receiver_customer_id: inv.receiver_customer_id,
|
||||
invoice_number: inv.invoice_number,
|
||||
amount: inv.amount,
|
||||
billing_date: inv.billing_date,
|
||||
remark: inv.remark || 'AI批量导入',
|
||||
})
|
||||
ok++
|
||||
} catch {}
|
||||
}
|
||||
pendingSubmitting.value = false
|
||||
ElMessage.success(`批量创建完成:${ok}/${valid.length} 成功`)
|
||||
// 移除已成功的,保留失败的
|
||||
pendingInvoices.value = pendingInvoices.value.filter(p => !(p.issuer && p.invoice_number && p.amount > 0 && p.receiver_customer_id))
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const removePending = (idx: number) => { pendingInvoices.value.splice(idx, 1) }
|
||||
const clearPending = () => { pendingInvoices.value = [] }
|
||||
|
||||
// 为待确认列表行搜索客户
|
||||
const searchCustomerForPending = async (query: string, row: PendingInvoice) => {
|
||||
if (!query) return
|
||||
await handleCustomerSearch(query)
|
||||
if (customerOptions.value.length > 0) {
|
||||
row.receiver_customer_id = customerOptions.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
|
||||
if (!rawFiles.length) return
|
||||
|
||||
// 单文件:填入表单(旧逻辑)
|
||||
// 单文件:填入表单
|
||||
if (rawFiles.length === 1) {
|
||||
const file = rawFiles[0]
|
||||
uploadProcessing.value = true
|
||||
@@ -164,7 +218,7 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
addForm.invoice_number = aiData.invoice_num || aiData.invoice_number || addForm.invoice_number
|
||||
addForm.amount = parseFloat(aiData.amount) || addForm.amount
|
||||
addForm.billing_date = aiData.date || addForm.billing_date
|
||||
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
const buyerName = (aiData.buyer || aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
if (buyerName) {
|
||||
await handleCustomerSearch(buyerName)
|
||||
if (customerOptions.value.length > 0) {
|
||||
@@ -185,7 +239,7 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 多文件:批量处理,逐个 OCR + 自动创建
|
||||
// 多文件:批量 OCR → 暂存到待确认列表
|
||||
ocrQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
|
||||
uploadProcessing.value = true
|
||||
uploadAbortController = new AbortController()
|
||||
@@ -218,7 +272,7 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
const invoiceNumber = aiData.invoice_num || aiData.invoice_number || ''
|
||||
const amount = parseFloat(aiData.amount) || 0
|
||||
const billingDate = aiData.date || new Date().toISOString().split('T')[0]
|
||||
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
const buyerName = (aiData.buyer || aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
|
||||
// 自动查找客户
|
||||
let customerId = ''
|
||||
@@ -227,17 +281,22 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
if (customerOptions.value.length > 0) customerId = customerOptions.value[0].id
|
||||
}
|
||||
|
||||
if (issuer && invoiceNumber && amount > 0 && customerId) {
|
||||
await request.post('/api/finance/sales-invoices', {
|
||||
issuer, receiver_customer_id: customerId, invoice_number: invoiceNumber,
|
||||
amount, billing_date: billingDate, remark: 'AI批量导入',
|
||||
const isComplete = !!(issuer && invoiceNumber && amount > 0 && customerId)
|
||||
pendingInvoices.value.push({
|
||||
issuer,
|
||||
receiver_customer_id: customerId,
|
||||
invoice_number: invoiceNumber,
|
||||
amount,
|
||||
billing_date: billingDate,
|
||||
remark: '',
|
||||
_buyer_text: buyerName,
|
||||
_complete: isComplete,
|
||||
})
|
||||
ocrQueue.value[i].status = 'success'
|
||||
ocrQueue.value[i].message = `✅ ${invoiceNumber}`
|
||||
} else {
|
||||
ocrQueue.value[i].status = 'error'
|
||||
ocrQueue.value[i].message = '⚠️ 数据不完整,请手动创建'
|
||||
}
|
||||
|
||||
ocrQueue.value[i].status = isComplete ? 'success' : 'error'
|
||||
ocrQueue.value[i].message = isComplete
|
||||
? `✅ ${invoiceNumber || file.name}`
|
||||
: `⚠️ 待补全 → 已加入确认列表`
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') break
|
||||
ocrQueue.value[i].status = 'error'
|
||||
@@ -248,11 +307,10 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
uploadProcessing.value = false
|
||||
uploadAbortController = null
|
||||
if (!signal.aborted) {
|
||||
const ok = ocrQueue.value.filter(q => q.status === 'success').length
|
||||
ElMessage.success(`批量处理完成:${ok}/${rawFiles.length} 自动创建成功`)
|
||||
handleSearch()
|
||||
const complete = pendingInvoices.value.filter(p => p._complete).length
|
||||
ElMessage.success(`AI 解析完成:${complete}/${rawFiles.length} 数据完整,请在下方确认列表中审核后提交`)
|
||||
}
|
||||
setTimeout(() => { ocrQueue.value = [] }, 5000)
|
||||
// 不自动清空队列,由用户手动关闭
|
||||
}
|
||||
|
||||
// --- Dialog 关闭保护 ---
|
||||
@@ -436,13 +494,13 @@ onMounted(fetchList)
|
||||
</el-card>
|
||||
|
||||
<!-- 新增发票弹窗 -->
|
||||
<el-dialog v-model="addDialogVisible" title="发票智能录入" width="600px" destroy-on-close :before-close="handleDialogClose">
|
||||
<el-dialog v-model="addDialogVisible" title="发票智能录入" width="960px" destroy-on-close :before-close="handleDialogClose">
|
||||
<div style="margin-bottom: 20px" v-loading="uploadProcessing || aiParsing" :element-loading-text="uploadProcessing ? '文件上传中...' : '🤖 AI 正在识别发票表单字段...'">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.md,.ofd,.xml"
|
||||
multiple
|
||||
@change="handleOCRUpload"
|
||||
>
|
||||
@@ -450,10 +508,14 @@ onMounted(fetchList)
|
||||
<div class="el-upload__text">
|
||||
将发票原件(PDF/图片)拖到此处,或 <em>点击上传进行AI填单</em>
|
||||
</div>
|
||||
<div class="el-upload__tip" style="font-size:12px;color:#909399">支持 PDF/图片/MD 文件。单文件自动填入表单,多文件自动批量创建发票</div>
|
||||
<div class="el-upload__tip" style="font-size:12px;color:#909399">支持 PDF / 图片 / MD / OFD / XML 文件。单文件自动填入表单,多文件自动批量创建发票</div>
|
||||
</el-upload>
|
||||
<!-- 批量处理队列 -->
|
||||
<div v-if="ocrQueue.length" style="margin-top: 10px">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px">
|
||||
<span style="font-size:13px; color:#606266; font-weight:500">📋 解析进度</span>
|
||||
<el-button size="small" link @click="ocrQueue = []">清空</el-button>
|
||||
</div>
|
||||
<div v-for="(item, idx) in ocrQueue" :key="idx" style="display:flex; justify-content:space-between; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px">
|
||||
<span style="color:#606266; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px">{{ item.name }}</span>
|
||||
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
|
||||
@@ -462,6 +524,67 @@ onMounted(fetchList)
|
||||
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待确认列表(方案A:AI预填 + 人工审核) -->
|
||||
<div v-if="pendingInvoices.length" style="margin-top: 16px">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<span style="font-size:14px; font-weight:600; color:#303133">🛒 待确认发票({{ pendingInvoices.length }}条)</span>
|
||||
<div>
|
||||
<el-button size="small" @click="clearPending">清空</el-button>
|
||||
<el-button type="primary" size="small" :loading="pendingSubmitting" @click="submitAllPending">
|
||||
✅ 一键全部创建
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="pendingInvoices" border stripe size="small" max-height="300px" style="width:100%">
|
||||
<el-table-column label="开票方" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.issuer" size="small" placeholder="开票方"
|
||||
:class="{ 'field-warning': !row.issuer }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发票号" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.invoice_number" size="small" placeholder="发票号"
|
||||
:class="{ 'field-warning': !row.invoice_number }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.amount" :min="0" :precision="2" size="small" style="width:85px"
|
||||
:class="{ 'field-warning': !row.amount || row.amount <= 0 }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.billing_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="受票客户" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.receiver_customer_id"
|
||||
filterable remote clearable reserve-keyword
|
||||
:placeholder="row._buyer_text || '搜索客户'"
|
||||
:remote-method="(q: string) => handleCustomerSearch(q)"
|
||||
:loading="customerSearchLoading"
|
||||
size="small" style="width:100%"
|
||||
:class="{ 'field-warning': !row.receiver_customer_id }"
|
||||
>
|
||||
<el-option v-for="item in customerOptions" :key="item.id" :label="item.name" :value="item.id">
|
||||
<span>{{ item.name }}</span>
|
||||
<span style="color: #999; font-size: 11px; margin-left: 6px">{{ item.level }}级</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="40">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link size="small" @click="removePending($index)">✕</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
|
||||
@@ -558,4 +681,10 @@ onMounted(fetchList)
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
:deep(.field-warning .el-input__wrapper),
|
||||
:deep(.field-warning .el-input-number .el-input__wrapper),
|
||||
:deep(.field-warning .el-select .el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px #e6a23c inset !important;
|
||||
background: #fdf6ec !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Tab 2: 购物车式新建报销(AI 一键生成草稿)
|
||||
* Tab 3: 报销大盘与审批(A4 打印 + Excel 导出 + 审批态只读)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { Search, Plus, Delete, View, Check, Close, RefreshLeft, Download, Printer, Link } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
|
||||
@@ -43,89 +43,142 @@ const fetchInvoices = async () => {
|
||||
|
||||
watch(invCategory, () => { invPage.value = 1; fetchInvoices() })
|
||||
|
||||
// ── 拖拽上传 + AI 解析链路(批量队列) ──
|
||||
// ── 拖拽上传 + 分流(XML/ZIP即时入池, 图片PDF入队列) ──
|
||||
const uploadProcessing = ref(false)
|
||||
const aiParsing = ref(false)
|
||||
|
||||
interface QueueItem {
|
||||
name: string
|
||||
status: 'waiting' | 'processing' | 'success' | 'error'
|
||||
status: 'waiting' | 'processing' | 'success' | 'error' | 'queued'
|
||||
message: string
|
||||
taskId?: string
|
||||
}
|
||||
const uploadQueue = ref<QueueItem[]>([])
|
||||
let uploadAbortController: AbortController | null = null
|
||||
|
||||
const handleUploadChange = async (_file: UploadFile, fileList: any[]) => {
|
||||
// 收集所有新增文件的 raw
|
||||
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
|
||||
if (!rawFiles.length) return
|
||||
|
||||
// 初始化队列
|
||||
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
|
||||
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'processing' as const, message: '上传中...' }))
|
||||
uploadProcessing.value = true
|
||||
uploadAbortController = new AbortController()
|
||||
const signal = uploadAbortController.signal
|
||||
|
||||
for (let i = 0; i < rawFiles.length; i++) {
|
||||
if (signal.aborted) break
|
||||
|
||||
const file = rawFiles[i]
|
||||
uploadQueue.value[i].status = 'processing'
|
||||
uploadQueue.value[i].message = '🤖 AI 正在解析...'
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
for (const file of rawFiles) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
formData.append('scene', 'invoice')
|
||||
formData.append('inv_type', invCategory.value)
|
||||
|
||||
const response = await fetch('/api/finance/ocr', {
|
||||
const response = await fetch('/api/finance/upload-batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${userStore.token}` },
|
||||
headers: { 'Authorization': `Bearer ${userStore.token}`, 'X-Company-Id': userStore.currentCompanyId || '' },
|
||||
body: formData,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const result = await response.json()
|
||||
if (result.code !== 200) throw new Error(result.message || 'OCR 识别失败')
|
||||
if (result.code !== 200) throw new Error(result.message || '上传失败')
|
||||
|
||||
const ocrResult = result.data || {}
|
||||
const aiData = ocrResult.ocr_data || {}
|
||||
const fileUrl = ocrResult.file_url || `/uploads/${file.name}`
|
||||
const batchResults = result.data?.results || []
|
||||
uploadQueue.value = batchResults.map((r: any) => ({
|
||||
name: r.filename || '',
|
||||
status: r.action === 'pooled' ? 'success' : r.action === 'queued' ? 'queued' : 'error',
|
||||
message: r.message || '',
|
||||
taskId: r.task_id,
|
||||
}))
|
||||
|
||||
const ocrSuccess = !!(aiData.merchant || aiData.merchant_name || aiData.amount)
|
||||
const merchant = aiData.merchant || aiData.merchant_name || '(AI 未识别)'
|
||||
const amount = parseFloat(aiData.amount) || 0
|
||||
const invoiceDate = aiData.date || new Date().toISOString().slice(0, 10)
|
||||
|
||||
await request.post('/api/finance/invoices', {
|
||||
merchant_name: merchant, amount, invoice_date: invoiceDate,
|
||||
type: invCategory.value, file_url: fileUrl, ai_extracted_data: aiData,
|
||||
})
|
||||
|
||||
uploadQueue.value[i].status = 'success'
|
||||
uploadQueue.value[i].message = ocrSuccess
|
||||
? `✅ ${merchant} ¥${amount}`
|
||||
: '⚠️ 已上传,AI未能识别'
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') break
|
||||
uploadQueue.value[i].status = 'error'
|
||||
uploadQueue.value[i].message = `❌ ${e.message || '处理失败'}`
|
||||
}
|
||||
}
|
||||
|
||||
uploadProcessing.value = false
|
||||
uploadAbortController = null
|
||||
if (!signal.aborted) {
|
||||
const successCount = uploadQueue.value.filter(q => q.status === 'success').length
|
||||
ElMessage.success(`批量处理完成:${successCount}/${rawFiles.length} 成功`)
|
||||
ElMessage.success(result.message || '批量处理完成')
|
||||
fetchInvoices()
|
||||
if (batchResults.some((r: any) => r.action === 'queued')) {
|
||||
fetchOcrTasks()
|
||||
}
|
||||
} catch (e: any) {
|
||||
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'error' as const, message: `❌ ${e.message}` }))
|
||||
ElMessage.error(e.message || '上传失败')
|
||||
} finally {
|
||||
uploadProcessing.value = false
|
||||
}
|
||||
|
||||
// 3秒后清空队列
|
||||
setTimeout(() => { uploadQueue.value = [] }, 3000)
|
||||
}
|
||||
|
||||
// ── OCR 任务队列 ──
|
||||
const ocrTasksLoading = ref(false)
|
||||
const ocrTasks = ref<any[]>([])
|
||||
const ocrTasksTotal = ref(0)
|
||||
const ocrTasksPage = ref(1)
|
||||
const ocrTasksStatusFilter = ref('')
|
||||
|
||||
const fetchOcrTasks = async () => {
|
||||
ocrTasksLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: ocrTasksPage.value, size: 20 }
|
||||
if (ocrTasksStatusFilter.value) params.status = ocrTasksStatusFilter.value
|
||||
const data: any = await request.get('/api/finance/ocr-tasks', { params })
|
||||
ocrTasks.value = data?.items || []
|
||||
ocrTasksTotal.value = data?.total || 0
|
||||
} catch {}
|
||||
finally { ocrTasksLoading.value = false }
|
||||
}
|
||||
|
||||
const retryOcrTask = async (taskId: string) => {
|
||||
try {
|
||||
await request.post(`/api/finance/ocr-tasks/${taskId}/retry`)
|
||||
ElMessage.success('任务已重新入队')
|
||||
fetchOcrTasks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const deleteOcrTask = async (taskId: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定取消此任务?', '确认')
|
||||
} catch { return }
|
||||
try {
|
||||
await request.delete(`/api/finance/ocr-tasks/${taskId}`)
|
||||
ElMessage.success('任务已取消')
|
||||
fetchOcrTasks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const changePriority = async (taskId: string, delta: number) => {
|
||||
const task = ocrTasks.value.find((t: any) => t.id === taskId)
|
||||
if (!task) return
|
||||
try {
|
||||
await request.put(`/api/finance/ocr-tasks/${taskId}/priority`, { priority: Math.max(1, task.priority + delta) })
|
||||
fetchOcrTasks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 手动录入弹窗(for OCR failed tasks)
|
||||
const manualDialogVisible = ref(false)
|
||||
const manualTaskId = ref('')
|
||||
const manualForm = reactive({ merchant_name: '', amount: 0, invoice_date: '' })
|
||||
|
||||
const openManualInput = (task: any) => {
|
||||
manualTaskId.value = task.id
|
||||
const ocr = task.ocr_result || {}
|
||||
manualForm.merchant_name = ocr.merchant || ocr.merchant_name || ''
|
||||
manualForm.amount = parseFloat(ocr.amount) || 0
|
||||
manualForm.invoice_date = ocr.date || ''
|
||||
manualDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitManualInput = async () => {
|
||||
if (!manualForm.merchant_name.trim()) { ElMessage.warning('请填写开票方'); return }
|
||||
try {
|
||||
await request.post(`/api/finance/ocr-tasks/${manualTaskId.value}/manual`, {
|
||||
merchant_name: manualForm.merchant_name,
|
||||
amount: manualForm.amount,
|
||||
invoice_date: manualForm.invoice_date || null,
|
||||
})
|
||||
ElMessage.success('手动录入成功,发票已入池')
|
||||
manualDialogVisible.value = false
|
||||
fetchOcrTasks()
|
||||
fetchInvoices()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const ocrStatusLabel = (s: string) => ({ pending: '⏳ 待处理', processing: '⚙️ 处理中', success: '✅ 已完成', failed: '❌ 失败', manual: '✏️ 手动' }[s] || s)
|
||||
const ocrStatusType = (s: string) => ({ pending: 'info', processing: 'warning', success: 'success', failed: 'danger', manual: '' }[s] || 'info')
|
||||
|
||||
// 手动录入弹窗
|
||||
const invDialogVisible = ref(false)
|
||||
const invSubmitting = ref(false)
|
||||
@@ -456,12 +509,10 @@ const exportExcel = async () => {
|
||||
const formatCurrency = (v: number) => `¥${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
|
||||
|
||||
onMounted(() => { fetchInvoices() })
|
||||
onBeforeUnmount(() => {
|
||||
if (uploadAbortController) { uploadAbortController.abort(); uploadAbortController = null }
|
||||
})
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'create') fetchAvailableInvoices()
|
||||
if (tab === 'expenses') fetchExpenses()
|
||||
if (tab === 'ocr-queue') fetchOcrTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -477,21 +528,21 @@ watch(activeTab, (tab) => {
|
||||
|
||||
<!-- 拖拽上传区(支持多文件) -->
|
||||
<div class="upload-zone">
|
||||
<el-upload drag :auto-upload="false" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png"
|
||||
<el-upload drag :auto-upload="false" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png,.md,.ofd,.xml,.zip"
|
||||
multiple @change="handleUploadChange">
|
||||
<div class="upload-inner">
|
||||
<el-icon style="font-size:40px; color:#409eff"><Plus /></el-icon>
|
||||
<div class="upload-text">拖拽发票文件到此处,或 <em>点击上传</em></div>
|
||||
<div class="upload-hint">支持 PDF / JPG / PNG / MD,可一次选择多个文件批量上传,AI 自动排队解析</div>
|
||||
<div class="upload-hint">支持 ZIP / XML / OFD(即时入池) | PDF / JPG / PNG(后台排队 OCR)</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<!-- 批量上传队列状态 -->
|
||||
<!-- 批量上传结果状态 -->
|
||||
<div v-if="uploadQueue.length" class="upload-queue">
|
||||
<div v-for="(item, idx) in uploadQueue" :key="idx" class="queue-item">
|
||||
<span class="queue-name">{{ item.name }}</span>
|
||||
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
|
||||
<el-tag v-else-if="item.status === 'processing'" size="small" type="warning" effect="dark">处理中...</el-tag>
|
||||
<el-tag v-if="item.status === 'processing'" size="small" type="warning" effect="dark">上传中...</el-tag>
|
||||
<el-tag v-else-if="item.status === 'success'" size="small" type="success" effect="dark">{{ item.message }}</el-tag>
|
||||
<el-tag v-else-if="item.status === 'queued'" size="small" type="primary" effect="plain">{{ item.message }}</el-tag>
|
||||
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,6 +599,97 @@ watch(activeTab, (tab) => {
|
||||
@size-change="(s: number) => { invSize = s; invPage = 1; fetchInvoices() }" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══════ Tab 1.5: OCR 处理队列 ═══════ -->
|
||||
<el-tab-pane name="ocr-queue">
|
||||
<template #label>
|
||||
<span>📋 OCR 队列
|
||||
<el-badge v-if="ocrTasks.filter((t: any) => t.status === 'pending' || t.status === 'processing').length"
|
||||
:value="ocrTasks.filter((t: any) => t.status === 'pending' || t.status === 'processing').length"
|
||||
type="warning" style="margin-left:4px" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="tab-toolbar">
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="ocrTasksStatusFilter" clearable placeholder="全部" style="width:130px"
|
||||
@change="() => { ocrTasksPage = 1; fetchOcrTasks() }">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已完成" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="手动" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="fetchOcrTasks">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" :closable="false" show-icon style="flex:1; margin-left:16px">
|
||||
<template #title>处理策略:工作时间限流(1并发+60s间隔),17:00-20:00 全速处理</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<el-table :data="ocrTasks" v-loading="ocrTasksLoading" stripe border style="width:100%" height="calc(100vh - 380px)">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="original_name" label="文件名" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-family: Consolas, monospace;">{{ row.original_name }}</span>
|
||||
<el-tag size="small" effect="plain" style="margin-left:6px">{{ row.file_ext }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(ocrStatusType(row.status) as any)" effect="dark" size="small">{{ ocrStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优先级" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:bold">{{ row.priority }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重试" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.retry_count }}/{{ row.max_retries }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error_message" label="错误信息" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error_message" style="color:#f56c6c; font-size:12px">{{ row.error_message }}</span>
|
||||
<span v-else-if="row.status === 'success'" style="color:#67c23a; font-size:12px">已入池</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="uploader_name" label="上传人" width="90" align="center" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="155" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button type="primary" link size="small" @click="changePriority(row.id, -10)">↑ 提前</el-button>
|
||||
<el-button type="info" link size="small" @click="changePriority(row.id, 10)">↓ 推后</el-button>
|
||||
<el-button type="danger" link size="small" @click="deleteOcrTask(row.id)">取消</el-button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'failed'">
|
||||
<el-button type="warning" link size="small" @click="retryOcrTask(row.id)">🔄 重试</el-button>
|
||||
<el-button type="success" link size="small" @click="openManualInput(row)">✏️ 手动</el-button>
|
||||
<el-button type="danger" link size="small" @click="deleteOcrTask(row.id)">删除</el-button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'success'">
|
||||
<el-tag size="small" type="success" effect="plain">已入池</el-tag>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'processing'">
|
||||
<el-tag size="small" type="warning" effect="dark">处理中...</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination style="margin-top:12px; justify-content:flex-end" background
|
||||
layout="total, prev, pager, next" :total="ocrTasksTotal" :page-size="20" :current-page="ocrTasksPage"
|
||||
@current-change="(p: number) => { ocrTasksPage = p; fetchOcrTasks() }" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══════ Tab 2: 新建报销(AI 一键生成草稿) ═══════ -->
|
||||
<el-tab-pane label="📝 新建报销" name="create">
|
||||
<el-row :gutter="20">
|
||||
@@ -743,6 +885,28 @@ watch(activeTab, (tab) => {
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ═══════ OCR 失败任务手动录入弹窗 ═══════ -->
|
||||
<el-dialog v-model="manualDialogVisible" title="✏️ 手动录入发票信息" width="450px" destroy-on-close>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||
<template #title>OCR 未能识别此发票,请手动填写关键字段后入池</template>
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="开票方" required>
|
||||
<el-input v-model="manualForm.merchant_name" placeholder="开票方名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额" required>
|
||||
<el-input-number v-model="manualForm.amount" :min="0" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票日期">
|
||||
<el-date-picker v-model="manualForm.invoice_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="manualDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitManualInput">确认录入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Search, Document, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
/**
|
||||
* CRM 销售日志
|
||||
* GET /api/sales-logs (分页 + keyword 搜索)
|
||||
* POST /api/sales-logs (创建日志)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Search, Plus, Refresh, EditPen, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
@@ -15,6 +21,7 @@ const size = ref(20)
|
||||
const keyword = ref('')
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
// --- 拉取日志列表 ---
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -37,7 +44,169 @@ const fetchLogs = async () => {
|
||||
const handleSearch = () => { page.value = 1; fetchLogs() }
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchLogs() }
|
||||
|
||||
onMounted(fetchLogs)
|
||||
// --- 写日志弹窗 ---
|
||||
const newLogVisible = ref(false)
|
||||
const newLogSubmitting = ref(false)
|
||||
const newLogForm = reactive({
|
||||
content: '',
|
||||
customer_id: '',
|
||||
contact_ids: [] as string[],
|
||||
log_date: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
const customerOptions = ref<any[]>([])
|
||||
const customerSearchLoading = ref(false)
|
||||
const contactOptions = ref<any[]>([])
|
||||
const contactLoading = ref(false)
|
||||
|
||||
const openNewLogDialog = () => {
|
||||
newLogForm.content = ''
|
||||
newLogForm.customer_id = ''
|
||||
newLogForm.contact_ids = []
|
||||
newLogForm.log_date = new Date().toISOString().slice(0, 10)
|
||||
contactOptions.value = []
|
||||
newLogVisible.value = true
|
||||
}
|
||||
|
||||
// 选择客户后加载联系人
|
||||
const onCustomerChange = async (customerId: string) => {
|
||||
newLogForm.contact_ids = []
|
||||
contactOptions.value = []
|
||||
if (!customerId) return
|
||||
contactLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/customers/${customerId}/contacts`)
|
||||
contactOptions.value = data || []
|
||||
} catch { /* ignore */ } finally {
|
||||
contactLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 远程搜索客户
|
||||
const searchCustomers = async (q: string) => {
|
||||
if (!q || q.length < 1) return
|
||||
customerSearchLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get('/api/customers/search', { params: { q } })
|
||||
customerOptions.value = data || []
|
||||
} catch { /* ignore */ } finally {
|
||||
customerSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitNewLog = async () => {
|
||||
if (!newLogForm.content.trim()) {
|
||||
ElMessage.warning('请输入日志内容')
|
||||
return
|
||||
}
|
||||
newLogSubmitting.value = true
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
content: newLogForm.content,
|
||||
log_date: newLogForm.log_date,
|
||||
}
|
||||
if (newLogForm.customer_id) body.customer_id = newLogForm.customer_id
|
||||
if (newLogForm.contact_ids.length) body.contact_ids = newLogForm.contact_ids
|
||||
await request.post('/api/sales-logs', body)
|
||||
ElMessage.success('日志创建成功')
|
||||
newLogVisible.value = false
|
||||
fetchLogs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
newLogSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 查看详情 ---
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<any>({})
|
||||
const viewDetail = (row: any) => {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// --- 编辑日志 ---
|
||||
const editVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editForm = reactive({
|
||||
id: '',
|
||||
content: '',
|
||||
customer_id: '',
|
||||
contact_ids: [] as string[],
|
||||
log_date: '',
|
||||
})
|
||||
|
||||
const openEditDialog = async (row: any) => {
|
||||
editForm.id = row.id
|
||||
editForm.content = row.content
|
||||
editForm.customer_id = row.customer_id || ''
|
||||
editForm.contact_ids = row.contact_ids || []
|
||||
editForm.log_date = row.log_date || ''
|
||||
// 加载客户选项(如果有关联客户)
|
||||
if (row.customer_name && row.customer_id) {
|
||||
customerOptions.value = [{ id: row.customer_id, name: row.customer_name }]
|
||||
await onCustomerChange(row.customer_id)
|
||||
}
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editForm.content.trim()) {
|
||||
ElMessage.warning('日志内容不能为空')
|
||||
return
|
||||
}
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
const body: Record<string, any> = { content: editForm.content }
|
||||
if (editForm.log_date) body.log_date = editForm.log_date
|
||||
body.customer_id = editForm.customer_id || null
|
||||
body.contact_ids = editForm.contact_ids
|
||||
await request.put(`/api/sales-logs/${editForm.id}`, body)
|
||||
ElMessage.success('日志更新成功')
|
||||
editVisible.value = false
|
||||
fetchLogs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 删除日志 ---
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条日志?删除后不可恢复。', '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await request.delete(`/api/sales-logs/${row.id}`)
|
||||
ElMessage.success('日志已删除')
|
||||
fetchLogs()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 权限判断 ---
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.dataScope === 'all')
|
||||
const canOperate = (row: any) => {
|
||||
return isAdmin.value || row.salesperson_id === userStore.userInfo?.user_id
|
||||
}
|
||||
|
||||
// --- ?action=new 自动弹出 ---
|
||||
onMounted(() => {
|
||||
fetchLogs()
|
||||
if (route.query.action === 'new') {
|
||||
openNewLogDialog()
|
||||
}
|
||||
})
|
||||
watch(() => route.query.action, (v) => {
|
||||
if (v === 'new') openNewLogDialog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,7 +214,7 @@ onMounted(fetchLogs)
|
||||
<!-- 搜索栏 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="8">
|
||||
<el-col :span="7">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索日志内容..."
|
||||
@@ -67,10 +236,10 @@ onMounted(fetchLogs)
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :span="9">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="keyword = ''; dateRange = []; handleSearch()">重置</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="router.push('/logs?action=new')">写日志</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="openNewLogDialog">写日志</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
@@ -91,6 +260,13 @@ onMounted(fetchLogs)
|
||||
{{ row.created_at?.slice(0, 16)?.replace('T', ' ') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="EditPen" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button v-if="canOperate(row)" type="warning" link :icon="EditPen" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="canOperate(row)" type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > size">
|
||||
@@ -103,6 +279,192 @@ onMounted(fetchLogs)
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 写日志弹窗 -->
|
||||
<el-dialog v-model="newLogVisible" title="写销售日志" width="560px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="日志日期" required>
|
||||
<el-date-picker
|
||||
v-model="newLogForm.log_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联客户">
|
||||
<el-select
|
||||
v-model="newLogForm.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="输入客户名称搜索(可选)"
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerSearchLoading"
|
||||
@change="onCustomerChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in customerOptions"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联联系人" v-if="newLogForm.customer_id">
|
||||
<el-select
|
||||
v-model="newLogForm.contact_ids"
|
||||
multiple
|
||||
clearable
|
||||
:loading="contactLoading"
|
||||
placeholder="选择联系人(可多选,用于生成联系人画像)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in contactOptions"
|
||||
:key="c.id"
|
||||
:label="c.name + (c.position ? ` (${c.position})` : '')"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">
|
||||
选择联系人后,AI 将自动提取并更新联系人画像 (Buyer Persona)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="日志内容" required>
|
||||
<el-input
|
||||
v-model="newLogForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="记录今日客户拜访、沟通、跟进等内容..."
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="newLogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="newLogSubmitting" @click="submitNewLog">提交日志</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查看详情弹窗(含 AI 教练评估面板) -->
|
||||
<el-dialog v-model="detailVisible" title="日志详情" width="680px">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="日期">{{ detailRow.log_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关联客户">{{ detailRow.customer_name || '未关联' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="记录人">{{ detailRow.author_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ detailRow.created_at?.slice(0, 16)?.replace('T', ' ') }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="detail-content">
|
||||
<h4 style="margin: 16px 0 8px">日志内容</h4>
|
||||
<div class="content-box">{{ detailRow.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 🧠 AI 教练评估面板 -->
|
||||
<el-card shadow="never" style="margin-top: 16px; border-radius: 8px;" class="coaching-card">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: bold;">🧠 AI 教练评估</span>
|
||||
<el-tag v-if="detailRow.ai_coaching_feedback" type="success" size="small" effect="dark">已分析</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="plain">等待分析</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailRow.ai_coaching_feedback">
|
||||
<!-- MEDDIC 评分 -->
|
||||
<div v-if="detailRow.ai_coaching_feedback.meddic" style="margin-bottom: 16px;">
|
||||
<h5 style="margin: 0 0 8px; color: #606266;">📊 MEDDIC 评分</h5>
|
||||
<div class="meddic-grid">
|
||||
<div v-for="(val, key) in detailRow.ai_coaching_feedback.meddic" :key="key" class="meddic-item">
|
||||
<div class="meddic-label">{{ key }}</div>
|
||||
<el-progress :percentage="(val || 0) * 10" :stroke-width="8"
|
||||
:color="(val || 0) >= 7 ? '#67c23a' : (val || 0) >= 4 ? '#e6a23c' : '#f56c6c'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPIN 提问建议 -->
|
||||
<div v-if="detailRow.ai_coaching_feedback.spin_questions" style="margin-bottom: 16px;">
|
||||
<h5 style="margin: 0 0 8px; color: #606266;">💡 SPIN 提问建议</h5>
|
||||
<ul style="padding-left: 20px; margin: 0; line-height: 1.8;">
|
||||
<li v-for="(q, i) in detailRow.ai_coaching_feedback.spin_questions" :key="i" style="color: #303133; font-size: 13px;">{{ q }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 教练总评 -->
|
||||
<div v-if="detailRow.ai_coaching_feedback.summary">
|
||||
<h5 style="margin: 0 0 8px; color: #606266;">📝 教练总评</h5>
|
||||
<div class="content-box" style="font-size: 13px;">{{ detailRow.ai_coaching_feedback.summary }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始 JSON 兜底 -->
|
||||
<div v-if="!detailRow.ai_coaching_feedback.meddic && !detailRow.ai_coaching_feedback.spin_questions && !detailRow.ai_coaching_feedback.summary">
|
||||
<pre style="font-size: 12px; background: #f5f7fa; padding: 8px; border-radius: 4px; overflow-x: auto;">{{ JSON.stringify(detailRow.ai_coaching_feedback, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-skeleton :rows="4" animated />
|
||||
<p style="color: #909399; font-size: 13px; margin-top: 8px; text-align: center;">
|
||||
AI 教练正在分析此日志,反馈将在 Dify Workflow 处理完成后自动显示
|
||||
</p>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑日志弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑销售日志" width="560px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="日志日期">
|
||||
<el-date-picker
|
||||
v-model="editForm.log_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联客户">
|
||||
<el-select
|
||||
v-model="editForm.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="输入客户名称搜索"
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerSearchLoading"
|
||||
@change="onCustomerChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联联系人" v-if="editForm.customer_id">
|
||||
<el-select
|
||||
v-model="editForm.contact_ids"
|
||||
multiple clearable
|
||||
:loading="contactLoading"
|
||||
placeholder="选择联系人"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in contactOptions" :key="c.id" :label="c.name + (c.position ? ` (${c.position})` : '')" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日志内容" required>
|
||||
<el-input v-model="editForm.content" type="textarea" :rows="6" maxlength="5000" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="submitEdit">保存修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,4 +483,34 @@ onMounted(fetchLogs)
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.content-box {
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
min-height: 80px;
|
||||
}
|
||||
.meddic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.meddic-item {
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.meddic-label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.coaching-card {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #f5f3ff 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订单全景详情(独立全屏页)
|
||||
* 包含:商品明细、发货记录、关联发票(含开票+关联/上传)、收款追踪
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Van, Tickets, Upload, Link as LinkIcon } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const orderId = computed(() => route.query.id as string)
|
||||
|
||||
// ═══════ 订单数据 ═══════
|
||||
const loading = ref(false)
|
||||
const order = ref<any>({})
|
||||
|
||||
const fetchOrder = async () => {
|
||||
if (!orderId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/orders/${orderId.value}`)
|
||||
order.value = data
|
||||
paymentAmount.value = data.paid_amount || 0
|
||||
fetchShippingHistory()
|
||||
fetchOrderInvoices()
|
||||
} catch { ElMessage.error('加载订单失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 发货记录 ═══════
|
||||
const shippingHistory = ref<any[]>([])
|
||||
const shippingHistoryLoading = ref(false)
|
||||
const fetchShippingHistory = async () => {
|
||||
shippingHistoryLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/shipping/order/${orderId.value}`)
|
||||
shippingHistory.value = data?.shipments || []
|
||||
} catch {}
|
||||
finally { shippingHistoryLoading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 关联发票 ═══════
|
||||
const orderInvoices = ref<any[]>([])
|
||||
const invoicesLoading = ref(false)
|
||||
const fetchOrderInvoices = async () => {
|
||||
invoicesLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/orders/${orderId.value}/invoices`)
|
||||
orderInvoices.value = data || []
|
||||
} catch {}
|
||||
finally { invoicesLoading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 收款标记 ═══════
|
||||
const paymentAmount = ref(0)
|
||||
const paymentSaving = ref(false)
|
||||
const handleMarkPayment = async () => {
|
||||
if (!order.value.id) return
|
||||
paymentSaving.value = true
|
||||
try {
|
||||
await request.put(`/api/orders/${order.value.id}/payment`, { paid_amount: paymentAmount.value })
|
||||
ElMessage.success('收款状态已更新')
|
||||
await fetchOrder()
|
||||
} catch {}
|
||||
finally { paymentSaving.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 开票明细预览弹窗 ═══════
|
||||
const invoicePreviewVisible = ref(false)
|
||||
const invoicePreviewLoading = ref(false)
|
||||
const invoicePreview = ref<any>({})
|
||||
const invoiceMode = ref<'full' | 'batch'>('full')
|
||||
const selectedShippingId = ref('')
|
||||
|
||||
const openInvoicePreview = async (mode: 'full' | 'batch', shippingId?: string) => {
|
||||
invoiceMode.value = mode
|
||||
selectedShippingId.value = shippingId || ''
|
||||
invoicePreviewLoading.value = true
|
||||
invoicePreviewVisible.value = true
|
||||
try {
|
||||
const params: any = { mode }
|
||||
if (mode === 'batch' && shippingId) params.shipping_id = shippingId
|
||||
const data: any = await request.get(`/api/orders/${orderId.value}/invoice-detail-preview`, { params })
|
||||
invoicePreview.value = data
|
||||
} catch { ElMessage.error('生成开票明细失败') }
|
||||
finally { invoicePreviewLoading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 关联已有发票 ═══════
|
||||
const linkDialogVisible = ref(false)
|
||||
const unlinkSearchKey = ref('')
|
||||
const unlinkedInvoices = ref<any[]>([])
|
||||
const unlinkedLoading = ref(false)
|
||||
|
||||
const openLinkDialog = async () => {
|
||||
linkDialogVisible.value = true
|
||||
unlinkSearchKey.value = ''
|
||||
await fetchUnlinkedInvoices()
|
||||
}
|
||||
const fetchUnlinkedInvoices = async () => {
|
||||
unlinkedLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (unlinkSearchKey.value) params.keyword = unlinkSearchKey.value
|
||||
const data: any = await request.get('/api/orders/unlinked-invoices', { params })
|
||||
unlinkedInvoices.value = data || []
|
||||
} catch {}
|
||||
finally { unlinkedLoading.value = false }
|
||||
}
|
||||
const handleLinkInvoice = async (inv: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将发票 ${inv.invoice_number}(¥${inv.amount})关联到本订单?`, '关联确认')
|
||||
} catch { return }
|
||||
try {
|
||||
await request.post(`/api/orders/${orderId.value}/invoices/link`, {
|
||||
invoice_id: inv.id,
|
||||
shipping_record_id: selectedShippingId.value || null,
|
||||
})
|
||||
ElMessage.success('发票已关联')
|
||||
linkDialogVisible.value = false
|
||||
invoicePreviewVisible.value = false
|
||||
fetchOrderInvoices()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ═══════ 创建新发票(OCR + 手动) ═══════
|
||||
const createInvVisible = ref(false)
|
||||
const createInvForm = reactive({
|
||||
invoice_number: '',
|
||||
amount: 0,
|
||||
issuer: '',
|
||||
billing_date: '',
|
||||
remark: '',
|
||||
})
|
||||
const ocrLoading = ref(false)
|
||||
const ocrMessage = ref('')
|
||||
const createInvSubmitting = ref(false)
|
||||
|
||||
const openCreateInvoice = () => {
|
||||
createInvForm.invoice_number = ''
|
||||
createInvForm.amount = invoicePreview.value?.total_amount || 0
|
||||
createInvForm.issuer = invoicePreview.value?.seller_name || ''
|
||||
createInvForm.billing_date = new Date().toISOString().slice(0, 10)
|
||||
createInvForm.remark = ''
|
||||
ocrMessage.value = ''
|
||||
createInvVisible.value = true
|
||||
}
|
||||
|
||||
const handleUploadInvoiceFile = async (uploadFile: any) => {
|
||||
ocrLoading.value = true
|
||||
ocrMessage.value = '正在识别发票...'
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.raw || uploadFile.file)
|
||||
formData.append('scene', 'invoice')
|
||||
const resp: any = await request.post('/api/finance/ocr', formData)
|
||||
const ocrData = resp?.ocr_data || {}
|
||||
ocrMessage.value = resp?.message || '识别完成'
|
||||
|
||||
if (ocrData.invoice_number) createInvForm.invoice_number = ocrData.invoice_number
|
||||
if (ocrData.amount) createInvForm.amount = parseFloat(ocrData.amount) || createInvForm.amount
|
||||
if (ocrData.merchant) createInvForm.issuer = ocrData.merchant
|
||||
if (ocrData.date) createInvForm.billing_date = ocrData.date
|
||||
|
||||
if (ocrData.invoice_number || ocrData.amount) {
|
||||
ElMessage.success('发票识别成功,已自动填充')
|
||||
} else {
|
||||
ElMessage.warning('自动识别未提取到关键字段,请手动填写')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ocrMessage.value = '识别失败'
|
||||
ElMessage.error('发票识别失败,请手动填写')
|
||||
}
|
||||
finally { ocrLoading.value = false }
|
||||
}
|
||||
|
||||
const submitCreateInvoice = async () => {
|
||||
if (!createInvForm.invoice_number.trim()) { ElMessage.warning('请填写发票号'); return }
|
||||
if (createInvForm.amount <= 0) { ElMessage.warning('金额需大于0'); return }
|
||||
if (!createInvForm.issuer.trim()) { ElMessage.warning('请填写开票方名称'); return }
|
||||
|
||||
createInvSubmitting.value = true
|
||||
try {
|
||||
await request.post(`/api/orders/${orderId.value}/invoices/create`, {
|
||||
invoice_number: createInvForm.invoice_number,
|
||||
amount: createInvForm.amount,
|
||||
issuer: createInvForm.issuer,
|
||||
billing_date: createInvForm.billing_date,
|
||||
remark: createInvForm.remark,
|
||||
receiver_customer_id: invoicePreview.value?.customer_id || null,
|
||||
shipping_record_id: selectedShippingId.value || null,
|
||||
})
|
||||
ElMessage.success('发票创建并关联成功')
|
||||
createInvVisible.value = false
|
||||
invoicePreviewVisible.value = false
|
||||
fetchOrderInvoices()
|
||||
} catch {}
|
||||
finally { createInvSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 辅助 ═══════
|
||||
const activeTab = ref('items')
|
||||
const formatCurrency = (v: number) => `¥${(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
|
||||
const shippingTagType = (s: string) => ({ pending: 'info', partial: 'warning', shipped: 'success' }[s] || 'info')
|
||||
const shippingLabel = (s: string) => ({ pending: '待发货', partial: '部分发货', shipped: '已发货' }[s] || s)
|
||||
const paymentTagType = (s: string) => ({ unpaid: 'danger', partial: 'warning', cleared: 'success' }[s] || 'info')
|
||||
const paymentLabel = (s: string) => ({ unpaid: '未收款', partial: '部分收款', cleared: '已结清' }[s] || s)
|
||||
|
||||
const goBack = () => router.push('/orders')
|
||||
|
||||
onMounted(fetchOrder)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="order-detail-page" v-loading="loading">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="page-header">
|
||||
<el-button :icon="ArrowLeft" @click="goBack">返回订单列表</el-button>
|
||||
<div class="header-title">
|
||||
<h2>订单全景详情</h2>
|
||||
<el-tag v-if="order.order_no" type="primary" effect="dark" size="large" style="margin-left:12px; font-family: Consolas, monospace;">
|
||||
{{ order.order_no }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单概览卡片 -->
|
||||
<el-card class="overview-card" v-if="order.id">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="客户">
|
||||
<b>{{ order.customer_name }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="业务员">{{ order.salesperson_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单日期">{{ order.order_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<b style="color:#e6a23c; font-size:16px">{{ formatCurrency(order.total_amount) }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已收款">
|
||||
<b style="color:#67c23a">{{ formatCurrency(order.paid_amount || 0) }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="未收款">
|
||||
<b style="color:#f56c6c">{{ formatCurrency((order.total_amount || 0) - (order.paid_amount || 0)) }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发货状态">
|
||||
<el-tag :type="(shippingTagType(order.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(order.shipping_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收款状态">
|
||||
<el-tag :type="(paymentTagType(order.payment_state) as any)" size="small">{{ paymentLabel(order.payment_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="4">{{ order.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- Tabs 内容区 -->
|
||||
<el-card class="content-card" v-if="order.id">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- Tab 1: 商品明细 -->
|
||||
<el-tab-pane label="📦 商品明细" name="items">
|
||||
<el-table :data="order.items || []" border stripe style="width:100%">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="sku_code" label="SKU" width="160">
|
||||
<template #default="{ row }"><span class="mono-bold">{{ row.sku_code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" width="100" />
|
||||
<el-table-column label="单价" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="qty" label="订购" width="80" align="center" />
|
||||
<el-table-column label="已发" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.shipped_qty >= row.qty ? '#67c23a' : '#e6a23c', fontWeight: 'bold' }">{{ row.shipped_qty }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="120" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 发货记录 -->
|
||||
<el-tab-pane label="🚛 发货记录" name="shipping">
|
||||
<div v-loading="shippingHistoryLoading">
|
||||
<el-empty v-if="!shippingHistory.length" description="暂无发货记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="ship in shippingHistory" :key="ship.id"
|
||||
:timestamp="ship.ship_date" placement="top"
|
||||
:color="ship.status === 'delivered' ? '#67c23a' : '#409eff'"
|
||||
>
|
||||
<el-card shadow="hover" class="timeline-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<span class="mono-bold">{{ ship.shipping_no }}</span>
|
||||
<el-tag size="small" :type="ship.status === 'delivered' ? 'success' : 'primary'" effect="dark">
|
||||
{{ ship.status === 'delivered' ? '已签收' : '运输中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">物流:{{ ship.carrier || '-' }} | 单号:{{ ship.tracking_no || '-' }}</p>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">操作人:{{ ship.operator_name || '-' }}</p>
|
||||
<el-table :data="ship.items" border size="small" style="margin-top:8px">
|
||||
<el-table-column prop="sku_code" label="SKU" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品" min-width="130" />
|
||||
<el-table-column prop="spec" label="规格" width="100">
|
||||
<template #default="{ row }">{{ row.spec || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货数量" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 关联发票(增强) -->
|
||||
<el-tab-pane label="🎟️ 关联发票" name="invoices">
|
||||
<div v-loading="invoicesLoading">
|
||||
<!-- 操作按钮区 -->
|
||||
<div class="invoice-actions">
|
||||
<el-button type="primary" :icon="Tickets" @click="openInvoicePreview('full')">整体开票</el-button>
|
||||
<el-dropdown v-if="shippingHistory.length" trigger="click" @command="(id: string) => openInvoicePreview('batch', id)">
|
||||
<el-button type="success" :icon="Van">按发货批次开票<el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="ship in shippingHistory" :key="ship.id" :command="ship.id">
|
||||
{{ ship.shipping_no }} ({{ ship.ship_date }})
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 已关联发票列表 -->
|
||||
<el-empty v-if="!orderInvoices.length" description="暂无关联发票" style="margin-top:16px" />
|
||||
<el-table v-else :data="orderInvoices" border stripe size="small" style="margin-top:16px">
|
||||
<el-table-column prop="invoice_number" label="发票号" width="180">
|
||||
<template #default="{ row }"><span class="mono-bold">{{ row.invoice_number }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="issuer" label="开票方" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="receiver_name" label="购买方" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.amount) }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billing_date" label="开票日期" width="110" />
|
||||
<el-table-column label="回款状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.payment_status === '已回款' ? 'success' : row.payment_status === '部分回款' ? 'warning' : 'danger'" size="small">
|
||||
{{ row.payment_status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payment_due_date" label="回款截止日" width="110" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 收款追踪 -->
|
||||
<el-tab-pane label="💰 收款追踪" name="payment">
|
||||
<el-descriptions :column="2" border style="margin-bottom: 16px;">
|
||||
<el-descriptions-item label="订单金额"><b>{{ formatCurrency(order.total_amount) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="已收款"><b style="color:#67c23a">{{ formatCurrency(order.paid_amount || 0) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="未收款"><b style="color:#f56c6c">{{ formatCurrency((order.total_amount || 0) - (order.paid_amount || 0)) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="收款状态">
|
||||
<el-tag :type="(paymentTagType(order.payment_state) as any)" size="small">{{ paymentLabel(order.payment_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-form :inline="true" style="margin-top: 12px;">
|
||||
<el-form-item label="录入收款金额">
|
||||
<el-input-number v-model="paymentAmount" :min="0" :precision="2" style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="paymentSaving" @click="handleMarkPayment">更新收款</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="paymentAmount = order.total_amount; handleMarkPayment()">一键结清</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- ═══════ 开票明细预览弹窗 ═══════ -->
|
||||
<el-dialog v-model="invoicePreviewVisible" title="📋 开票明细预览" width="800px" destroy-on-close>
|
||||
<div v-loading="invoicePreviewLoading">
|
||||
<el-descriptions :column="2" border style="margin-bottom:16px">
|
||||
<el-descriptions-item label="买方(客户)"><b>{{ invoicePreview.buyer_name }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="卖方(我方)"><b>{{ invoicePreview.seller_name }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="订单编号">{{ invoicePreview.order_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票模式">
|
||||
<el-tag :type="invoiceMode === 'full' ? 'primary' : 'success'" size="small" effect="dark">
|
||||
{{ invoiceMode === 'full' ? '整体开票' : '按发货批次' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table :data="invoicePreview.items || []" border stripe size="small">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="sku_code" label="SKU" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" width="90" />
|
||||
<el-table-column label="数量" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.qty }} {{ row.unit || '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="preview-total">
|
||||
合计金额:<b style="color:#e6a23c; font-size:18px">{{ formatCurrency(invoicePreview.total_amount || 0) }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="invoicePreviewVisible = false">取消</el-button>
|
||||
<el-button type="warning" :icon="LinkIcon" @click="openLinkDialog">关联已有发票</el-button>
|
||||
<el-button type="primary" :icon="Upload" @click="openCreateInvoice">创建/上传发票</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ═══════ 关联已有发票弹窗 ═══════ -->
|
||||
<el-dialog v-model="linkDialogVisible" title="🔗 关联已有发票" width="650px" destroy-on-close>
|
||||
<el-input v-model="unlinkSearchKey" placeholder="搜索发票号..." clearable style="margin-bottom:12px"
|
||||
@input="fetchUnlinkedInvoices" />
|
||||
<el-table v-loading="unlinkedLoading" :data="unlinkedInvoices" border stripe size="small" max-height="400">
|
||||
<el-table-column prop="invoice_number" label="发票号" width="160">
|
||||
<template #default="{ row }"><span class="mono-bold">{{ row.invoice_number }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="issuer" label="开票方" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="receiver_name" label="购买方" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="110" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.amount) }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billing_date" label="开票日期" width="100" />
|
||||
<el-table-column label="" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="handleLinkInvoice(row)">关联</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!unlinkedInvoices.length && !unlinkedLoading" description="暂无未关联的发票" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- ═══════ 创建新发票弹窗(OCR + 手动) ═══════ -->
|
||||
<el-dialog v-model="createInvVisible" title="📄 创建发票并关联" width="550px" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px" show-icon>
|
||||
<template #title>
|
||||
上传发票文件(XML / PDF / 图片),系统自动识别填充;识别失败可手动填写
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xml,.pdf,.png,.jpg,.jpeg,.ofd"
|
||||
@change="handleUploadInvoiceFile"
|
||||
>
|
||||
<el-button type="success" :icon="Upload" :loading="ocrLoading">
|
||||
{{ ocrLoading ? '识别中...' : '上传发票文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-tag v-if="ocrMessage" :type="ocrMessage.includes('成功') ? 'success' : 'warning'" style="margin-top:8px">
|
||||
{{ ocrMessage }}
|
||||
</el-tag>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="发票号" required>
|
||||
<el-input v-model="createInvForm.invoice_number" placeholder="如 12345678" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票金额" required>
|
||||
<el-input-number v-model="createInvForm.amount" :min="0" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票方" required>
|
||||
<el-input v-model="createInvForm.issuer" placeholder="卖方公司名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票日期">
|
||||
<el-date-picker v-model="createInvForm.billing_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createInvForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="createInvVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createInvSubmitting" @click="submitCreateInvoice">确认创建并关联</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.overview-card {
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,0.08);
|
||||
}
|
||||
.content-card {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,0.08);
|
||||
}
|
||||
.mono-bold {
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
.timeline-card {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.timeline-card p {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.invoice-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preview-total {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,14 @@
|
||||
* 列表 + 开单 + 详情(含发货记录 timeline)+ 安排发货弹窗(防超发)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus, View, Delete, Van } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ════════════════════════ 订单列表 ════════════════════════
|
||||
const loading = ref(false)
|
||||
const orderList = ref<any[]>([])
|
||||
@@ -43,34 +46,8 @@ const paymentLabel = (s: string) => ({ unpaid: '未收款', partial: '部分收
|
||||
const formatCurrency = (v: number) => `¥${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
|
||||
|
||||
// ════════════════════════ 订单详情 ════════════════════════
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const currentOrder = ref<any>({})
|
||||
const activeDetailTab = ref('items')
|
||||
const shippingHistory = ref<any[]>([])
|
||||
const shippingHistoryLoading = ref(false)
|
||||
|
||||
const openDetail = async (row: any) => {
|
||||
activeDetailTab.value = 'items'
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
shippingHistory.value = []
|
||||
try {
|
||||
const data: any = await request.get(`/api/orders/${row.id}`)
|
||||
currentOrder.value = data
|
||||
// 同时加载发货轨迹
|
||||
fetchShippingHistory(row.id)
|
||||
} catch {}
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
const fetchShippingHistory = async (orderId: string) => {
|
||||
shippingHistoryLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/shipping/order/${orderId}`)
|
||||
shippingHistory.value = data?.shipments || []
|
||||
} catch {}
|
||||
finally { shippingHistoryLoading.value = false }
|
||||
const openDetail = (row: any) => {
|
||||
router.push({ path: '/orders/detail', query: { id: row.id } })
|
||||
}
|
||||
|
||||
// ════════════════════════ 沉浸式开单 ════════════════════════
|
||||
@@ -241,10 +218,6 @@ const submitShipping = async () => {
|
||||
ElMessage.success('发货成功,库存已同步扣减')
|
||||
shipDialogVisible.value = false
|
||||
fetchOrders()
|
||||
// 如果详情也开着,刷新它
|
||||
if (detailVisible.value && currentOrder.value.id === shipOrder.value.id) {
|
||||
openDetail(shipOrder.value)
|
||||
}
|
||||
} catch {}
|
||||
finally { shipSubmitting.value = false }
|
||||
}
|
||||
@@ -381,87 +354,6 @@ onMounted(fetchOrders)
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ═══════ 4. 订单详情抽屉(含发货轨迹 tab)═══════ -->
|
||||
<el-drawer v-model="detailVisible" title="订单全景详情" size="750px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="currentOrder.id">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单编号"><span class="order-id-bold">{{ currentOrder.order_no }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ currentOrder.customer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务员">{{ currentOrder.salesperson_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单日期">{{ currentOrder.order_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额"><b style="color:#e6a23c">{{ formatCurrency(currentOrder.total_amount) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="已收款">{{ formatCurrency(currentOrder.paid_amount || 0) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发货状态">
|
||||
<el-tag :type="(shippingTagType(currentOrder.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(currentOrder.shipping_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收款状态">
|
||||
<el-tag :type="(paymentTagType(currentOrder.payment_state) as any)" size="small">{{ paymentLabel(currentOrder.payment_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs v-model="activeDetailTab" style="margin-top:16px">
|
||||
<!-- Tab 1: 商品明细 -->
|
||||
<el-tab-pane label="📦 商品明细" name="items">
|
||||
<el-table :data="currentOrder.items || []" border stripe style="width:100%">
|
||||
<el-table-column prop="sku_code" label="SKU" width="180"><template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template></el-table-column>
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" width="100" />
|
||||
<el-table-column label="单价" width="110" align="right"><template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template></el-table-column>
|
||||
<el-table-column prop="qty" label="订购" width="70" align="center" />
|
||||
<el-table-column label="已发" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.shipped_qty >= row.qty ? '#67c23a' : '#e6a23c', fontWeight: 'bold' }">{{ row.shipped_qty }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right"><template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 发货记录 -->
|
||||
<el-tab-pane label="🚛 发货记录" name="shipping">
|
||||
<div v-loading="shippingHistoryLoading">
|
||||
<el-empty v-if="!shippingHistory.length" description="暂无发货记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="ship in shippingHistory" :key="ship.id"
|
||||
:timestamp="ship.ship_date" placement="top"
|
||||
:color="ship.status === 'delivered' ? '#67c23a' : '#409eff'"
|
||||
>
|
||||
<el-card shadow="hover" class="timeline-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<span class="order-id-bold">{{ ship.shipping_no }}</span>
|
||||
<el-tag size="small" :type="ship.status === 'delivered' ? 'success' : 'primary'" effect="dark">
|
||||
{{ ship.status === 'delivered' ? '已签收' : '运输中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">物流:{{ ship.carrier || '-' }} | 单号:{{ ship.tracking_no || '-' }}</p>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">操作人:{{ ship.operator_name || '-' }}</p>
|
||||
<el-table :data="ship.items" border size="small" style="margin-top:8px">
|
||||
<el-table-column prop="sku_code" label="SKU" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品" min-width="130" />
|
||||
<el-table-column prop="spec" label="规格" width="100">
|
||||
<template #default="{ row }">{{ row.spec || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货数量" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 详情页快速发货按钮 -->
|
||||
<div v-if="currentOrder.shipping_state !== 'shipped'" style="margin-top:16px; text-align:right">
|
||||
<el-button type="success" :icon="Van" @click="openShipDialog(currentOrder)">安排发货</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ═══════ 5. 安排发货弹窗(防超发)═══════ -->
|
||||
<el-dialog v-model="shipDialogVisible" title="🚛 安排发货" width="750px" destroy-on-close>
|
||||
|
||||
@@ -239,6 +239,7 @@ const invForm = reactive({
|
||||
qty: 1,
|
||||
reason: 'purchase',
|
||||
remark: '',
|
||||
purchase_unit_price: 0,
|
||||
})
|
||||
|
||||
const invRules = reactive<FormRules>({
|
||||
@@ -259,7 +260,7 @@ const outReasons = [
|
||||
|
||||
const openInventory = (row: any) => {
|
||||
currentInvSku.value = row
|
||||
Object.assign(invForm, { direction: 'in', qty: 1, reason: 'purchase', remark: '' })
|
||||
Object.assign(invForm, { direction: 'in', qty: 1, reason: 'purchase', remark: '', purchase_unit_price: 0 })
|
||||
invDialogVisible.value = true
|
||||
nextTick(() => invFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -281,6 +282,7 @@ const submitInventory = async () => {
|
||||
change_qty: changeQty,
|
||||
reason: invForm.reason,
|
||||
remark: invForm.remark || null,
|
||||
purchase_unit_price: invForm.direction === 'in' ? invForm.purchase_unit_price : 0,
|
||||
})
|
||||
ElMessage.success('库存变更成功')
|
||||
invDialogVisible.value = false
|
||||
@@ -529,6 +531,10 @@ onMounted(async () => {
|
||||
<el-form-item label="变更数量" prop="qty">
|
||||
<el-input-number v-model="invForm.qty" :min="1" :max="99999" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="invForm.direction === 'in'" label="采购单价">
|
||||
<el-input-number v-model="invForm.purchase_unit_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">用于 MWA 加权成本计算,0 表示不记入成本</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作备注">
|
||||
<el-input v-model="invForm.remark" type="textarea" :rows="2" placeholder="非必填,记录特殊单号或说明" />
|
||||
</el-form-item>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 利润核算报表页
|
||||
* GET /api/profit/report (按订单维度聚合)
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const report = ref<any>({ orders: [], total_revenue: 0, total_profit: 0, overall_profit_rate: 0 })
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const fetchReport = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
if (dateRange.value) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const data: any = await request.get('/api/profit/report', { params })
|
||||
report.value = data
|
||||
} catch { /* */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => fetchReport())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profit-container">
|
||||
<!-- 筛选 -->
|
||||
<el-card shadow="never" class="filter-section">
|
||||
<div style="display: flex; gap: 16px; align-items: center;">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" />
|
||||
<el-button type="primary" @click="fetchReport">查询</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="summary-cards">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-label">总营收</div>
|
||||
<div class="stat-value">¥{{ (report.total_revenue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<div class="stat-label">总利润</div>
|
||||
<div class="stat-value" :class="{ 'text-success': report.total_profit > 0, 'text-danger': report.total_profit < 0 }">
|
||||
¥{{ (report.total_profit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<div class="stat-label">综合利润率</div>
|
||||
<div class="stat-value">{{ report.overall_profit_rate || 0 }}%</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 明细表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">订单利润明细</span></template>
|
||||
<el-table :data="report.orders || []" stripe border v-loading="loading">
|
||||
<el-table-column prop="order_no" label="订单编号" width="200" />
|
||||
<el-table-column prop="order_date" label="订单日期" width="120" />
|
||||
<el-table-column label="营收" width="160" align="right">
|
||||
<template #default="scope">¥{{ (scope.row.revenue || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="利润" width="160" align="right">
|
||||
<template #default="scope">
|
||||
<span :class="{ 'text-success': scope.row.profit > 0, 'text-danger': scope.row.profit < 0 }">
|
||||
¥{{ (scope.row.profit || 0).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="利润率" width="120" align="center">
|
||||
<template #default="scope">{{ scope.row.profit_rate || 0 }}%</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profit-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.filter-section { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
|
||||
.summary-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.stat-label { font-size: 14px; color: #909399; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: bold; color: #303133; }
|
||||
.text-success { color: #67c23a; }
|
||||
.text-danger { color: #f56c6c; }
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
* Tab2: 角色管理(新增/编辑弹窗 + 权限分配面板 + JSONB menu_keys)
|
||||
*/
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { Plus, Edit, Key, Lock, Setting, User, Operation, Check } from '@element-plus/icons-vue'
|
||||
import { Plus, Edit, Key, Lock, Setting, User, Operation, Check, OfficeBuilding } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
@@ -280,6 +280,57 @@ const submitRole = async () => {
|
||||
finally { roleSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tab 3: 企业账号配置
|
||||
// ==========================================
|
||||
const companyLoading = ref(false)
|
||||
const companySaving = ref(false)
|
||||
const companyForm = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
full_info: {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCompany = async () => {
|
||||
companyLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get('/api/companies/current')
|
||||
if (data) {
|
||||
companyForm.name = data.name || ''
|
||||
companyForm.code = data.code || ''
|
||||
const info = data.full_info || {}
|
||||
Object.assign(companyForm.full_info, {
|
||||
company_name: info.company_name || '',
|
||||
tax_id: info.tax_id || '',
|
||||
address: info.address || '',
|
||||
phone: info.phone || '',
|
||||
bank_name: info.bank_name || '',
|
||||
bank_account: info.bank_account || '',
|
||||
})
|
||||
}
|
||||
} catch (e) { console.warn('[Settings] fetchCompany error:', e) }
|
||||
finally { companyLoading.value = false }
|
||||
}
|
||||
|
||||
const saveCompany = async () => {
|
||||
companySaving.value = true
|
||||
try {
|
||||
await request.put('/api/companies/current', {
|
||||
name: companyForm.name,
|
||||
full_info: companyForm.full_info,
|
||||
})
|
||||
ElMessage.success('企业信息已保存')
|
||||
} catch { /* 统一处理 */ }
|
||||
finally { companySaving.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 初始化
|
||||
// ==========================================
|
||||
@@ -289,6 +340,7 @@ onMounted(async () => {
|
||||
flatDepts.value = flattenDepts(orgTreeData.value)
|
||||
await fetchUsers()
|
||||
await fetchRoles()
|
||||
fetchCompany()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -429,6 +481,54 @@ onMounted(async () => {
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ============================================== -->
|
||||
<!-- Tab 3: 企业账号配置 -->
|
||||
<!-- ============================================== -->
|
||||
<el-tab-pane name="company">
|
||||
<template #label>
|
||||
<span class="custom-tabs-label"><el-icon><OfficeBuilding /></el-icon><span>企业账号配置</span></span>
|
||||
</template>
|
||||
|
||||
<el-card shadow="never" class="perm-card" v-loading="companyLoading">
|
||||
<template #header>
|
||||
<div class="perm-header">
|
||||
<div class="card-header-bold">🏢 当前公司卖方信息</div>
|
||||
<el-button type="success" :icon="Check" :loading="companySaving" @click="saveCompany">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-width="130px" style="max-width: 700px;">
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-form-item label="公司简称">
|
||||
<el-input v-model="companyForm.name" placeholder="系统内简称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司编码">
|
||||
<el-input v-model="companyForm.code" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">开票 / 合同卖方信息</el-divider>
|
||||
<el-form-item label="企业全称">
|
||||
<el-input v-model="companyForm.full_info.company_name" placeholder="营业执照上的全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="纳税人识别号">
|
||||
<el-input v-model="companyForm.full_info.tax_id" placeholder="统一社会信用代码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="注册地址">
|
||||
<el-input v-model="companyForm.full_info.address" placeholder="公司注册地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业电话">
|
||||
<el-input v-model="companyForm.full_info.phone" placeholder="公司电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户银行">
|
||||
<el-input v-model="companyForm.full_info.bank_name" placeholder="开户行名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="companyForm.full_info.bank_account" placeholder="银行账号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/crm_erp?ssl=disable
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=your-jwt-secret-key-here
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# App
|
||||
APP_NAME=润滑油CRM/ERP系统
|
||||
APP_VERSION=0.1.0
|
||||
DEBUG=false
|
||||
|
||||
# Dify AI 中枢
|
||||
DIFY_API_BASE_URL=http://your-dify-host
|
||||
DIFY_API_KEY=your-dify-api-key
|
||||
DIFY_APP_ID=your-dify-app-id
|
||||
DIFY_TIMEOUT_MS=30000
|
||||
|
||||
# Dify Workflow Keys
|
||||
DIFY_WORKFLOW_PERSONA_KEY=your-persona-workflow-key
|
||||
DIFY_WORKFLOW_REPORT_KEY=your-report-workflow-key
|
||||
|
||||
# Ollama 算力节点
|
||||
OLLAMA_4060_BASE_URL=http://your-ollama-4060-host:11435
|
||||
OLLAMA_4060_MODEL=qwen3.5:4b
|
||||
OLLAMA_3090_BASE_URL=http://your-ollama-3090-host:11434
|
||||
OLLAMA_3090_MODEL=qwen3.5:27b
|
||||
@@ -25,7 +25,10 @@ config = context.config
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
# Alembic 需要同步驱动,将 asyncpg 替换为 psycopg2
|
||||
sync_url = db_url.replace("+asyncpg", "")
|
||||
config.set_main_option("sqlalchemy.url", sync_url)
|
||||
# 宿主机执行 Alembic 时,host.docker.internal 不可达,替换为回环地址
|
||||
sync_url = sync_url.replace("host.docker.internal", "127.0.0.1")
|
||||
# configparser 把 % 当插值语法,需要转义为 %%
|
||||
config.set_main_option("sqlalchemy.url", sync_url.replace("%", "%%"))
|
||||
|
||||
# 日志配置
|
||||
if config.config_file_name is not None:
|
||||
@@ -33,7 +36,7 @@ if config.config_file_name is not None:
|
||||
|
||||
# 导入所有模型,确保 Alembic 能检测到所有表
|
||||
from app.models.base import Base
|
||||
from app.models import crm, erp, order, shipping, finance, ai, sys as sys_models
|
||||
from app.models import crm, erp, order, shipping, finance, ai, sys as sys_models, contract, cost
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -61,8 +64,10 @@ async def run_async_migrations():
|
||||
"""在线迁移(异步引擎)"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
# 宿主机执行 Alembic 时,host.docker.internal 不可达,替换为回环地址
|
||||
async_url = os.getenv("DATABASE_URL", "").replace("host.docker.internal", "127.0.0.1")
|
||||
connectable = create_async_engine(
|
||||
os.getenv("DATABASE_URL", ""),
|
||||
async_url,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""multi-tenant company isolation
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 03d8dcc2d72a
|
||||
Create Date: 2026-03-18 08:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, Sequence[str], None] = '03d8dcc2d72a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# 默认公司的固定 UUID
|
||||
DEFAULT_COMPANY_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0001'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 1: 创建 sys_companies 公司主体表
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
op.create_table(
|
||||
'sys_companies',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('name', sa.String(200), nullable=False),
|
||||
sa.Column('code', sa.String(50), unique=True, nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
)
|
||||
|
||||
# Step 2: 插入默认公司
|
||||
op.execute(f"""
|
||||
INSERT INTO sys_companies (id, name, code, is_active)
|
||||
VALUES ('{DEFAULT_COMPANY_ID}', '天津硕博霖', 'SHBL-TJ', true)
|
||||
""")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 3: 创建 sys_user_companies 用户-公司关联表
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
op.create_table(
|
||||
'sys_user_companies',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sys_users.id'), nullable=False),
|
||||
sa.Column('company_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sys_companies.id'), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.UniqueConstraint('user_id', 'company_id', name='uq_user_company'),
|
||||
)
|
||||
|
||||
# Step 4: 为所有现有用户关联默认公司
|
||||
op.execute(f"""
|
||||
INSERT INTO sys_user_companies (id, user_id, company_id, is_default)
|
||||
SELECT gen_random_uuid(), id, '{DEFAULT_COMPANY_ID}'::uuid, true
|
||||
FROM sys_users
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 5: 创建 erp_sku_inventory 分公司库存表
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
op.create_table(
|
||||
'erp_sku_inventory',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('sku_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('erp_product_skus.id'), nullable=False),
|
||||
sa.Column('company_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sys_companies.id'), nullable=False),
|
||||
sa.Column('stock_qty', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('warning_threshold', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.UniqueConstraint('sku_id', 'company_id', name='uq_sku_company'),
|
||||
)
|
||||
op.create_index('ix_erp_sku_inventory_company_id', 'erp_sku_inventory', ['company_id'])
|
||||
|
||||
# Step 6: 迁移 erp_product_skus 的库存数据到 erp_sku_inventory
|
||||
op.execute(f"""
|
||||
INSERT INTO erp_sku_inventory (id, sku_id, company_id, stock_qty, warning_threshold)
|
||||
SELECT gen_random_uuid(), id, '{DEFAULT_COMPANY_ID}'::uuid, stock_qty, warning_threshold
|
||||
FROM erp_product_skus
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 7: 为业务表追加 company_id 列(先 nullable → 填数据 → set NOT NULL)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
tables_with_company_id = [
|
||||
'erp_orders',
|
||||
'erp_inventory_flows',
|
||||
'erp_shipping_records',
|
||||
'fin_invoice_pool',
|
||||
'fin_expense_records',
|
||||
'finance_sales_invoices',
|
||||
'sales_logs',
|
||||
]
|
||||
|
||||
for table in tables_with_company_id:
|
||||
# 添加列(先允许 NULL)
|
||||
op.add_column(table, sa.Column('company_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
|
||||
# 填入默认公司 ID
|
||||
op.execute(f"""
|
||||
UPDATE {table} SET company_id = '{DEFAULT_COMPANY_ID}'::uuid WHERE company_id IS NULL
|
||||
""")
|
||||
|
||||
# 设 NOT NULL
|
||||
op.alter_column(table, 'company_id', nullable=False)
|
||||
|
||||
# 创建外键
|
||||
op.create_foreign_key(
|
||||
f'fk_{table}_company_id',
|
||||
table, 'sys_companies',
|
||||
['company_id'], ['id'],
|
||||
)
|
||||
|
||||
# 创建索引
|
||||
op.create_index(f'ix_{table}_company_id', table, ['company_id'])
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 8: 从 erp_product_skus 删除已迁移的库存字段
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
op.drop_column('erp_product_skus', 'stock_qty')
|
||||
op.drop_column('erp_product_skus', 'warning_threshold')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复 erp_product_skus 的库存字段
|
||||
op.add_column('erp_product_skus', sa.Column('stock_qty', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False))
|
||||
op.add_column('erp_product_skus', sa.Column('warning_threshold', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False))
|
||||
|
||||
# 从 erp_sku_inventory 回迁默认公司的库存数据
|
||||
op.execute(f"""
|
||||
UPDATE erp_product_skus SET
|
||||
stock_qty = inv.stock_qty,
|
||||
warning_threshold = inv.warning_threshold
|
||||
FROM erp_sku_inventory inv
|
||||
WHERE erp_product_skus.id = inv.sku_id AND inv.company_id = '{DEFAULT_COMPANY_ID}'::uuid
|
||||
""")
|
||||
|
||||
# 删除 company_id 列
|
||||
tables_with_company_id = [
|
||||
'erp_orders', 'erp_inventory_flows', 'erp_shipping_records',
|
||||
'fin_invoice_pool', 'fin_expense_records', 'finance_sales_invoices', 'sales_logs',
|
||||
]
|
||||
for table in tables_with_company_id:
|
||||
op.drop_index(f'ix_{table}_company_id', table_name=table)
|
||||
op.drop_constraint(f'fk_{table}_company_id', table, type_='foreignkey')
|
||||
op.drop_column(table, 'company_id')
|
||||
|
||||
# 删除新建的表
|
||||
op.drop_index('ix_erp_sku_inventory_company_id', table_name='erp_sku_inventory')
|
||||
op.drop_table('erp_sku_inventory')
|
||||
op.drop_table('sys_user_companies')
|
||||
op.drop_table('sys_companies')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add xinyu lubricant company
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-03-19
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "b2c3d4e5f6a7"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
XINYU_COMPANY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0002"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 插入第二个公司:新宇润滑油
|
||||
op.execute(f"""
|
||||
INSERT INTO sys_companies (id, name, code, is_active)
|
||||
VALUES ('{XINYU_COMPANY_ID}', '新宇润滑油', 'XY-LUB', true)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""")
|
||||
|
||||
# 2. 将所有现有用户关联到新宇润滑油(非默认)
|
||||
op.execute(f"""
|
||||
INSERT INTO sys_user_companies (id, user_id, company_id, is_default)
|
||||
SELECT gen_random_uuid(), id, '{XINYU_COMPANY_ID}'::uuid, false
|
||||
FROM sys_users
|
||||
WHERE id NOT IN (
|
||||
SELECT user_id FROM sys_user_companies
|
||||
WHERE company_id = '{XINYU_COMPANY_ID}'::uuid
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(f"""
|
||||
DELETE FROM sys_user_companies WHERE company_id = '{XINYU_COMPANY_ID}'::uuid
|
||||
""")
|
||||
op.execute(f"""
|
||||
DELETE FROM sys_companies WHERE id = '{XINYU_COMPANY_ID}'::uuid
|
||||
""")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""sales_logs company_id to involved_company_ids
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-03-19
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "b2c3d4e5f6a7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
DEFAULT_COMPANY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0001"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 添加新列 involved_company_ids (ARRAY UUID)
|
||||
op.add_column(
|
||||
"sales_logs",
|
||||
sa.Column("involved_company_ids", ARRAY(UUID(as_uuid=True)), nullable=True)
|
||||
)
|
||||
|
||||
# 2. 数据迁移:将现有 company_id 转为数组
|
||||
op.execute("""
|
||||
UPDATE sales_logs
|
||||
SET involved_company_ids = ARRAY[company_id]
|
||||
WHERE company_id IS NOT NULL
|
||||
""")
|
||||
|
||||
# 3. 没有 company_id 的行(不太可能但防御性处理)
|
||||
op.execute(f"""
|
||||
UPDATE sales_logs
|
||||
SET involved_company_ids = ARRAY['{DEFAULT_COMPANY_ID}'::uuid]
|
||||
WHERE involved_company_ids IS NULL
|
||||
""")
|
||||
|
||||
# 4. 设置 NOT NULL
|
||||
op.alter_column("sales_logs", "involved_company_ids", nullable=False)
|
||||
|
||||
# 5. 删除旧的 company_id 列及其外键
|
||||
op.drop_constraint(
|
||||
"fk_sales_logs_company_id", "sales_logs", type_="foreignkey"
|
||||
)
|
||||
op.drop_index("ix_sales_logs_company_id", table_name="sales_logs", if_exists=True)
|
||||
op.drop_column("sales_logs", "company_id")
|
||||
|
||||
# 6. 为 involved_company_ids 创建 GIN 索引(支持 ANY/contains 查询)
|
||||
op.create_index(
|
||||
"ix_sales_logs_involved_company_ids",
|
||||
"sales_logs",
|
||||
["involved_company_ids"],
|
||||
postgresql_using="gin"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚:重新添加 company_id,从数组取第一个元素
|
||||
op.drop_index("ix_sales_logs_involved_company_ids", table_name="sales_logs")
|
||||
|
||||
op.add_column(
|
||||
"sales_logs",
|
||||
sa.Column("company_id", UUID(as_uuid=True), nullable=True)
|
||||
)
|
||||
|
||||
op.execute("""
|
||||
UPDATE sales_logs
|
||||
SET company_id = involved_company_ids[1]
|
||||
WHERE array_length(involved_company_ids, 1) > 0
|
||||
""")
|
||||
|
||||
op.alter_column("sales_logs", "company_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"sales_logs_company_id_fkey",
|
||||
"sales_logs", "sys_companies",
|
||||
["company_id"], ["id"]
|
||||
)
|
||||
op.create_index("ix_sales_logs_company_id", "sales_logs", ["company_id"])
|
||||
|
||||
op.drop_column("sales_logs", "involved_company_ids")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add billing_info to crm_customers
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "d4e5f6a7b8c9"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"crm_customers",
|
||||
sa.Column(
|
||||
"billing_info",
|
||||
JSONB,
|
||||
nullable=True,
|
||||
comment="客户开票信息: company_name/tax_id/address/phone/bank_name/bank_account",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("crm_customers", "billing_info")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Phase B: contract management + order/invoice linkage
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "e5f6a7b8c9d0"
|
||||
down_revision = "d4e5f6a7b8c9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── 1. sys_companies 新增 full_info ──
|
||||
op.add_column(
|
||||
"sys_companies",
|
||||
sa.Column("full_info", JSONB, nullable=True,
|
||||
comment="公司完整信息: full_name/address/phone/bank_name/bank_account/tax_id"),
|
||||
)
|
||||
|
||||
# ── 2. erp_contracts 主表 ──
|
||||
op.create_table(
|
||||
"erp_contracts",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("contract_no", sa.String(30), unique=True, nullable=False),
|
||||
sa.Column("buyer_customer_id", UUID(as_uuid=True), sa.ForeignKey("crm_customers.id"), nullable=False),
|
||||
sa.Column("seller_company_id", UUID(as_uuid=True), sa.ForeignKey("sys_companies.id"), nullable=False),
|
||||
sa.Column("company_id", UUID(as_uuid=True), sa.ForeignKey("sys_companies.id"), nullable=False, index=True),
|
||||
sa.Column("total_amount_excl_tax", sa.Numeric(14, 2), default=0),
|
||||
sa.Column("total_amount_incl_tax", sa.Numeric(14, 2), default=0),
|
||||
sa.Column("total_amount_cn", sa.String(100), nullable=True),
|
||||
sa.Column("payment_terms", sa.String(50), nullable=False, server_default="货到付全款"),
|
||||
sa.Column("shipping_terms", sa.String(50), nullable=False, server_default="买方自提"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
|
||||
sa.Column("is_signed", sa.Boolean, default=False, server_default="false"),
|
||||
sa.Column("signed_file_url", sa.String(500), nullable=True),
|
||||
sa.Column("linked_order_id", UUID(as_uuid=True), sa.ForeignKey("erp_orders.id"), nullable=True),
|
||||
sa.Column("salesperson_id", UUID(as_uuid=True), sa.ForeignKey("sys_users.id"), nullable=True),
|
||||
sa.Column("sign_date", sa.Date, nullable=True),
|
||||
sa.Column("remark", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("is_deleted", sa.Boolean, default=False, server_default="false"),
|
||||
)
|
||||
|
||||
# ── 3. erp_contract_items 明细行 ──
|
||||
op.create_table(
|
||||
"erp_contract_items",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("contract_id", UUID(as_uuid=True), sa.ForeignKey("erp_contracts.id"), nullable=False),
|
||||
sa.Column("sku_id", UUID(as_uuid=True), sa.ForeignKey("erp_product_skus.id"), nullable=False),
|
||||
sa.Column("qty", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("unit_price", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("sub_total", sa.Numeric(14, 2), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("is_deleted", sa.Boolean, default=False, server_default="false"),
|
||||
)
|
||||
|
||||
# ── 4. erp_contract_attachments 附件 ──
|
||||
op.create_table(
|
||||
"erp_contract_attachments",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("contract_id", UUID(as_uuid=True), sa.ForeignKey("erp_contracts.id"), nullable=False),
|
||||
sa.Column("file_name", sa.String(200), nullable=False),
|
||||
sa.Column("file_url", sa.String(500), nullable=False),
|
||||
sa.Column("file_type", sa.String(30), nullable=False, server_default="signed_copy"),
|
||||
sa.Column("uploader_id", UUID(as_uuid=True), sa.ForeignKey("sys_users.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("is_deleted", sa.Boolean, default=False, server_default="false"),
|
||||
)
|
||||
|
||||
# ── 5. erp_orders 新增 contract_id ──
|
||||
op.add_column(
|
||||
"erp_orders",
|
||||
sa.Column("contract_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("erp_contracts.id"), nullable=True,
|
||||
comment="来源合同(一键推单后回填)"),
|
||||
)
|
||||
|
||||
# ── 6. finance_sales_invoices 新增 order_id / shipping_record_id / payment_due_date ──
|
||||
op.add_column(
|
||||
"finance_sales_invoices",
|
||||
sa.Column("order_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("erp_orders.id"), nullable=True,
|
||||
comment="关联订单"),
|
||||
)
|
||||
op.add_column(
|
||||
"finance_sales_invoices",
|
||||
sa.Column("shipping_record_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("erp_shipping_records.id"), nullable=True,
|
||||
comment="关联发货单"),
|
||||
)
|
||||
op.add_column(
|
||||
"finance_sales_invoices",
|
||||
sa.Column("payment_due_date", sa.Date, nullable=True,
|
||||
comment="回款截止日(根据合同付款条件自动推算)"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("finance_sales_invoices", "payment_due_date")
|
||||
op.drop_column("finance_sales_invoices", "shipping_record_id")
|
||||
op.drop_column("finance_sales_invoices", "order_id")
|
||||
op.drop_column("erp_orders", "contract_id")
|
||||
op.drop_table("erp_contract_attachments")
|
||||
op.drop_table("erp_contract_items")
|
||||
op.drop_table("erp_contracts")
|
||||
op.drop_column("sys_companies", "full_info")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Phase C: MWA inventory + profit accounting
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "f6a7b8c9d0e1"
|
||||
down_revision = "e5f6a7b8c9d0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── 1. erp_sku_inventory 新增 mwa_unit_cost ──
|
||||
op.add_column(
|
||||
"erp_sku_inventory",
|
||||
sa.Column("mwa_unit_cost", sa.Numeric(12, 4), server_default="0",
|
||||
comment="移动加权均价 (Moving Weighted Average)"),
|
||||
)
|
||||
|
||||
# ── 2. erp_inventory_flows 新增 purchase_unit_price + is_special_zero_cost ──
|
||||
op.add_column(
|
||||
"erp_inventory_flows",
|
||||
sa.Column("purchase_unit_price", sa.Numeric(12, 2), server_default="0",
|
||||
comment="入库采购单价"),
|
||||
)
|
||||
op.add_column(
|
||||
"erp_inventory_flows",
|
||||
sa.Column("is_special_zero_cost", sa.Boolean, server_default="false",
|
||||
comment="特殊零元入库标识,不参与 MWA 计算"),
|
||||
)
|
||||
|
||||
# ── 3. erp_order_item_costs 新表 ──
|
||||
op.create_table(
|
||||
"erp_order_item_costs",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("order_item_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("erp_order_items.id"), nullable=False, unique=True),
|
||||
sa.Column("purchase_unit_price", sa.Numeric(12, 4), nullable=False,
|
||||
comment="MWA 成本快照"),
|
||||
sa.Column("profit_amount", sa.Numeric(14, 2), server_default="0",
|
||||
comment="利润额 = (售价-成本)*数量"),
|
||||
sa.Column("profit_rate", sa.Numeric(5, 4), server_default="0",
|
||||
comment="利润率"),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("erp_order_item_costs")
|
||||
op.drop_column("erp_inventory_flows", "is_special_zero_cost")
|
||||
op.drop_column("erp_inventory_flows", "purchase_unit_price")
|
||||
op.drop_column("erp_sku_inventory", "mwa_unit_cost")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Phase D: AI coaching engine (JSONB fields only, pgvector table deferred)
|
||||
|
||||
Revision ID: a7b8c9d0e1f2
|
||||
Revises: f6a7b8c9d0e1
|
||||
Create Date: 2026-03-27
|
||||
|
||||
Note: kb_obsidian_vectors (pgvector) 表需要先安装 postgresql-16-pgvector 包,
|
||||
安装后手动执行:
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
然后运行: alembic upgrade pgvector_head
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "a7b8c9d0e1f2"
|
||||
down_revision = "f6a7b8c9d0e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── 1. sales_logs 新增 ai_coaching_feedback ──
|
||||
op.add_column(
|
||||
"sales_logs",
|
||||
sa.Column("ai_coaching_feedback", JSONB, nullable=True,
|
||||
comment="AI 教练引擎回写的指导反馈"),
|
||||
)
|
||||
|
||||
# ── 2. crm_customers 新增 health_score / meddic_status ──
|
||||
op.add_column(
|
||||
"crm_customers",
|
||||
sa.Column("health_score", sa.Numeric(5, 2), server_default="0",
|
||||
comment="客户健康度评分 (AI 教练引擎计算)"),
|
||||
)
|
||||
op.add_column(
|
||||
"crm_customers",
|
||||
sa.Column("meddic_status", JSONB, nullable=True,
|
||||
comment="MEDDIC 六维评估状态"),
|
||||
)
|
||||
|
||||
# ── 3. kb_obsidian_vectors 表暂不在此迁移创建 ──
|
||||
# 需先安装 pgvector 扩展,见单独迁移脚本
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("crm_customers", "meddic_status")
|
||||
op.drop_column("crm_customers", "health_score")
|
||||
op.drop_column("sales_logs", "ai_coaching_feedback")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Phase D addon: pgvector kb_obsidian_vectors table
|
||||
|
||||
Revision ID: b8c9d0e1f2a3
|
||||
Revises: a7b8c9d0e1f2
|
||||
Create Date: 2026-03-27
|
||||
|
||||
Prerequisites: sudo apt-get install postgresql-16-pgvector
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "b8c9d0e1f2a3"
|
||||
down_revision = "a7b8c9d0e1f2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
op.create_table(
|
||||
"kb_obsidian_vectors",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("company_id", UUID(as_uuid=True), sa.ForeignKey("sys_companies.id"), nullable=False, index=True),
|
||||
sa.Column("source_path", sa.String(500), nullable=False, comment="源文件路径"),
|
||||
sa.Column("chunk_index", sa.SmallInteger, server_default="0"),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("metadata", JSONB, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("is_deleted", sa.Boolean, server_default="false"),
|
||||
)
|
||||
op.execute("ALTER TABLE kb_obsidian_vectors ADD COLUMN embedding vector(1536)")
|
||||
op.execute("""
|
||||
CREATE INDEX ix_kb_obsidian_vectors_embedding
|
||||
ON kb_obsidian_vectors
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_kb_obsidian_vectors_embedding")
|
||||
op.drop_table("kb_obsidian_vectors")
|
||||
op.execute("DROP EXTENSION IF EXISTS vector")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
AI 教练引擎路由 —— /api/ai-coaching
|
||||
Dify 回调 + SSE 通知流
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.services import ai_coaching_service as svc
|
||||
|
||||
router = APIRouter(prefix="/ai-coaching", tags=["AI教练引擎"])
|
||||
|
||||
|
||||
@router.post("/dify-callback/{sales_log_id}", summary="Dify Workflow 回调端点")
|
||||
async def dify_coaching_callback(
|
||||
sales_log_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""接收 Dify Workflow 的异步回调,写回教练反馈"""
|
||||
import json
|
||||
body = await request.json()
|
||||
await svc.handle_dify_coaching_callback(db, sales_log_id, body)
|
||||
return ok(message="教练反馈已回写")
|
||||
|
||||
|
||||
@router.get("/notifications/stream", summary="SSE 通知流")
|
||||
async def sse_notifications(
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
"""Server-Sent Events 推送通知(AI 教练反馈、系统通知等)"""
|
||||
return StreamingResponse(
|
||||
svc.sse_notification_generator(current_user.user_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
公司管理路由 —— /api/companies
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.models.sys import SysCompany, SysUserCompany
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
|
||||
router = APIRouter(prefix="/companies", tags=["公司管理"])
|
||||
|
||||
|
||||
@router.get("", summary="获取当前用户可访问的公司列表")
|
||||
async def list_companies(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""返回当前登录用户所关联的所有激活公司"""
|
||||
stmt = (
|
||||
select(SysCompany)
|
||||
.join(SysUserCompany, SysUserCompany.company_id == SysCompany.id)
|
||||
.where(
|
||||
SysUserCompany.user_id == current_user.user_id,
|
||||
SysCompany.is_active.is_(True),
|
||||
)
|
||||
.order_by(SysCompany.created_at)
|
||||
)
|
||||
companies = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
# 查该用户的默认公司
|
||||
default_stmt = (
|
||||
select(SysUserCompany.company_id)
|
||||
.where(
|
||||
SysUserCompany.user_id == current_user.user_id,
|
||||
SysUserCompany.is_default.is_(True),
|
||||
)
|
||||
)
|
||||
default_id = (await db.execute(default_stmt)).scalar_one_or_none()
|
||||
|
||||
return ok(data={
|
||||
"companies": [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"name": c.name,
|
||||
"code": c.code,
|
||||
"is_active": c.is_active,
|
||||
}
|
||||
for c in companies
|
||||
],
|
||||
"default_company_id": str(default_id) if default_id else (
|
||||
str(companies[0].id) if companies else None
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/current", summary="获取当前公司详情(含 full_info)")
|
||||
async def get_current_company(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
company = (await db.execute(
|
||||
select(SysCompany).where(SysCompany.id == company_id)
|
||||
)).scalar_one_or_none()
|
||||
if company is None:
|
||||
return ok(data=None)
|
||||
return ok(data={
|
||||
"id": str(company.id),
|
||||
"name": company.name,
|
||||
"code": company.code,
|
||||
"full_info": company.full_info or {},
|
||||
"is_active": company.is_active,
|
||||
})
|
||||
|
||||
|
||||
@router.put("/current", summary="更新当前公司信息(含 full_info)")
|
||||
async def update_current_company(
|
||||
body: dict = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
# 仅管理员可编辑
|
||||
if current_user.data_scope != "all":
|
||||
from app.core.exceptions import ForbiddenException
|
||||
raise ForbiddenException("仅管理员可编辑公司信息")
|
||||
|
||||
values: dict = {}
|
||||
if "name" in body:
|
||||
values["name"] = body["name"]
|
||||
if "full_info" in body:
|
||||
values["full_info"] = body["full_info"]
|
||||
if values:
|
||||
values["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(SysCompany).where(SysCompany.id == company_id).values(**values)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# 返回更新后的数据
|
||||
company = (await db.execute(
|
||||
select(SysCompany).where(SysCompany.id == company_id)
|
||||
)).scalar_one()
|
||||
return ok(data={
|
||||
"id": str(company.id),
|
||||
"name": company.name,
|
||||
"code": company.code,
|
||||
"full_info": company.full_info or {},
|
||||
"is_active": company.is_active,
|
||||
}, message="公司信息已更新")
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
合同管理路由 —— /api/contracts
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Body, Depends, Query, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.contract import ContractCreate, ContractUpdate
|
||||
from app.schemas.response import ok
|
||||
from app.services import contract_service as svc
|
||||
|
||||
router = APIRouter(prefix="/contracts", tags=["合同管理"])
|
||||
|
||||
|
||||
@router.post("", summary="新增合同")
|
||||
async def create_contract(
|
||||
body: ContractCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.create_contract(db, current_user, company_id, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="合同创建成功")
|
||||
|
||||
|
||||
@router.get("", summary="合同列表(分页)")
|
||||
async def list_contracts(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
keyword: str | None = Query(None, description="合同编号搜索"),
|
||||
status: str | None = Query(None, description="状态筛选"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_contracts(db, company_id, page, size, keyword, status)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{contract_id}", summary="合同详情(含执行进度)")
|
||||
async def get_contract(
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.get_contract(db, contract_id, company_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/{contract_id}", summary="编辑合同")
|
||||
async def update_contract(
|
||||
contract_id: uuid.UUID,
|
||||
body: ContractUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.update_contract(db, contract_id, company_id, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="合同已更新")
|
||||
|
||||
|
||||
@router.delete("/{contract_id}", summary="删除合同")
|
||||
async def delete_contract(
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
await svc.delete_contract(db, contract_id, company_id)
|
||||
return ok(message="合同已删除")
|
||||
|
||||
|
||||
@router.post("/{contract_id}/generate-order", summary="一键从合同生成订单")
|
||||
async def generate_order_from_contract(
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.generate_order_from_contract(db, current_user, contract_id, company_id)
|
||||
return ok(data=result, message="订单生成成功")
|
||||
|
||||
|
||||
@router.get("/{contract_id}/generate", summary="生成合同 Word 文档下载")
|
||||
async def generate_contract_document(
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
):
|
||||
from fastapi.responses import Response
|
||||
docx_bytes = await svc.generate_contract_docx(db, contract_id, company_id)
|
||||
return Response(
|
||||
content=docx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename=contract_{contract_id}.docx"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{contract_id}/upload-signed", summary="上传双签盖章版")
|
||||
async def upload_signed_copy(
|
||||
contract_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
import os
|
||||
from app.models.contract import ErpContract, ErpContractAttachment
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
# 验证合同存在
|
||||
from sqlalchemy import select as sa_select
|
||||
contract = (await db.execute(
|
||||
sa_select(ErpContract).where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise Exception("合同不存在")
|
||||
|
||||
# 保存文件
|
||||
upload_dir = f"uploads/contracts/{contract_id}"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
file_path = f"{upload_dir}/{file.filename}"
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
file_url = f"/{file_path}"
|
||||
|
||||
# 记录附件
|
||||
attachment = ErpContractAttachment(
|
||||
contract_id=contract_id,
|
||||
file_name=file.filename or "signed_copy",
|
||||
file_url=file_url,
|
||||
file_type="signed_copy",
|
||||
uploader_id=current_user.user_id,
|
||||
)
|
||||
db.add(attachment)
|
||||
|
||||
# 更新合同签署状态
|
||||
await db.execute(
|
||||
sa_update(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.values(is_signed=True, signed_file_url=file_url)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ok(message="双签盖章版上传成功", data={"file_url": file_url})
|
||||
@@ -4,7 +4,7 @@ CRM 客户模块路由 —— /api/customers
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi import APIRouter, Body, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
@@ -91,6 +91,20 @@ async def restore_customer(
|
||||
return ok(message="客户已恢复")
|
||||
|
||||
|
||||
@router.put("/{customer_id}/transfer", summary="转移客户负责人(仅管理员)")
|
||||
async def transfer_customer(
|
||||
customer_id: uuid.UUID,
|
||||
body: dict = Body(..., examples=[{"new_owner_id": "uuid-here"}]),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
new_owner_id = body.get("new_owner_id")
|
||||
if not new_owner_id:
|
||||
raise Exception("缺少 new_owner_id 参数")
|
||||
result = await svc.transfer_customer(db, current_user, customer_id, uuid.UUID(str(new_owner_id)))
|
||||
return ok(data=result.model_dump(mode="json"), message="客户转移成功")
|
||||
|
||||
|
||||
@router.get("/{customer_id}/products", summary="获取客户关联产品(通过订单反查)")
|
||||
async def get_customer_products(
|
||||
customer_id: uuid.UUID,
|
||||
|
||||
+15
-10
@@ -3,20 +3,21 @@ Dashboard 统计 API — /api/dashboard
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select, and_, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
|
||||
from app.models.order import ErpOrder
|
||||
from app.models.shipping import ErpShippingRecord
|
||||
from app.models.erp import ProductSku
|
||||
from app.models.erp import ErpSkuInventory
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
@@ -25,42 +26,46 @@ router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
async def get_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
):
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# 本月新增订单数
|
||||
# 本月新增订单数(按公司隔离)
|
||||
orders_count_q = select(func.count()).select_from(ErpOrder).where(
|
||||
and_(
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.company_id == company_id,
|
||||
ErpOrder.order_date >= month_start,
|
||||
)
|
||||
)
|
||||
orders_count = (await db.execute(orders_count_q)).scalar() or 0
|
||||
|
||||
# 待出库发货数(状态为 pending)
|
||||
# 待出库发货数(按公司隔离)
|
||||
pending_shipping_q = select(func.count()).select_from(ErpOrder).where(
|
||||
and_(
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.company_id == company_id,
|
||||
ErpOrder.shipping_state == "pending",
|
||||
)
|
||||
)
|
||||
pending_shipping = (await db.execute(pending_shipping_q)).scalar() or 0
|
||||
|
||||
# 库存预警 SKU 数(stock_qty <= warning_threshold 且 warning_threshold > 0)
|
||||
warning_skus_q = select(func.count()).select_from(ProductSku).where(
|
||||
# 库存预警 SKU 数(从 erp_sku_inventory 查,按公司隔离)
|
||||
warning_skus_q = select(func.count()).select_from(ErpSkuInventory).where(
|
||||
and_(
|
||||
ProductSku.is_deleted.is_(False),
|
||||
ProductSku.warning_threshold > 0,
|
||||
ProductSku.stock_qty <= ProductSku.warning_threshold,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
ErpSkuInventory.warning_threshold > 0,
|
||||
ErpSkuInventory.stock_qty <= ErpSkuInventory.warning_threshold,
|
||||
)
|
||||
)
|
||||
warning_skus = (await db.execute(warning_skus_q)).scalar() or 0
|
||||
|
||||
# 本月预计营收(本月订单总金额)
|
||||
# 本月预计营收(按公司隔离)
|
||||
revenue_q = select(func.coalesce(func.sum(ErpOrder.total_amount), 0)).where(
|
||||
and_(
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.company_id == company_id,
|
||||
ErpOrder.order_date >= month_start,
|
||||
)
|
||||
)
|
||||
|
||||
+47
-2
@@ -1,18 +1,21 @@
|
||||
"""
|
||||
FastAPI 依赖注入 —— 权限拦截核心
|
||||
get_current_user: 解析 JWT → 查表获取完整权限上下文
|
||||
get_current_company_id: 从 X-Company-Id Header 提取公司 ID + IDOR 校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import UnauthorizedException
|
||||
from app.core.exceptions import ForbiddenException, UnauthorizedException
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.database import get_db
|
||||
from app.models.sys import SysUser
|
||||
from app.models.sys import SysCompany, SysUser, SysUserCompany
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
|
||||
|
||||
@@ -65,3 +68,45 @@ async def get_current_user(
|
||||
data_scope=user.role.data_scope if user.role else "self",
|
||||
menu_keys=user.role.menu_keys if user.role else [],
|
||||
)
|
||||
|
||||
|
||||
async def get_current_company_id(
|
||||
x_company_id: str = Header(..., alias="X-Company-Id", description="当前工作台的公司 ID"),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> uuid.UUID:
|
||||
"""
|
||||
公司视角依赖(IDOR 防护核心):
|
||||
1. 从 X-Company-Id Header 提取公司 UUID
|
||||
2. 校验当前用户是否归属于该公司(查 sys_user_companies)
|
||||
3. 校验公司是否启用
|
||||
"""
|
||||
# ── 解析 company_id ──
|
||||
try:
|
||||
company_uuid = uuid.UUID(x_company_id)
|
||||
except ValueError:
|
||||
raise UnauthorizedException("X-Company-Id 格式错误,需为合法 UUID")
|
||||
|
||||
# ── IDOR 防护:校验用户-公司归属 ──
|
||||
assoc = (await db.execute(
|
||||
select(SysUserCompany).where(
|
||||
SysUserCompany.user_id == current_user.user_id,
|
||||
SysUserCompany.company_id == company_uuid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if assoc is None:
|
||||
raise ForbiddenException("您无权访问该公司数据")
|
||||
|
||||
# ── 校验公司是否启用 ──
|
||||
company = (await db.execute(
|
||||
select(SysCompany).where(
|
||||
SysCompany.id == company_uuid,
|
||||
SysCompany.is_active.is_(True),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if company is None:
|
||||
raise ForbiddenException("公司不存在或已停用")
|
||||
|
||||
return company_uuid
|
||||
|
||||
+403
-17
@@ -9,7 +9,7 @@ import time
|
||||
import base64
|
||||
from fastapi import APIRouter, Depends, Query, Body, File, UploadFile, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.finance import ExpenseCreate, ExpenseStatusUpdate, InvoiceCreate
|
||||
@@ -43,34 +43,96 @@ async def ocr_recognize(
|
||||
|
||||
file_url = f"/uploads/finance/{safe_filename}"
|
||||
|
||||
# 仅支持图片(png/jpg/jpeg)和 PDF,不再支持 MD/TXT
|
||||
supported = {".png", ".jpg", ".jpeg", ".pdf"}
|
||||
# 支持的格式:结构化零算力 > 文本 LLM > 图片 Vision
|
||||
supported = {".png", ".jpg", ".jpeg", ".pdf", ".md", ".ofd", ".xml", ".zip"}
|
||||
if ext not in supported:
|
||||
raise BizException(message=f"不支持的文件格式 {ext},仅支持: {', '.join(supported)}")
|
||||
raise BizException(message=f"不支持的文件格式 {ext},仅支持: {', '.join(sorted(supported))}")
|
||||
|
||||
# 如果是 PDF,转成 PNG 再做 OCR
|
||||
ocr_bytes = file_bytes
|
||||
# ── 策略 A0: ZIP → 解包所有 XML 并逐个解析 ──
|
||||
if ext == ".zip":
|
||||
from app.services.invoice_parser import parse_zip_invoices
|
||||
results = parse_zip_invoices(file_bytes)
|
||||
return ok(data={"zip_results": [
|
||||
{"filename": r.get("filename", ""), "success": r.get("success", False),
|
||||
"ocr_data": r.get("data", {}), "needs_llm": r.get("needs_llm", False),
|
||||
"error": r.get("error")}
|
||||
for r in results
|
||||
], "file_url": file_url}, message=f"ZIP 解析完成:{sum(1 for r in results if r.get('success'))}/{len(results)} 成功")
|
||||
|
||||
# ── 策略 A: OFD / XML → 结构化零算力提取(最快最准)──
|
||||
if ext in (".ofd", ".xml"):
|
||||
from app.services.invoice_parser import parse_ofd_invoice, parse_xml_invoice
|
||||
parser = parse_ofd_invoice if ext == ".ofd" else parse_xml_invoice
|
||||
result = parser(file_bytes)
|
||||
print(f"[OCR] {ext.upper()} 解析: success={result.get('success')}")
|
||||
|
||||
if result.get("success"):
|
||||
# 如果解析器提取到 raw_text 且标记 needs_llm,交给 LLM 做字段提取
|
||||
if result.get("needs_llm") and result["data"].get("raw_text"):
|
||||
from app.services.ocr_service import extract_invoice_from_text
|
||||
llm_result = await extract_invoice_from_text(result["data"]["raw_text"], scene)
|
||||
if llm_result.get("success"):
|
||||
return ok(data={"ocr_data": llm_result["data"], "file_url": file_url}, message=f"AI 发票识别成功({ext.upper()} → LLM)")
|
||||
return ok(data={"ocr_data": llm_result.get("data", {}), "file_url": file_url}, message=llm_result.get("error", "LLM 解析失败"))
|
||||
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message=f"发票识别成功({ext.upper()} 结构化提取)")
|
||||
return ok(data={"ocr_data": {}, "file_url": file_url}, message=result.get("error", f"{ext.upper()} 解析失败"))
|
||||
|
||||
# ── 策略 B: MD → 纯文本 LLM 理解(零 GPU Vision)──
|
||||
if ext == ".md":
|
||||
text = file_bytes.decode("utf-8", errors="replace").strip()
|
||||
print(f"[OCR] MD 文本: {len(text)} 字符")
|
||||
if len(text) < 20:
|
||||
return ok(data={"ocr_data": {}, "file_url": file_url}, message="MD 文件内容过少,无法识别")
|
||||
from app.services.ocr_service import extract_invoice_from_text
|
||||
result = await extract_invoice_from_text(text, scene)
|
||||
if result.get("success"):
|
||||
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message="AI 发票识别成功(MD 文本解析)")
|
||||
return ok(data={"ocr_data": result.get("data", {}), "file_url": file_url}, message=result.get("error", "MD 文本解析失败"))
|
||||
|
||||
# ── 策略 C: PDF → PyMuPDF 提取文本 → LLM(零 GPU Vision)──
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
||||
page = doc[0] # 取第一页
|
||||
# 中等分辨率渲染(150 DPI,平衡质量与大小)
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text() + "\n"
|
||||
doc.close()
|
||||
text = text.strip()
|
||||
print(f"[OCR] PDF 文本提取: {len(text)} 字符")
|
||||
|
||||
if len(text) > 50: # 有足够文本内容
|
||||
from app.services.ocr_service import extract_invoice_from_text
|
||||
result = await extract_invoice_from_text(text, scene)
|
||||
if result.get("success"):
|
||||
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message="AI 发票识别成功(PDF 文本解析)")
|
||||
return ok(data={"ocr_data": result.get("data", {}), "file_url": file_url}, message=result.get("error", "PDF 文本提取失败"))
|
||||
else:
|
||||
# PDF 是扫描件(无文字层),降级到图片 OCR
|
||||
print(f"[OCR] PDF 无文本层(仅 {len(text)} 字符),降级到图片 OCR")
|
||||
page = fitz.open(stream=file_bytes, filetype="pdf")[0]
|
||||
pix = page.get_pixmap(dpi=150)
|
||||
ocr_bytes = pix.tobytes("png")
|
||||
doc.close()
|
||||
print(f"[OCR] PDF 转 PNG 成功: {len(ocr_bytes)} bytes")
|
||||
except Exception as e:
|
||||
print(f"[OCR] PDF 转换失败: {e}")
|
||||
return ok(data={"ocr_data": {}, "file_url": file_url}, message=f"PDF 转换失败: {e}")
|
||||
print(f"[OCR] PDF 处理失败: {e}")
|
||||
return ok(data={"ocr_data": {}, "file_url": file_url}, message=f"PDF 处理失败: {e}")
|
||||
else:
|
||||
ocr_bytes = file_bytes
|
||||
|
||||
# 转换为纯 base64 传给 OCR
|
||||
# ── 策略 D: 图片/扫描PDF → Vision OCR(需要视觉模型)──
|
||||
from app.services.ocr_service import ocr_image
|
||||
image_base64 = base64.b64encode(ocr_bytes).decode("utf-8")
|
||||
result = await ocr_image(image_base64, scene)
|
||||
|
||||
if result.get("success"):
|
||||
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message="AI OCR 识别成功")
|
||||
return ok(data={"ocr_data": result.get("data", {}), "file_url": file_url}, message=result.get("error", "OCR 识别失败"))
|
||||
|
||||
# Vision 失败时友好提示
|
||||
error_msg = result.get("error", "OCR 识别失败")
|
||||
if "模型进程崩溃" in error_msg or "unexpectedly stopped" in error_msg or "服务异常" in error_msg:
|
||||
error_msg += "。建议:请上传电子版 PDF/OFD/XML 发票,系统可零算力直接提取数据"
|
||||
return ok(data={"ocr_data": {}, "file_url": file_url}, message=error_msg)
|
||||
|
||||
|
||||
@router.post("/invoices", summary="上传票据入池(含 AI/OCR JSONB 数据)")
|
||||
@@ -78,8 +140,9 @@ async def create_invoice(
|
||||
body: InvoiceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.create_invoice(db, current_user, body)
|
||||
result = await svc.create_invoice(db, current_user, body, company_id)
|
||||
return ok(data=result.model_dump(mode="json"), message="票据入池成功")
|
||||
|
||||
|
||||
@@ -91,8 +154,9 @@ async def list_invoices(
|
||||
is_used: bool | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_invoices(db, current_user, page, size, type, is_used)
|
||||
result = await svc.list_invoices(db, current_user, page, size, type, is_used, company_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -111,8 +175,9 @@ async def create_expense(
|
||||
body: ExpenseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.create_expense(db, current_user, body)
|
||||
result = await svc.create_expense(db, current_user, body, company_id)
|
||||
return ok(data=result.model_dump(mode="json"), message=f"报销单 {result.system_no} 提交成功")
|
||||
|
||||
|
||||
@@ -124,8 +189,9 @@ async def list_expenses(
|
||||
applicant_id: uuid.UUID | None = Query(None, description="按申请人过滤(管理员用)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_expenses(db, current_user, page, size, status, applicant_id)
|
||||
result = await svc.list_expenses(db, current_user, page, size, status, applicant_id, company_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -148,3 +214,323 @@ async def update_expense_status(
|
||||
) -> dict:
|
||||
msg = await svc.update_expense_status(db, current_user, expense_id, body)
|
||||
return ok(message=msg)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 批量上传 + OCR 任务队列 API
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/upload-batch", summary="批量上传发票(ZIP/XML 即时入池,图片PDF 入队列)")
|
||||
async def upload_batch(
|
||||
files: list[UploadFile] = File(...),
|
||||
scene: str = Form("invoice"),
|
||||
inv_type: str = Form("expense"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
from app.services.invoice_parser import parse_xml_invoice, parse_ofd_invoice, parse_zip_invoices
|
||||
from app.services.ocr_service import extract_invoice_from_text
|
||||
from app.models.finance import FinInvoicePool, FinOcrTask
|
||||
|
||||
upload_dir = "uploads/finance"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
results = [] # 返回给前端
|
||||
|
||||
for file in files:
|
||||
file_bytes = await file.read()
|
||||
ext = os.path.splitext(file.filename or "")[1].lower() or ".bin"
|
||||
ts = int(time.time())
|
||||
safe_fn = f"{ts}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
file_path = os.path.join(upload_dir, safe_fn)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
file_url = f"/uploads/finance/{safe_fn}"
|
||||
|
||||
# ── ZIP: 解压内部 XML,逐个即时入池 ──
|
||||
if ext == ".zip":
|
||||
zip_results = parse_zip_invoices(file_bytes)
|
||||
for zr in zip_results:
|
||||
if zr.get("success") and not zr.get("needs_llm"):
|
||||
ai_data = zr.get("data", {})
|
||||
# 需要 LLM 的 zip 中的 xml 也立刻处理
|
||||
merchant = ai_data.get("merchant") or ai_data.get("merchant_name") or "(ZIP)"
|
||||
amount = float(ai_data.get("amount", 0) or 0)
|
||||
inv_date_str = ai_data.get("date")
|
||||
inv_date = None
|
||||
if inv_date_str:
|
||||
try:
|
||||
from datetime import date as d
|
||||
inv_date = d.fromisoformat(inv_date_str)
|
||||
except ValueError:
|
||||
pass
|
||||
inv = FinInvoicePool(
|
||||
uploader_id=current_user.user_id, company_id=company_id,
|
||||
file_url=file_url, merchant_name=merchant, amount=amount,
|
||||
invoice_date=inv_date, type=inv_type, ai_extracted_data=ai_data,
|
||||
)
|
||||
db.add(inv)
|
||||
results.append({"filename": zr.get("filename", file.filename), "action": "pooled",
|
||||
"status": "success", "message": f"✅ {merchant} ¥{amount}"})
|
||||
elif zr.get("needs_llm") and zr.get("data", {}).get("raw_text"):
|
||||
# LLM 文本理解(即时,<5s)
|
||||
try:
|
||||
llm_r = await extract_invoice_from_text(zr["data"]["raw_text"], scene)
|
||||
if llm_r.get("success"):
|
||||
ai_data = llm_r["data"]
|
||||
merchant = ai_data.get("merchant") or "(LLM)"
|
||||
amount = float(ai_data.get("amount", 0) or 0)
|
||||
inv = FinInvoicePool(
|
||||
uploader_id=current_user.user_id, company_id=company_id,
|
||||
file_url=file_url, merchant_name=merchant, amount=amount,
|
||||
type=inv_type, ai_extracted_data=ai_data,
|
||||
)
|
||||
db.add(inv)
|
||||
results.append({"filename": zr.get("filename"), "action": "pooled",
|
||||
"status": "success", "message": f"✅ {merchant} ¥{amount} (LLM)"})
|
||||
else:
|
||||
results.append({"filename": zr.get("filename"), "action": "failed",
|
||||
"status": "error", "message": llm_r.get("error", "LLM 解析失败")})
|
||||
except Exception as e:
|
||||
results.append({"filename": zr.get("filename"), "action": "failed",
|
||||
"status": "error", "message": str(e)})
|
||||
else:
|
||||
results.append({"filename": zr.get("filename", file.filename), "action": "failed",
|
||||
"status": "error", "message": zr.get("error", "解析失败")})
|
||||
continue
|
||||
|
||||
# ── XML / OFD: 零算力即时入池 ──
|
||||
if ext in (".xml", ".ofd"):
|
||||
parser = parse_xml_invoice if ext == ".xml" else parse_ofd_invoice
|
||||
r = parser(file_bytes)
|
||||
if r.get("success") and not r.get("needs_llm"):
|
||||
ai_data = r.get("data", {})
|
||||
merchant = ai_data.get("merchant") or ai_data.get("merchant_name") or "(解析)"
|
||||
amount = float(ai_data.get("amount", 0) or 0)
|
||||
inv_date_str = ai_data.get("date")
|
||||
inv_date = None
|
||||
if inv_date_str:
|
||||
try:
|
||||
from datetime import date as d
|
||||
inv_date = d.fromisoformat(inv_date_str)
|
||||
except ValueError:
|
||||
pass
|
||||
inv = FinInvoicePool(
|
||||
uploader_id=current_user.user_id, company_id=company_id,
|
||||
file_url=file_url, merchant_name=merchant, amount=amount,
|
||||
invoice_date=inv_date, type=inv_type, ai_extracted_data=ai_data,
|
||||
)
|
||||
db.add(inv)
|
||||
results.append({"filename": file.filename, "action": "pooled",
|
||||
"status": "success", "message": f"✅ {merchant} ¥{amount}"})
|
||||
elif r.get("needs_llm") and r.get("data", {}).get("raw_text"):
|
||||
try:
|
||||
llm_r = await extract_invoice_from_text(r["data"]["raw_text"], scene)
|
||||
if llm_r.get("success"):
|
||||
ai_data = llm_r["data"]
|
||||
merchant = ai_data.get("merchant") or "(LLM)"
|
||||
amount = float(ai_data.get("amount", 0) or 0)
|
||||
inv = FinInvoicePool(
|
||||
uploader_id=current_user.user_id, company_id=company_id,
|
||||
file_url=file_url, merchant_name=merchant, amount=amount,
|
||||
type=inv_type, ai_extracted_data=ai_data,
|
||||
)
|
||||
db.add(inv)
|
||||
results.append({"filename": file.filename, "action": "pooled",
|
||||
"status": "success", "message": f"✅ {merchant} ¥{amount} (LLM)"})
|
||||
else:
|
||||
results.append({"filename": file.filename, "action": "failed",
|
||||
"status": "error", "message": llm_r.get("error", "LLM 失败")})
|
||||
except Exception as e:
|
||||
results.append({"filename": file.filename, "action": "failed",
|
||||
"status": "error", "message": str(e)})
|
||||
else:
|
||||
results.append({"filename": file.filename, "action": "failed",
|
||||
"status": "error", "message": r.get("error", "解析失败")})
|
||||
continue
|
||||
|
||||
# ── 图片 / PDF : 写入 DB 任务队列 ──
|
||||
task = FinOcrTask(
|
||||
file_url=file_url, file_ext=ext,
|
||||
original_name=file.filename or "unknown",
|
||||
uploader_id=current_user.user_id,
|
||||
company_id=company_id,
|
||||
inv_type=inv_type,
|
||||
priority=50 if ext == ".pdf" else 100, # PDF 优先(可能有文字层)
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
results.append({"filename": file.filename, "action": "queued",
|
||||
"status": "pending", "task_id": str(task.id),
|
||||
"message": "🕐 已加入 OCR 处理队列"})
|
||||
|
||||
await db.commit()
|
||||
|
||||
pooled = sum(1 for r in results if r["action"] == "pooled")
|
||||
queued = sum(1 for r in results if r["action"] == "queued")
|
||||
failed = sum(1 for r in results if r["action"] == "failed")
|
||||
return ok(data={"results": results},
|
||||
message=f"批量处理完成:{pooled} 即时入池,{queued} 排队中,{failed} 失败")
|
||||
|
||||
|
||||
@router.get("/ocr-tasks", summary="OCR 任务队列列表")
|
||||
async def list_ocr_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
status: str | None = Query(None, description="pending/processing/success/failed/manual"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
from sqlalchemy import func, select
|
||||
from app.models.finance import FinOcrTask
|
||||
|
||||
where = [FinOcrTask.company_id == company_id, FinOcrTask.is_deleted.is_(False)]
|
||||
if current_user.data_scope == "self":
|
||||
where.append(FinOcrTask.uploader_id == current_user.user_id)
|
||||
if status:
|
||||
where.append(FinOcrTask.status == status)
|
||||
|
||||
total = (await db.execute(select(func.count()).select_from(FinOcrTask).where(*where))).scalar() or 0
|
||||
stmt = (
|
||||
select(FinOcrTask).where(*where)
|
||||
.order_by(FinOcrTask.priority, FinOcrTask.created_at.desc())
|
||||
.offset((page - 1) * size).limit(size)
|
||||
)
|
||||
tasks = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return ok(data={
|
||||
"total": total, "page": page, "size": size,
|
||||
"items": [{
|
||||
"id": str(t.id),
|
||||
"original_name": t.original_name,
|
||||
"file_ext": t.file_ext,
|
||||
"file_url": t.file_url,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"retry_count": t.retry_count,
|
||||
"max_retries": t.max_retries,
|
||||
"error_message": t.error_message,
|
||||
"ocr_result": t.ocr_result,
|
||||
"invoice_pool_id": str(t.invoice_pool_id) if t.invoice_pool_id else None,
|
||||
"uploader_name": t.uploader.real_name if t.uploader else None,
|
||||
"inv_type": t.inv_type,
|
||||
"created_at": str(t.created_at),
|
||||
"updated_at": str(t.updated_at),
|
||||
} for t in tasks],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/ocr-tasks/{task_id}/retry", summary="重试失败的 OCR 任务")
|
||||
async def retry_ocr_task(
|
||||
task_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from sqlalchemy import select, update
|
||||
from app.models.finance import FinOcrTask
|
||||
|
||||
task = (await db.execute(
|
||||
select(FinOcrTask).where(FinOcrTask.id == task_id, FinOcrTask.is_deleted.is_(False))
|
||||
)).scalar_one_or_none()
|
||||
if not task:
|
||||
raise BizException(message="任务不存在")
|
||||
if task.status not in ("failed", "manual"):
|
||||
raise BizException(message=f"当前状态 [{task.status}] 不允许重试")
|
||||
|
||||
task.status = "pending"
|
||||
task.retry_count = 0
|
||||
task.error_message = None
|
||||
await db.commit()
|
||||
return ok(message="任务已重新入队")
|
||||
|
||||
|
||||
@router.post("/ocr-tasks/{task_id}/manual", summary="手动录入 OCR 结果并入池")
|
||||
async def manual_ocr_task(
|
||||
task_id: uuid.UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from sqlalchemy import select
|
||||
from app.models.finance import FinOcrTask, FinInvoicePool
|
||||
|
||||
task = (await db.execute(
|
||||
select(FinOcrTask).where(FinOcrTask.id == task_id, FinOcrTask.is_deleted.is_(False))
|
||||
)).scalar_one_or_none()
|
||||
if not task:
|
||||
raise BizException(message="任务不存在")
|
||||
|
||||
merchant = body.get("merchant_name", "手动录入")
|
||||
amount = float(body.get("amount", 0))
|
||||
inv_date_str = body.get("invoice_date")
|
||||
inv_date = None
|
||||
if inv_date_str:
|
||||
try:
|
||||
from datetime import date as d
|
||||
inv_date = d.fromisoformat(inv_date_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
inv = FinInvoicePool(
|
||||
uploader_id=task.uploader_id, company_id=task.company_id,
|
||||
file_url=task.file_url, merchant_name=merchant, amount=amount,
|
||||
invoice_date=inv_date, type=task.inv_type, ai_extracted_data=body,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
|
||||
task.status = "manual"
|
||||
task.invoice_pool_id = inv.id
|
||||
task.ocr_result = body
|
||||
task.error_message = None
|
||||
await db.commit()
|
||||
|
||||
return ok(data={"invoice_pool_id": str(inv.id)}, message="手动录入成功,发票已入池")
|
||||
|
||||
|
||||
@router.put("/ocr-tasks/{task_id}/priority", summary="调整 OCR 任务优先级")
|
||||
async def update_ocr_task_priority(
|
||||
task_id: uuid.UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from sqlalchemy import select
|
||||
from app.models.finance import FinOcrTask
|
||||
|
||||
task = (await db.execute(
|
||||
select(FinOcrTask).where(FinOcrTask.id == task_id, FinOcrTask.is_deleted.is_(False))
|
||||
)).scalar_one_or_none()
|
||||
if not task:
|
||||
raise BizException(message="任务不存在")
|
||||
if task.status not in ("pending",):
|
||||
raise BizException(message="仅待处理任务可调整优先级")
|
||||
|
||||
new_priority = body.get("priority", task.priority)
|
||||
task.priority = int(new_priority)
|
||||
await db.commit()
|
||||
return ok(message=f"优先级已调整为 {task.priority}")
|
||||
|
||||
|
||||
@router.delete("/ocr-tasks/{task_id}", summary="取消/删除 OCR 任务")
|
||||
async def delete_ocr_task(
|
||||
task_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from sqlalchemy import select
|
||||
from app.models.finance import FinOcrTask
|
||||
|
||||
task = (await db.execute(
|
||||
select(FinOcrTask).where(FinOcrTask.id == task_id, FinOcrTask.is_deleted.is_(False))
|
||||
)).scalar_one_or_none()
|
||||
if not task:
|
||||
raise BizException(message="任务不存在")
|
||||
if task.status == "processing":
|
||||
raise BizException(message="正在处理中的任务无法取消")
|
||||
|
||||
task.is_deleted = True
|
||||
await db.commit()
|
||||
return ok(message="任务已取消")
|
||||
|
||||
@@ -56,7 +56,7 @@ async def import_products(
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
from openpyxl import load_workbook
|
||||
from app.models.erp import ErpProductSku
|
||||
from app.models.erp import ProductSku
|
||||
|
||||
content = await file.read()
|
||||
wb = load_workbook(io.BytesIO(content))
|
||||
@@ -79,7 +79,6 @@ async def import_products(
|
||||
spec = str(row[2] or "").strip() or None
|
||||
standard_price = float(row[3] or 0)
|
||||
unit = str(row[4] or "桶").strip()
|
||||
warning_threshold = float(row[5] or 0)
|
||||
|
||||
if not sku_code or not name:
|
||||
skipped += 1
|
||||
@@ -87,22 +86,21 @@ async def import_products(
|
||||
|
||||
# 检查 sku_code 是否已存在
|
||||
exists = (await db.execute(
|
||||
select(func.count()).select_from(ErpProductSku).where(
|
||||
ErpProductSku.sku_code == sku_code,
|
||||
ErpProductSku.is_deleted.is_(False),
|
||||
select(func.count()).select_from(ProductSku).where(
|
||||
ProductSku.sku_code == sku_code,
|
||||
ProductSku.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sku = ErpProductSku(
|
||||
sku = ProductSku(
|
||||
sku_code=sku_code,
|
||||
name=name,
|
||||
spec=spec,
|
||||
standard_price=standard_price,
|
||||
unit=unit,
|
||||
warning_threshold=warning_threshold,
|
||||
)
|
||||
db.add(sku)
|
||||
created += 1
|
||||
|
||||
+338
-4
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.order import OrderCreate
|
||||
@@ -32,8 +32,9 @@ async def create_order(
|
||||
body: OrderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.create_order(db, current_user, body)
|
||||
result = await svc.create_order(db, current_user, body, company_id)
|
||||
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
|
||||
|
||||
|
||||
@@ -47,16 +48,349 @@ async def list_orders(
|
||||
keyword: str | None = Query(None, description="模糊搜索订单号"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_orders(db, current_user, page, size, customer_id, shipping_state, payment_state, keyword)
|
||||
result = await svc.list_orders(db, current_user, page, size, customer_id, shipping_state, payment_state, keyword, company_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/unlinked-invoices", summary="查询未关联订单的发票列表")
|
||||
async def list_unlinked_invoices(
|
||||
keyword: str | None = Query(None, description="发票号模糊搜索"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
from sqlalchemy import select
|
||||
from app.models.finance import FinSalesInvoice
|
||||
conditions = [
|
||||
FinSalesInvoice.company_id == company_id,
|
||||
FinSalesInvoice.is_deleted.is_(False),
|
||||
FinSalesInvoice.order_id.is_(None),
|
||||
]
|
||||
if keyword:
|
||||
conditions.append(FinSalesInvoice.invoice_number.ilike(f"%{keyword}%"))
|
||||
|
||||
stmt = (
|
||||
select(FinSalesInvoice)
|
||||
.where(*conditions)
|
||||
.order_by(FinSalesInvoice.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
invoices = (await db.execute(stmt)).scalars().all()
|
||||
return ok(data=[
|
||||
{
|
||||
"id": str(inv.id),
|
||||
"invoice_number": inv.invoice_number,
|
||||
"issuer": inv.issuer,
|
||||
"receiver_name": inv.receiver_customer.name if inv.receiver_customer else None,
|
||||
"amount": float(inv.amount),
|
||||
"billing_date": str(inv.billing_date),
|
||||
}
|
||||
for inv in invoices
|
||||
])
|
||||
|
||||
|
||||
@router.get("/{order_id}", summary="订单全景详情(关系预加载 items + customer)")
|
||||
async def get_order(
|
||||
order_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.get_order(db, current_user, order_id)
|
||||
result = await svc.get_order(db, current_user, order_id, company_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{order_id}/invoices", summary="获取订单关联的销项发票")
|
||||
async def get_order_invoices(
|
||||
order_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
from sqlalchemy import select
|
||||
from app.models.finance import FinSalesInvoice
|
||||
stmt = (
|
||||
select(FinSalesInvoice)
|
||||
.where(
|
||||
FinSalesInvoice.order_id == order_id,
|
||||
FinSalesInvoice.company_id == company_id,
|
||||
FinSalesInvoice.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(FinSalesInvoice.created_at.desc())
|
||||
)
|
||||
invoices = (await db.execute(stmt)).scalars().all()
|
||||
return ok(data=[
|
||||
{
|
||||
"id": str(inv.id),
|
||||
"invoice_number": inv.invoice_number,
|
||||
"issuer": inv.issuer,
|
||||
"receiver_name": inv.receiver_customer.name if inv.receiver_customer else None,
|
||||
"amount": float(inv.amount),
|
||||
"billing_date": str(inv.billing_date),
|
||||
"payment_status": inv.payment_status,
|
||||
"payment_date": str(inv.payment_date) if inv.payment_date else None,
|
||||
"payment_amount": float(inv.payment_amount or 0),
|
||||
"payment_due_date": str(inv.payment_due_date) if inv.payment_due_date else None,
|
||||
}
|
||||
for inv in invoices
|
||||
])
|
||||
|
||||
|
||||
@router.put("/{order_id}/payment", summary="更新订单收款状态")
|
||||
async def update_order_payment(
|
||||
order_id: uuid.UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from app.models.order import ErpOrder
|
||||
from datetime import datetime
|
||||
|
||||
order = (await db.execute(
|
||||
select(ErpOrder).where(
|
||||
ErpOrder.id == order_id,
|
||||
ErpOrder.company_id == company_id,
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if order is None:
|
||||
from app.core.exceptions import NotFoundException
|
||||
raise NotFoundException("订单不存在")
|
||||
|
||||
values = {}
|
||||
if "paid_amount" in body:
|
||||
paid = float(body["paid_amount"])
|
||||
values["paid_amount"] = paid
|
||||
total = float(order.total_amount)
|
||||
if paid >= total:
|
||||
values["payment_state"] = "cleared"
|
||||
elif paid > 0:
|
||||
values["payment_state"] = "partial"
|
||||
else:
|
||||
values["payment_state"] = "unpaid"
|
||||
if "payment_state" in body:
|
||||
values["payment_state"] = body["payment_state"]
|
||||
if values:
|
||||
values["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
sa_update(ErpOrder).where(ErpOrder.id == order_id).values(**values)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ok(message="收款状态已更新")
|
||||
|
||||
|
||||
@router.get("/{order_id}/invoice-detail-preview", summary="生成开票明细预览")
|
||||
async def invoice_detail_preview(
|
||||
order_id: uuid.UUID,
|
||||
mode: str = Query("full", pattern=r"^(full|batch)$", description="full=整体开票, batch=按发货批次"),
|
||||
shipping_id: uuid.UUID | None = Query(None, description="batch模式下必传发货单ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
"""根据模式生成开票明细: 整体=订单全部商品, 批次=指定发货单商品"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.order import ErpOrder, ErpOrderItem
|
||||
from app.models.shipping import ErpShippingRecord, ErpShippingItem
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.models.sys import SysCompany
|
||||
from app.core.exceptions import NotFoundException, BizException
|
||||
|
||||
# 查订单
|
||||
order = (await db.execute(
|
||||
select(ErpOrder)
|
||||
.where(ErpOrder.id == order_id, ErpOrder.company_id == company_id, ErpOrder.is_deleted.is_(False))
|
||||
.options(
|
||||
selectinload(ErpOrder.items),
|
||||
selectinload(ErpOrder.customer),
|
||||
selectinload(ErpOrder.salesperson),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not order:
|
||||
raise NotFoundException("订单不存在")
|
||||
|
||||
# 买方名称
|
||||
buyer_name = order.customer.name if order.customer else ""
|
||||
# 卖方名称
|
||||
company = (await db.execute(
|
||||
select(SysCompany).where(SysCompany.id == company_id)
|
||||
)).scalar_one_or_none()
|
||||
seller_name = company.name if company else ""
|
||||
|
||||
items_data = []
|
||||
total_amount = 0.0
|
||||
|
||||
if mode == "full":
|
||||
# 整体开票: 聚合全部订单明细
|
||||
for oi in (order.items or []):
|
||||
sub = float(oi.sub_total or 0)
|
||||
items_data.append({
|
||||
"sku_code": oi.sku.sku_code if oi.sku else "",
|
||||
"sku_name": oi.sku.name if oi.sku else "",
|
||||
"spec": oi.sku.spec if oi.sku else "",
|
||||
"unit": oi.sku.unit if oi.sku else "",
|
||||
"qty": float(oi.qty),
|
||||
"unit_price": float(oi.unit_price),
|
||||
"sub_total": sub,
|
||||
})
|
||||
total_amount += sub
|
||||
else:
|
||||
# 按发货批次
|
||||
if not shipping_id:
|
||||
raise BizException(message="batch模式需指定shipping_id")
|
||||
ship = (await db.execute(
|
||||
select(ErpShippingRecord)
|
||||
.where(
|
||||
ErpShippingRecord.id == shipping_id,
|
||||
ErpShippingRecord.order_id == order_id,
|
||||
ErpShippingRecord.is_deleted.is_(False),
|
||||
)
|
||||
.options(selectinload(ErpShippingRecord.items).selectinload(ErpShippingItem.sku))
|
||||
)).scalar_one_or_none()
|
||||
if not ship:
|
||||
raise NotFoundException("发货单不存在")
|
||||
|
||||
# 查对应的订单明细来获取单价
|
||||
order_item_map = {str(oi.id): oi for oi in (order.items or [])}
|
||||
for si in (ship.items or []):
|
||||
oi = order_item_map.get(str(si.order_item_id))
|
||||
unit_price = float(oi.unit_price) if oi else 0
|
||||
qty = float(si.shipped_qty)
|
||||
sub = round(qty * unit_price, 2)
|
||||
items_data.append({
|
||||
"sku_code": si.sku.sku_code if si.sku else "",
|
||||
"sku_name": si.sku.name if si.sku else "",
|
||||
"spec": si.sku.spec if si.sku else "",
|
||||
"unit": si.sku.unit if si.sku else "",
|
||||
"qty": qty,
|
||||
"unit_price": unit_price,
|
||||
"sub_total": sub,
|
||||
})
|
||||
total_amount += sub
|
||||
|
||||
return ok(data={
|
||||
"order_no": order.order_no,
|
||||
"buyer_name": buyer_name,
|
||||
"seller_name": seller_name,
|
||||
"customer_id": str(order.customer_id),
|
||||
"items": items_data,
|
||||
"total_amount": round(total_amount, 2),
|
||||
"shipping_id": str(shipping_id) if shipping_id else None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{order_id}/invoices/link", summary="关联已有发票到订单")
|
||||
async def link_existing_invoice(
|
||||
order_id: uuid.UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
"""将已存在的销项发票关联到该订单"""
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from app.models.finance import FinSalesInvoice
|
||||
from app.core.exceptions import NotFoundException, BizException
|
||||
from datetime import datetime
|
||||
|
||||
invoice_id = body.get("invoice_id")
|
||||
shipping_record_id = body.get("shipping_record_id")
|
||||
if not invoice_id:
|
||||
raise BizException(message="请提供 invoice_id")
|
||||
|
||||
inv = (await db.execute(
|
||||
select(FinSalesInvoice).where(
|
||||
FinSalesInvoice.id == uuid.UUID(invoice_id),
|
||||
FinSalesInvoice.company_id == company_id,
|
||||
FinSalesInvoice.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not inv:
|
||||
raise NotFoundException("发票不存在")
|
||||
|
||||
values = {"order_id": order_id, "updated_at": datetime.utcnow()}
|
||||
if shipping_record_id:
|
||||
values["shipping_record_id"] = uuid.UUID(shipping_record_id)
|
||||
|
||||
await db.execute(
|
||||
sa_update(FinSalesInvoice)
|
||||
.where(FinSalesInvoice.id == uuid.UUID(invoice_id))
|
||||
.values(**values)
|
||||
)
|
||||
await db.commit()
|
||||
return ok(message="发票已关联到订单")
|
||||
|
||||
|
||||
@router.post("/{order_id}/invoices/create", summary="直接创建发票并关联到订单")
|
||||
async def create_and_link_invoice(
|
||||
order_id: uuid.UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
"""创建新的销项发票,同时关联到当前订单"""
|
||||
from sqlalchemy import select
|
||||
from app.models.finance import FinSalesInvoice
|
||||
from app.models.order import ErpOrder
|
||||
from app.core.exceptions import NotFoundException, BizException
|
||||
from datetime import date as dt_date
|
||||
|
||||
order = (await db.execute(
|
||||
select(ErpOrder).where(
|
||||
ErpOrder.id == order_id,
|
||||
ErpOrder.company_id == company_id,
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not order:
|
||||
raise NotFoundException("订单不存在")
|
||||
|
||||
invoice_number = body.get("invoice_number", "").strip()
|
||||
amount = float(body.get("amount", 0))
|
||||
issuer = body.get("issuer", "").strip()
|
||||
receiver_customer_id = body.get("receiver_customer_id") or str(order.customer_id)
|
||||
billing_date_str = body.get("billing_date")
|
||||
shipping_record_id = body.get("shipping_record_id")
|
||||
remark = body.get("remark")
|
||||
|
||||
if not invoice_number:
|
||||
raise BizException(message="请填写发票号")
|
||||
if amount <= 0:
|
||||
raise BizException(message="开票金额需大于0")
|
||||
if not issuer:
|
||||
raise BizException(message="请填写开票方名称")
|
||||
|
||||
# 检查唯一性
|
||||
from sqlalchemy import func as sa_func
|
||||
existing = (await db.execute(
|
||||
select(sa_func.count()).select_from(FinSalesInvoice).where(
|
||||
FinSalesInvoice.invoice_number == invoice_number,
|
||||
FinSalesInvoice.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar()
|
||||
if existing:
|
||||
raise BizException(message=f"发票号 {invoice_number} 已存在")
|
||||
|
||||
inv = FinSalesInvoice(
|
||||
issuer=issuer,
|
||||
receiver_customer_id=uuid.UUID(receiver_customer_id),
|
||||
invoice_number=invoice_number,
|
||||
amount=amount,
|
||||
billing_date=dt_date.fromisoformat(billing_date_str) if billing_date_str else dt_date.today(),
|
||||
remark=remark,
|
||||
order_id=order_id,
|
||||
shipping_record_id=uuid.UUID(shipping_record_id) if shipping_record_id else None,
|
||||
created_by=current_user.user_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.commit()
|
||||
return ok(data={"id": str(inv.id), "invoice_number": invoice_number}, message="发票创建并关联成功")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.erp import CategoryCreate, CategoryUpdate, InventoryFlowCreate, SkuCreate, SkuUpdate
|
||||
@@ -64,8 +64,9 @@ async def list_skus(
|
||||
keyword: str | None = Query(None, description="模糊搜索 SKU 编码或名称"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_skus(db, page, size, category_id, keyword)
|
||||
result = await svc.list_skus(db, company_id, page, size, category_id, keyword)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -95,8 +96,9 @@ async def create_inventory_flow(
|
||||
body: InventoryFlowCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.create_inventory_flow(db, current_user, body)
|
||||
result = await svc.create_inventory_flow(db, current_user, body, company_id)
|
||||
return ok(data=result.model_dump(mode="json"), message="库存变更成功")
|
||||
|
||||
|
||||
@@ -107,6 +109,7 @@ async def get_inventory_flows(
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.get_inventory_flows(db, sku_id, page, size)
|
||||
result = await svc.get_inventory_flows(db, sku_id, company_id, page, size)
|
||||
return ok(data=result)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
利润核算路由 —— /api/profit
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.services import profit_service as svc
|
||||
|
||||
router = APIRouter(prefix="/profit", tags=["利润核算"])
|
||||
|
||||
|
||||
@router.get("/report", summary="利润报表(订单维度)")
|
||||
async def profit_report(
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.get_profit_report(db, company_id, start_date, end_date)
|
||||
return ok(data=result)
|
||||
|
||||
|
||||
@router.post("/snapshot/{order_id}", summary="为订单锚定成本快照")
|
||||
async def snapshot_costs(
|
||||
order_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.snapshot_order_item_costs(db, order_id, company_id)
|
||||
return ok(data=result, message=f"已为 {len(result)} 项明细锚定成本")
|
||||
@@ -14,7 +14,7 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
@@ -28,15 +28,16 @@ async def generate_report(
|
||||
end_date: date = Body(..., embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
authorization: str | None = Header(None),
|
||||
):
|
||||
"""
|
||||
1. 聚合该用户在时间范围内的 sales_logs 内容
|
||||
1. 聚合该用户在时间范围内、涉及当前公司的 sales_logs 内容
|
||||
2. 调用 Dify Workflow (streaming) 生成复盘报告
|
||||
3. SSE 流式返回给前端
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_report_sse_generator(db, current_user, start_date, end_date, authorization or ""),
|
||||
_report_sse_generator(db, current_user, start_date, end_date, authorization or "", company_id),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
@@ -47,20 +48,25 @@ async def _report_sse_generator(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
authorization: str = "",
|
||||
company_id: uuid.UUID | None = None,
|
||||
):
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
from app.models.ai import SalesLog
|
||||
|
||||
# 1. 聚合日志
|
||||
stmt = (
|
||||
select(SalesLog)
|
||||
.where(
|
||||
# 1. 聚合日志 — 仅提取涉及当前公司的日志
|
||||
conditions = [
|
||||
SalesLog.salesperson_id == user.user_id,
|
||||
SalesLog.log_date >= start_date,
|
||||
SalesLog.log_date <= end_date,
|
||||
SalesLog.is_deleted.is_(False),
|
||||
)
|
||||
]
|
||||
if company_id:
|
||||
conditions.append(SalesLog.involved_company_ids.any(company_id))
|
||||
|
||||
stmt = (
|
||||
select(SalesLog)
|
||||
.where(*conditions)
|
||||
.order_by(SalesLog.log_date)
|
||||
)
|
||||
logs = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.sales_invoice import SalesInvoiceCreate, SalesInvoiceUpdate
|
||||
@@ -26,8 +26,9 @@ async def create_invoice(
|
||||
body: SalesInvoiceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.create_invoice(db, current_user, body)
|
||||
result = await svc.create_invoice(db, current_user, body, company_id)
|
||||
return ok(data=result.model_dump(mode="json"), message="销项发票创建成功")
|
||||
|
||||
|
||||
@@ -42,10 +43,11 @@ async def list_invoices(
|
||||
end_date: date | None = Query(None, description="开票结束日期"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_invoices(
|
||||
db, page, size, customer_name, invoice_number,
|
||||
payment_status, start_date, end_date,
|
||||
payment_status, start_date, end_date, company_id,
|
||||
)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from fastapi import APIRouter, Depends, Body, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
@@ -26,6 +28,7 @@ async def list_logs(
|
||||
end_date: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
):
|
||||
result = await sales_log_service.list_logs(
|
||||
db, current_user,
|
||||
@@ -34,18 +37,56 @@ async def list_logs(
|
||||
user_id=user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
company_id=company_id,
|
||||
)
|
||||
return ok(data=result)
|
||||
|
||||
|
||||
async def _resolve_company_ids(
|
||||
db: AsyncSession,
|
||||
company_id: uuid.UUID,
|
||||
customer_id: str | None,
|
||||
company_ids: list[str] | None,
|
||||
) -> list[uuid.UUID]:
|
||||
"""
|
||||
智能解析 involved_company_ids:
|
||||
1. 如果前端显式传了 company_ids,使用它
|
||||
2. 否则以当前视角公司为基础
|
||||
3. 如果选了客户,自动查客户 owner 所属的公司,合并进来
|
||||
"""
|
||||
if company_ids:
|
||||
resolved = set(uuid.UUID(cid) for cid in company_ids)
|
||||
else:
|
||||
resolved = {company_id}
|
||||
|
||||
# 自动关联客户 owner 所在公司
|
||||
if customer_id:
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.models.sys import SysUserCompany
|
||||
cust = await db.get(CrmCustomer, uuid.UUID(customer_id))
|
||||
if cust and cust.owner_id:
|
||||
stmt = select(SysUserCompany.company_id).where(
|
||||
SysUserCompany.user_id == cust.owner_id
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
for cid in rows:
|
||||
resolved.add(cid)
|
||||
|
||||
# 确保当前公司始终在内
|
||||
resolved.add(company_id)
|
||||
return list(resolved)
|
||||
|
||||
|
||||
@router.post("", summary="创建销售日志")
|
||||
async def create_log(
|
||||
content: str = Body(..., embed=True),
|
||||
customer_id: str | None = Body(None, embed=True),
|
||||
contact_ids: list[str] | None = Body(None, embed=True),
|
||||
log_date: str | None = Body(None, embed=True),
|
||||
company_ids: list[str] | None = Body(None, embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
):
|
||||
from datetime import date as date_type
|
||||
|
||||
@@ -53,17 +94,20 @@ async def create_log(
|
||||
if log_date:
|
||||
parsed_date = date_type.fromisoformat(log_date)
|
||||
|
||||
# 智能解析公司关联
|
||||
resolved_company_ids = await _resolve_company_ids(db, company_id, customer_id, company_ids)
|
||||
|
||||
result = await sales_log_service.create_log(
|
||||
db, current_user,
|
||||
content=content,
|
||||
customer_id=customer_id,
|
||||
contact_ids=contact_ids,
|
||||
log_date=parsed_date,
|
||||
company_ids=resolved_company_ids,
|
||||
)
|
||||
|
||||
# 异步触发 Dify 画像提取工作流(仅当关联了客户时)
|
||||
if customer_id:
|
||||
import uuid
|
||||
asyncio.create_task(
|
||||
sales_log_service.trigger_persona_workflow(
|
||||
log_id=uuid.UUID(result["id"]),
|
||||
@@ -75,3 +119,35 @@ async def create_log(
|
||||
)
|
||||
|
||||
return ok(data=result, message="日志创建成功")
|
||||
|
||||
|
||||
@router.put("/{log_id}", summary="编辑销售日志")
|
||||
async def update_log(
|
||||
log_id: uuid.UUID,
|
||||
content: str | None = Body(None, embed=True),
|
||||
customer_id: str | None = Body(None, embed=True),
|
||||
contact_ids: list[str] | None = Body(None, embed=True),
|
||||
log_date: str | None = Body(None, embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
):
|
||||
result = await sales_log_service.update_log(
|
||||
db, current_user, log_id,
|
||||
content=content,
|
||||
customer_id=customer_id,
|
||||
contact_ids=contact_ids,
|
||||
log_date=log_date,
|
||||
company_id=company_id,
|
||||
)
|
||||
return ok(data=result, message="日志更新成功")
|
||||
|
||||
|
||||
@router.delete("/{log_id}", summary="删除销售日志(软删除)")
|
||||
async def delete_log(
|
||||
log_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
await sales_log_service.delete_log(db, current_user, log_id)
|
||||
return ok(message="日志已删除")
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, get_current_company_id
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.shipping import ShippingCreate
|
||||
@@ -21,8 +21,9 @@ async def create_shipping(
|
||||
body: ShippingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
resp, new_state = await svc.create_shipping(db, current_user, body)
|
||||
resp, new_state = await svc.create_shipping(db, current_user, body, company_id)
|
||||
return ok(data=resp.model_dump(mode="json"), message=f"发货单 {resp.shipping_no} 创建成功,订单状态已更新为 {new_state}")
|
||||
|
||||
|
||||
@@ -34,8 +35,9 @@ async def list_shipping(
|
||||
tracking_no: str | None = Query(None, description="按物流单号搜索"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.list_shipping(db, current_user, page, size, order_no, tracking_no)
|
||||
result = await svc.list_shipping(db, current_user, page, size, order_no, tracking_no, company_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -44,6 +46,7 @@ async def get_shipping_by_order(
|
||||
order_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||||
) -> dict:
|
||||
result = await svc.get_shipping_by_order(db, current_user, order_id)
|
||||
result = await svc.get_shipping_by_order(db, current_user, order_id, company_id)
|
||||
return ok(data=result)
|
||||
|
||||
@@ -26,6 +26,10 @@ from app.api.sales_invoice import router as sales_invoice_router
|
||||
from app.api.reports import router as reports_router
|
||||
from app.api.contacts import router as contacts_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.companies import router as companies_router
|
||||
from app.api.contracts import router as contracts_router
|
||||
from app.api.profit import router as profit_router
|
||||
from app.api.ai_coaching import router as ai_coaching_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -33,8 +37,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""应用生命周期:启动/关闭时的钩子"""
|
||||
# ── startup ──
|
||||
print(f"🚀 {settings.APP_NAME} v{settings.APP_VERSION} 启动中...")
|
||||
from app.services.ocr_worker import ocr_worker
|
||||
ocr_worker.start()
|
||||
yield
|
||||
# ── shutdown ──
|
||||
await ocr_worker.stop()
|
||||
print("👋 服务正在关闭...")
|
||||
|
||||
|
||||
@@ -81,6 +88,10 @@ app.include_router(sales_invoice_router, prefix="/api")
|
||||
app.include_router(reports_router, prefix="/api")
|
||||
app.include_router(contacts_router, prefix="/api")
|
||||
app.include_router(dashboard_router, prefix="/api")
|
||||
app.include_router(companies_router, prefix="/api")
|
||||
app.include_router(contracts_router, prefix="/api")
|
||||
app.include_router(profit_router, prefix="/api")
|
||||
app.include_router(ai_coaching_router, prefix="/api")
|
||||
|
||||
|
||||
# ── 健康检查 ──
|
||||
|
||||
+26
-1
@@ -7,7 +7,7 @@ import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, SmallInteger, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
@@ -30,11 +30,19 @@ class SalesLog(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
salesperson_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False)
|
||||
involved_company_ids: Mapped[list] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), nullable=False, default=list,
|
||||
comment="该篇日志涉及的公司ID列表"
|
||||
)
|
||||
customer_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("crm_customers.id"), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
log_date: Mapped[date] = mapped_column(Date, default=date.today)
|
||||
contact_ids: Mapped[list | None] = mapped_column(JSONB, default=list, nullable=True)
|
||||
ai_processed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
ai_coaching_feedback: Mapped[dict | None] = mapped_column(
|
||||
JSONB, default=dict, nullable=True,
|
||||
comment="AI 教练引擎回写的指导反馈"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -53,3 +61,20 @@ class AiReportDraft(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class KbObsidianVector(Base):
|
||||
"""知识库向量表 —— pgvector 存储 Obsidian 文档分块向量"""
|
||||
__tablename__ = "kb_obsidian_vectors"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
source_path: Mapped[str] = mapped_column(String(500), nullable=False, comment="源文件路径")
|
||||
chunk_index: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, default=dict)
|
||||
# 向量字段使用 raw SQL 创建(vector(1536))因 SQLAlchemy 无原生 pgvector 类型
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
合同域 ORM 模型
|
||||
映射: erp_contracts / erp_contract_items / erp_contract_attachments
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
# ── 付款条件枚举 ─────────────────────────────────────────
|
||||
PAYMENT_TERMS = [
|
||||
"预付全款订货",
|
||||
"预付30%订货,到货前付清",
|
||||
"预付50%订货,到货前付清",
|
||||
"货到付全款",
|
||||
"开具发票后30天内付款",
|
||||
"开具发票45天付款",
|
||||
"开具发票60天付款",
|
||||
"开具发票90天付款",
|
||||
]
|
||||
|
||||
# ── 运费条款枚举 ─────────────────────────────────────────
|
||||
SHIPPING_TERMS = [
|
||||
"买方自提",
|
||||
"卖方免费送达天津指定地点",
|
||||
"卖方免费送达指定地点",
|
||||
"物流发货,运费买方承担",
|
||||
]
|
||||
|
||||
|
||||
class ErpContract(Base):
|
||||
"""合同主表 —— B2B 交易防线核心"""
|
||||
__tablename__ = "erp_contracts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
contract_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
buyer_customer_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("crm_customers.id"), nullable=False,
|
||||
comment="买方(CRM 客户)"
|
||||
)
|
||||
seller_company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False,
|
||||
comment="卖方(当前操作公司)"
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True,
|
||||
comment="多租户隔离"
|
||||
)
|
||||
total_amount_excl_tax: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
total_amount_incl_tax: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
total_amount_cn: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="大写合计金额"
|
||||
)
|
||||
payment_terms: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="货到付全款"
|
||||
)
|
||||
shipping_terms: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="买方自提"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="draft",
|
||||
comment="draft→active→completed→cancelled"
|
||||
)
|
||||
is_signed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
signed_file_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
linked_order_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_orders.id"), nullable=True,
|
||||
comment="一键推单后回填"
|
||||
)
|
||||
salesperson_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
sign_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
delivery_terms: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, comment="货期(手动输入)"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 关系
|
||||
buyer_customer: Mapped["CrmCustomer"] = relationship( # noqa: F821
|
||||
"CrmCustomer", lazy="selectin"
|
||||
)
|
||||
seller_company: Mapped["SysCompany"] = relationship( # noqa: F821
|
||||
"SysCompany", foreign_keys=[seller_company_id], lazy="selectin"
|
||||
)
|
||||
salesperson: Mapped["SysUser | None"] = relationship("SysUser", foreign_keys=[salesperson_id], lazy="selectin") # noqa: F821
|
||||
linked_order: Mapped["ErpOrder | None"] = relationship("ErpOrder", foreign_keys=[linked_order_id], lazy="selectin") # noqa: F821
|
||||
items: Mapped[list["ErpContractItem"]] = relationship(
|
||||
"ErpContractItem", back_populates="contract", lazy="selectin"
|
||||
)
|
||||
attachments: Mapped[list["ErpContractAttachment"]] = relationship(
|
||||
"ErpContractAttachment", back_populates="contract", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class ErpContractItem(Base):
|
||||
"""合同明细行"""
|
||||
__tablename__ = "erp_contract_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
contract_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_contracts.id"), nullable=False
|
||||
)
|
||||
sku_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_product_skus.id"), nullable=False
|
||||
)
|
||||
qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
sub_total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 关系
|
||||
contract: Mapped[ErpContract] = relationship("ErpContract", back_populates="items")
|
||||
sku: Mapped["ProductSku"] = relationship("ProductSku", lazy="selectin") # noqa: F821
|
||||
|
||||
|
||||
class ErpContractAttachment(Base):
|
||||
"""合同附件(双签盖章版等)"""
|
||||
__tablename__ = "erp_contract_attachments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
contract_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_contracts.id"), nullable=False
|
||||
)
|
||||
file_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
file_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
file_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, default="signed_copy",
|
||||
comment="signed_copy / supplement / other"
|
||||
)
|
||||
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 关系
|
||||
contract: Mapped[ErpContract] = relationship("ErpContract", back_populates="attachments")
|
||||
uploader: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
成本域 ORM 模型
|
||||
映射: erp_order_item_costs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Numeric, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class ErpOrderItemCost(Base):
|
||||
"""订单明细成本快照表 —— 发货/确认瞬间锚定 MWA 成本"""
|
||||
__tablename__ = "erp_order_item_costs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
order_item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_order_items.id"), nullable=False, unique=True,
|
||||
comment="关联订单明细"
|
||||
)
|
||||
purchase_unit_price: Mapped[float] = mapped_column(
|
||||
Numeric(12, 4), nullable=False, comment="MWA 成本快照"
|
||||
)
|
||||
profit_amount: Mapped[float] = mapped_column(
|
||||
Numeric(14, 2), default=0, comment="利润额 = (售价-成本)*数量"
|
||||
)
|
||||
profit_rate: Mapped[float] = mapped_column(
|
||||
Numeric(5, 4), default=0, comment="利润率"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
# 关系
|
||||
order_item: Mapped["ErpOrderItem"] = relationship("ErpOrderItem", lazy="selectin") # noqa: F821
|
||||
@@ -29,6 +29,18 @@ class CrmCustomer(Base):
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0)
|
||||
ai_persona: Mapped[dict | None] = mapped_column(JSONB, default=dict, nullable=True)
|
||||
billing_info: Mapped[dict | None] = mapped_column(
|
||||
JSONB, default=dict, nullable=True,
|
||||
comment="客户开票信息: company_name/tax_id/address/phone/bank_name/bank_account"
|
||||
)
|
||||
health_score: Mapped[float] = mapped_column(
|
||||
Numeric(5, 2), default=0,
|
||||
comment="客户健康度评分 (AI 教练引擎计算)"
|
||||
)
|
||||
meddic_status: Mapped[dict | None] = mapped_column(
|
||||
JSONB, default=dict, nullable=True,
|
||||
comment="MEDDIC 六维评估状态"
|
||||
)
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
|
||||
@@ -12,11 +12,13 @@ from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
@@ -56,8 +58,6 @@ class ProductSku(Base):
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
spec: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
standard_price: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
stock_qty: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
warning_threshold: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
unit: Mapped[str] = mapped_column(String(20), default="桶")
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
@@ -80,9 +80,18 @@ class InventoryFlow(Base):
|
||||
sku_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_product_skus.id"), nullable=False
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
change_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
reason: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
purchase_unit_price: Mapped[float] = mapped_column(
|
||||
Numeric(12, 2), default=0, comment="入库采购单价"
|
||||
)
|
||||
is_special_zero_cost: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="特殊零元入库标识,不参与 MWA 计算"
|
||||
)
|
||||
operator_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
@@ -94,3 +103,34 @@ class InventoryFlow(Base):
|
||||
|
||||
sku: Mapped[ProductSku | None] = relationship("ProductSku", lazy="selectin")
|
||||
operator: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
|
||||
|
||||
|
||||
class ErpSkuInventory(Base):
|
||||
"""SKU 分公司库存表 —— 同一 SKU 在不同公司有独立库存"""
|
||||
__tablename__ = "erp_sku_inventory"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
sku_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_product_skus.id"), nullable=False
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
stock_qty: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
warning_threshold: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
mwa_unit_cost: Mapped[float] = mapped_column(
|
||||
Numeric(12, 4), default=0,
|
||||
comment="移动加权均价 (Moving Weighted Average)"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("sku_id", "company_id", name="uq_sku_company"),
|
||||
)
|
||||
|
||||
sku: Mapped[ProductSku | None] = relationship("ProductSku", lazy="selectin")
|
||||
|
||||
@@ -33,6 +33,9 @@ class FinInvoicePool(Base):
|
||||
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
file_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
merchant_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
@@ -59,6 +62,9 @@ class FinExpenseRecord(Base):
|
||||
applicant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
total_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -134,9 +140,23 @@ class FinSalesInvoice(Base):
|
||||
payment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
payment_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
order_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_orders.id"), nullable=True,
|
||||
comment="关联订单"
|
||||
)
|
||||
shipping_record_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_shipping_records.id"), nullable=True,
|
||||
comment="关联发货单"
|
||||
)
|
||||
payment_due_date: Mapped[date | None] = mapped_column(
|
||||
Date, nullable=True, comment="回款截止日(根据合同付款条件自动推算)"
|
||||
)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
@@ -150,3 +170,43 @@ class FinSalesInvoice(Base):
|
||||
creator: Mapped["SysUser | None"] = relationship( # noqa: F821
|
||||
"SysUser", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class FinOcrTask(Base):
|
||||
"""OCR 处理任务队列 — 持久化排队"""
|
||||
__tablename__ = "fin_ocr_tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
file_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
file_ext: Mapped[str] = mapped_column(String(10), nullable=False, comment=".pdf/.png/.jpg")
|
||||
original_name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending",
|
||||
comment="pending/processing/success/failed/manual",
|
||||
)
|
||||
priority: Mapped[int] = mapped_column(default=100, comment="值越小越优先")
|
||||
ocr_result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
retry_count: Mapped[int] = mapped_column(default=0)
|
||||
max_retries: Mapped[int] = mapped_column(default=3)
|
||||
invoice_pool_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("fin_invoice_pool.id"), nullable=True,
|
||||
comment="成功入池后关联的发票 ID",
|
||||
)
|
||||
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True,
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True,
|
||||
)
|
||||
inv_type: Mapped[str] = mapped_column(String(30), nullable=False, default="expense")
|
||||
scheduled_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
uploader: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
|
||||
|
||||
@@ -37,6 +37,13 @@ class ErpOrder(Base):
|
||||
salesperson_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
contract_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("erp_contracts.id"), nullable=True,
|
||||
comment="来源合同(一键推单后回填)"
|
||||
)
|
||||
total_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
shipping_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
|
||||
@@ -42,6 +42,9 @@ class ErpShippingRecord(Base):
|
||||
operator_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, SmallInteger, String, Text, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -97,3 +97,44 @@ class SysUser(Base):
|
||||
"SysDepartment", lazy="selectin"
|
||||
)
|
||||
role: Mapped[SysRole | None] = relationship("SysRole", lazy="selectin")
|
||||
|
||||
|
||||
class SysCompany(Base):
|
||||
"""公司主体表 —— 多租户逻辑隔离核心"""
|
||||
__tablename__ = "sys_companies"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
full_info: Mapped[dict | None] = mapped_column(
|
||||
JSONB, default=dict, nullable=True,
|
||||
comment="公司完整信息: full_name/address/phone/bank_name/bank_account/tax_id"
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SysUserCompany(Base):
|
||||
"""用户-公司多对多关联 —— IDOR 防护核心"""
|
||||
__tablename__ = "sys_user_companies"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("sys_companies.id"), nullable=False
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "company_id", name="uq_user_company"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
合同域 Pydantic V2 Schemas
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── 合同明细行 ────────────────────────────────────────────
|
||||
class ContractItemCreate(BaseModel):
|
||||
sku_id: uuid.UUID
|
||||
qty: float = Field(gt=0)
|
||||
unit_price: float = Field(ge=0)
|
||||
sub_total: float = Field(ge=0)
|
||||
|
||||
|
||||
class ContractItemResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
sku_id: uuid.UUID
|
||||
sku_code: str | None = None
|
||||
sku_name: str | None = None
|
||||
spec: str | None = None
|
||||
unit: str | None = None
|
||||
qty: float
|
||||
unit_price: float
|
||||
sub_total: float
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── 合同创建 ──────────────────────────────────────────────
|
||||
class ContractCreate(BaseModel):
|
||||
buyer_customer_id: uuid.UUID
|
||||
items: list[ContractItemCreate] = Field(min_length=1)
|
||||
payment_terms: str = "货到付全款"
|
||||
shipping_terms: str = "买方自提"
|
||||
remark: str | None = None
|
||||
delivery_terms: str | None = None
|
||||
sign_date: date | None = None
|
||||
|
||||
|
||||
# ── 合同更新 ──────────────────────────────────────────────
|
||||
class ContractUpdate(BaseModel):
|
||||
buyer_customer_id: uuid.UUID | None = None
|
||||
items: list[ContractItemCreate] | None = None
|
||||
payment_terms: str | None = None
|
||||
shipping_terms: str | None = None
|
||||
status: str | None = None
|
||||
is_signed: bool | None = None
|
||||
remark: str | None = None
|
||||
delivery_terms: str | None = None
|
||||
sign_date: date | None = None
|
||||
|
||||
|
||||
# ── 执行进度 ──────────────────────────────────────────────
|
||||
class ContractProgressResponse(BaseModel):
|
||||
is_signed: bool = False
|
||||
has_order: bool = False
|
||||
order_id: uuid.UUID | None = None
|
||||
has_shipped: bool = False
|
||||
has_invoice: bool = False
|
||||
is_paid: bool = False
|
||||
|
||||
|
||||
# ── 合同响应 ──────────────────────────────────────────────
|
||||
class ContractResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
contract_no: str
|
||||
buyer_customer_id: uuid.UUID
|
||||
buyer_customer_name: str | None = None
|
||||
seller_company_id: uuid.UUID
|
||||
seller_company_name: str | None = None
|
||||
company_id: uuid.UUID
|
||||
total_amount_excl_tax: float = 0
|
||||
total_amount_incl_tax: float = 0
|
||||
total_amount_cn: str | None = None
|
||||
payment_terms: str
|
||||
shipping_terms: str
|
||||
status: str
|
||||
is_signed: bool = False
|
||||
signed_file_url: str | None = None
|
||||
linked_order_id: uuid.UUID | None = None
|
||||
salesperson_id: uuid.UUID | None = None
|
||||
salesperson_name: str | None = None
|
||||
sign_date: date | None = None
|
||||
remark: str | None = None
|
||||
delivery_terms: str | None = None
|
||||
items: list[ContractItemResponse] = []
|
||||
progress: ContractProgressResponse | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── 分页列表 ──────────────────────────────────────────────
|
||||
class ContractListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[ContractResponse]
|
||||
page: int
|
||||
size: int
|
||||
@@ -12,6 +12,16 @@ from typing import Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── 开票信息子结构 ─────────────────────────────────────────
|
||||
class BillingInfoSchema(BaseModel):
|
||||
company_name: str | None = Field(default=None, max_length=200, description="开票公司全称")
|
||||
tax_id: str | None = Field(default=None, max_length=50, description="纳税人识别号")
|
||||
address: str | None = Field(default=None, max_length=300, description="地址")
|
||||
phone: str | None = Field(default=None, max_length=30, description="电话")
|
||||
bank_name: str | None = Field(default=None, max_length=200, description="开户行")
|
||||
bank_account: str | None = Field(default=None, max_length=50, description="银行账号")
|
||||
|
||||
|
||||
# ── 创建 ──────────────────────────────────────────────────
|
||||
class CustomerCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, examples=["中石化润滑油公司"])
|
||||
@@ -21,6 +31,7 @@ class CustomerCreate(BaseModel):
|
||||
phone: str | None = Field(default=None, max_length=30)
|
||||
email: str | None = Field(default=None, max_length=100)
|
||||
address: str | None = None
|
||||
billing_info: BillingInfoSchema | None = None
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
|
||||
|
||||
@@ -33,6 +44,7 @@ class CustomerUpdate(BaseModel):
|
||||
phone: str | None = Field(default=None, max_length=30)
|
||||
email: str | None = Field(default=None, max_length=100)
|
||||
address: str | None = None
|
||||
billing_info: BillingInfoSchema | None = None
|
||||
status: int | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
@@ -48,6 +60,7 @@ class CustomerResponse(BaseModel):
|
||||
address: str | None = None
|
||||
ai_score: float = 0
|
||||
ai_persona: dict[str, Any] | None = None
|
||||
billing_info: dict[str, Any] | None = None
|
||||
owner_id: uuid.UUID | None = None
|
||||
owner_name: str | None = None
|
||||
status: int = 1
|
||||
|
||||
@@ -104,6 +104,8 @@ class InventoryFlowCreate(BaseModel):
|
||||
examples=["purchase"],
|
||||
)
|
||||
remark: str | None = Field(default=None, description="备注")
|
||||
purchase_unit_price: float = Field(default=0, ge=0, description="采购单价(仅入库时有意义)")
|
||||
is_special_zero_cost: bool = Field(default=False, description="特殊零元入库标识,不参与 MWA 计算")
|
||||
|
||||
|
||||
class InventoryFlowResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
AI 教练引擎 — 事件总线 + Dify 回调
|
||||
CQRS 解耦模式:
|
||||
1. 业务端 POST /api/sales-logs → 立即 200 OK → 发消息到 Redis Streams
|
||||
2. Worker 消费消息 → 调用 Dify Workflow → 写回 ai_coaching_feedback
|
||||
3. 前端通过 SSE /api/notifications/stream 接收推送
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai import SalesLog
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
|
||||
|
||||
# ── Redis 事件发布 ───────────────────────────────────────
|
||||
async def publish_coaching_event(
|
||||
sales_log_id: uuid.UUID,
|
||||
content: str,
|
||||
customer_id: uuid.UUID | None = None,
|
||||
salesperson_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""将销售日志推送到 Redis Streams,供 Worker 异步消费"""
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
import os
|
||||
|
||||
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
r = aioredis.from_url(redis_url, decode_responses=True)
|
||||
await r.xadd(
|
||||
"coaching:sales_logs",
|
||||
{
|
||||
"sales_log_id": str(sales_log_id),
|
||||
"content": content[:2000], # 限长
|
||||
"customer_id": str(customer_id) if customer_id else "",
|
||||
"salesperson_id": str(salesperson_id) if salesperson_id else "",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
},
|
||||
)
|
||||
await r.aclose()
|
||||
except Exception as e:
|
||||
# Redis 不可用时降级——不阻塞主流程
|
||||
print(f"[AI EventBus] Redis 推送失败(降级): {e}")
|
||||
|
||||
|
||||
# ── Dify 回调处理 ───────────────────────────────────────
|
||||
async def handle_dify_coaching_callback(
|
||||
db: AsyncSession,
|
||||
sales_log_id: uuid.UUID,
|
||||
feedback: dict,
|
||||
) -> None:
|
||||
"""Dify Workflow 回调 → 写回 SalesLog.ai_coaching_feedback"""
|
||||
await db.execute(
|
||||
update(SalesLog)
|
||||
.where(SalesLog.id == sales_log_id)
|
||||
.values(
|
||||
ai_coaching_feedback=feedback,
|
||||
ai_processed=True,
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
# 如果反馈中包含客户健康评分,同步更新 CrmCustomer
|
||||
health_score = feedback.get("health_score")
|
||||
meddic_status = feedback.get("meddic_status")
|
||||
if health_score is not None or meddic_status is not None:
|
||||
log = (await db.execute(
|
||||
select(SalesLog).where(SalesLog.id == sales_log_id)
|
||||
)).scalar_one_or_none()
|
||||
if log and log.customer_id:
|
||||
update_vals: dict = {}
|
||||
if health_score is not None:
|
||||
update_vals["health_score"] = float(health_score)
|
||||
if meddic_status is not None:
|
||||
update_vals["meddic_status"] = meddic_status
|
||||
if update_vals:
|
||||
await db.execute(
|
||||
update(CrmCustomer)
|
||||
.where(CrmCustomer.id == log.customer_id)
|
||||
.values(**update_vals)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── SSE 通知流 ──────────────────────────────────────────
|
||||
async def sse_notification_generator(user_id: uuid.UUID):
|
||||
"""服务端推送事件流(SSE)—— 监听 Redis PubSub 频道"""
|
||||
import asyncio
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
import os
|
||||
|
||||
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
r = aioredis.from_url(redis_url, decode_responses=True)
|
||||
pubsub = r.pubsub()
|
||||
channel = f"notifications:{user_id}"
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
yield f"data: {message['data']}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
@@ -0,0 +1,762 @@
|
||||
"""
|
||||
合同管理 Service 层
|
||||
核心逻辑:CRUD + 一键推单 + 账期引擎 + 执行进度聚合
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
import re
|
||||
|
||||
from sqlalchemy import func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
|
||||
from app.models.contract import ErpContract, ErpContractItem, ErpContractAttachment
|
||||
from app.models.order import ErpOrder, ErpOrderItem
|
||||
from app.models.shipping import ErpShippingRecord
|
||||
from app.models.finance import FinSalesInvoice
|
||||
from app.models.erp import ProductSku
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.contract import (
|
||||
ContractCreate,
|
||||
ContractUpdate,
|
||||
ContractItemResponse,
|
||||
ContractListResponse,
|
||||
ContractProgressResponse,
|
||||
ContractResponse,
|
||||
)
|
||||
|
||||
|
||||
# ── 金额大写转换 ─────────────────────────────────────────
|
||||
_CN_DIGITS = "零壹贰叁肆伍陆柒捌玖"
|
||||
_CN_UNITS = ["", "拾", "佰", "仟"]
|
||||
_CN_BIG_UNITS = ["", "万", "亿", "兆"]
|
||||
|
||||
|
||||
def amount_to_cn(amount: float) -> str:
|
||||
"""将金额转为中文大写"""
|
||||
if amount == 0:
|
||||
return "零元整"
|
||||
neg = ""
|
||||
if amount < 0:
|
||||
neg = "负"
|
||||
amount = -amount
|
||||
|
||||
yuan = int(amount)
|
||||
jiao = int(amount * 10) % 10
|
||||
fen = int(amount * 100) % 10
|
||||
|
||||
parts = []
|
||||
if yuan > 0:
|
||||
yuan_str = str(yuan)
|
||||
n = len(yuan_str)
|
||||
zero_flag = False
|
||||
for i, ch in enumerate(yuan_str):
|
||||
d = int(ch)
|
||||
pos = n - 1 - i
|
||||
big_idx = pos // 4
|
||||
unit_idx = pos % 4
|
||||
if d == 0:
|
||||
zero_flag = True
|
||||
if unit_idx == 0 and big_idx > 0:
|
||||
parts.append(_CN_BIG_UNITS[big_idx])
|
||||
else:
|
||||
if zero_flag:
|
||||
parts.append("零")
|
||||
zero_flag = False
|
||||
parts.append(_CN_DIGITS[d] + _CN_UNITS[unit_idx])
|
||||
if unit_idx == 0 and big_idx > 0:
|
||||
parts.append(_CN_BIG_UNITS[big_idx])
|
||||
parts.append("元")
|
||||
else:
|
||||
parts.append("零元")
|
||||
|
||||
if jiao > 0:
|
||||
parts.append(_CN_DIGITS[jiao] + "角")
|
||||
if fen > 0:
|
||||
parts.append(_CN_DIGITS[fen] + "分")
|
||||
else:
|
||||
if jiao == 0:
|
||||
parts.append("整")
|
||||
|
||||
return neg + "".join(parts)
|
||||
|
||||
|
||||
# ── 生成合同编号 ─────────────────────────────────────────
|
||||
async def _gen_contract_no(db: AsyncSession) -> str:
|
||||
today_str = date.today().strftime("%Y%m%d")
|
||||
prefix = f"HT-{today_str}-"
|
||||
count_stmt = select(func.count()).select_from(ErpContract).where(
|
||||
ErpContract.contract_no.like(f"{prefix}%")
|
||||
)
|
||||
count = (await db.execute(count_stmt)).scalar() or 0
|
||||
return f"{prefix}{count + 1:03d}"
|
||||
|
||||
|
||||
# ── 账期引擎 ────────────────────────────────────────────
|
||||
def calc_payment_due_date(payment_terms: str, base_date: date) -> date | None:
|
||||
"""根据付款条件枚举和基准日期(开票/发货)推算回款截止日"""
|
||||
m = re.search(r"(\d+)天", payment_terms)
|
||||
if m:
|
||||
days = int(m.group(1))
|
||||
return base_date + timedelta(days=days)
|
||||
if "货到" in payment_terms or "全款" in payment_terms:
|
||||
return base_date # 当天
|
||||
return None
|
||||
|
||||
|
||||
# ── ORM → Response ──────────────────────────────────────
|
||||
def _item_to_response(item: ErpContractItem) -> ContractItemResponse:
|
||||
sku = item.sku
|
||||
return ContractItemResponse(
|
||||
id=item.id,
|
||||
sku_id=item.sku_id,
|
||||
sku_code=sku.sku_code if sku else None,
|
||||
sku_name=sku.name if sku else None,
|
||||
spec=sku.spec if sku else None,
|
||||
unit=sku.unit if sku else None,
|
||||
qty=float(item.qty),
|
||||
unit_price=float(item.unit_price),
|
||||
sub_total=float(item.sub_total),
|
||||
)
|
||||
|
||||
|
||||
def _to_response(c: ErpContract, progress: ContractProgressResponse | None = None) -> ContractResponse:
|
||||
return ContractResponse(
|
||||
id=c.id,
|
||||
contract_no=c.contract_no,
|
||||
buyer_customer_id=c.buyer_customer_id,
|
||||
buyer_customer_name=c.buyer_customer.name if c.buyer_customer else None,
|
||||
seller_company_id=c.seller_company_id,
|
||||
seller_company_name=c.seller_company.name if c.seller_company else None,
|
||||
company_id=c.company_id,
|
||||
total_amount_excl_tax=float(c.total_amount_excl_tax or 0),
|
||||
total_amount_incl_tax=float(c.total_amount_incl_tax or 0),
|
||||
total_amount_cn=c.total_amount_cn,
|
||||
payment_terms=c.payment_terms,
|
||||
shipping_terms=c.shipping_terms,
|
||||
status=c.status,
|
||||
is_signed=c.is_signed,
|
||||
signed_file_url=c.signed_file_url,
|
||||
linked_order_id=c.linked_order_id,
|
||||
salesperson_id=c.salesperson_id,
|
||||
salesperson_name=c.salesperson.real_name if c.salesperson else None,
|
||||
sign_date=c.sign_date,
|
||||
remark=c.remark,
|
||||
delivery_terms=c.delivery_terms,
|
||||
items=[_item_to_response(i) for i in (c.items or []) if not i.is_deleted],
|
||||
progress=progress,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ── 执行进度聚合 ────────────────────────────────────────
|
||||
async def _get_progress(db: AsyncSession, contract: ErpContract) -> ContractProgressResponse:
|
||||
progress = ContractProgressResponse(is_signed=contract.is_signed)
|
||||
|
||||
if contract.linked_order_id:
|
||||
progress.has_order = True
|
||||
progress.order_id = contract.linked_order_id
|
||||
|
||||
# 是否有发货
|
||||
ship_count = (await db.execute(
|
||||
select(func.count()).select_from(ErpShippingRecord).where(
|
||||
ErpShippingRecord.order_id == contract.linked_order_id,
|
||||
ErpShippingRecord.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar() or 0
|
||||
progress.has_shipped = ship_count > 0
|
||||
|
||||
# 是否有销项发票
|
||||
inv_count = (await db.execute(
|
||||
select(func.count()).select_from(FinSalesInvoice).where(
|
||||
FinSalesInvoice.order_id == contract.linked_order_id,
|
||||
FinSalesInvoice.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar() or 0
|
||||
progress.has_invoice = inv_count > 0
|
||||
|
||||
# 是否回款(检查订单回款状态)
|
||||
order = (await db.execute(
|
||||
select(ErpOrder).where(ErpOrder.id == contract.linked_order_id)
|
||||
)).scalar_one_or_none()
|
||||
if order and order.payment_state == "paid":
|
||||
progress.is_paid = True
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
# ── 公共 eager-load 选项 ────────────────────────────────────
|
||||
def _contract_load_options():
|
||||
"""返回 selectinload 链,保证 commit 后仍可安全访问关系属性"""
|
||||
return [
|
||||
selectinload(ErpContract.buyer_customer),
|
||||
selectinload(ErpContract.seller_company),
|
||||
selectinload(ErpContract.salesperson),
|
||||
selectinload(ErpContract.items).selectinload(ErpContractItem.sku),
|
||||
]
|
||||
|
||||
|
||||
# ── Service Functions ────────────────────────────────────
|
||||
|
||||
async def create_contract(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
company_id: uuid.UUID,
|
||||
body: ContractCreate,
|
||||
) -> ContractResponse:
|
||||
contract_no = await _gen_contract_no(db)
|
||||
|
||||
# 计算合计
|
||||
total = sum(item.sub_total for item in body.items)
|
||||
|
||||
contract = ErpContract(
|
||||
contract_no=contract_no,
|
||||
buyer_customer_id=body.buyer_customer_id,
|
||||
seller_company_id=company_id,
|
||||
company_id=company_id,
|
||||
total_amount_excl_tax=total,
|
||||
total_amount_incl_tax=total, # 含税金额默认同不含税,可后续区分
|
||||
total_amount_cn=amount_to_cn(total),
|
||||
payment_terms=body.payment_terms,
|
||||
shipping_terms=body.shipping_terms,
|
||||
sign_date=body.sign_date,
|
||||
remark=body.remark,
|
||||
delivery_terms=body.delivery_terms,
|
||||
salesperson_id=user.user_id,
|
||||
status="draft",
|
||||
)
|
||||
db.add(contract)
|
||||
await db.flush()
|
||||
|
||||
# 添加明细行
|
||||
for item_data in body.items:
|
||||
item = ErpContractItem(
|
||||
contract_id=contract.id,
|
||||
sku_id=item_data.sku_id,
|
||||
qty=item_data.qty,
|
||||
unit_price=item_data.unit_price,
|
||||
sub_total=item_data.sub_total,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 重新查询并 eager-load 所有关系,避免 commit 后隐式 lazy load
|
||||
fresh = (await db.execute(
|
||||
select(ErpContract)
|
||||
.where(ErpContract.id == contract.id)
|
||||
.options(*_contract_load_options())
|
||||
)).scalar_one()
|
||||
return _to_response(fresh)
|
||||
|
||||
|
||||
async def list_contracts(
|
||||
db: AsyncSession,
|
||||
company_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> ContractListResponse:
|
||||
base_where = [
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
]
|
||||
if keyword:
|
||||
base_where.append(ErpContract.contract_no.ilike(f"%{keyword}%"))
|
||||
if status:
|
||||
base_where.append(ErpContract.status == status)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count()).select_from(ErpContract).where(*base_where)
|
||||
)).scalar() or 0
|
||||
|
||||
stmt = (
|
||||
select(ErpContract)
|
||||
.where(*base_where)
|
||||
.options(*_contract_load_options())
|
||||
.order_by(ErpContract.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
contracts = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return ContractListResponse(
|
||||
total=total,
|
||||
items=[_to_response(c) for c in contracts],
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
async def get_contract(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> ContractResponse:
|
||||
stmt = (
|
||||
select(ErpContract)
|
||||
.where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
.options(*_contract_load_options())
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
progress = await _get_progress(db, contract)
|
||||
return _to_response(contract, progress)
|
||||
|
||||
|
||||
async def update_contract(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
body: ContractUpdate,
|
||||
) -> ContractResponse:
|
||||
stmt = select(ErpContract).where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
# 更新主表字段
|
||||
update_data = body.model_dump(exclude_unset=True, exclude={"items"})
|
||||
if update_data:
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(ErpContract).where(ErpContract.id == contract_id).values(**update_data)
|
||||
)
|
||||
|
||||
# 如果有明细行更新,删旧增新
|
||||
if body.items is not None:
|
||||
await db.execute(
|
||||
update(ErpContractItem)
|
||||
.where(ErpContractItem.contract_id == contract_id)
|
||||
.values(is_deleted=True)
|
||||
)
|
||||
total = 0
|
||||
for item_data in body.items:
|
||||
item = ErpContractItem(
|
||||
contract_id=contract_id,
|
||||
sku_id=item_data.sku_id,
|
||||
qty=item_data.qty,
|
||||
unit_price=item_data.unit_price,
|
||||
sub_total=item_data.sub_total,
|
||||
)
|
||||
total += item_data.sub_total
|
||||
db.add(item)
|
||||
|
||||
await db.execute(
|
||||
update(ErpContract).where(ErpContract.id == contract_id).values(
|
||||
total_amount_excl_tax=total,
|
||||
total_amount_incl_tax=total,
|
||||
total_amount_cn=amount_to_cn(total),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
updated = (await db.execute(
|
||||
select(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.options(*_contract_load_options())
|
||||
)).scalar_one()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
async def delete_contract(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> None:
|
||||
stmt = select(ErpContract).where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
await db.execute(
|
||||
update(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.values(is_deleted=True, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def generate_order_from_contract(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""一键从合同生成订单 —— 防篡改推单逻辑"""
|
||||
stmt = (
|
||||
select(ErpContract)
|
||||
.where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
.options(*_contract_load_options())
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
if contract.linked_order_id is not None:
|
||||
raise BizException(message="该合同已关联订单,不可重复生成")
|
||||
|
||||
# 生成订单编号
|
||||
today_str = date.today().strftime("%Y%m%d")
|
||||
prefix = f"SO-{today_str}-"
|
||||
count = (await db.execute(
|
||||
select(func.count()).select_from(ErpOrder).where(
|
||||
ErpOrder.order_no.like(f"{prefix}%")
|
||||
)
|
||||
)).scalar() or 0
|
||||
order_no = f"{prefix}{count + 1:03d}"
|
||||
|
||||
# 创建订单
|
||||
new_order = ErpOrder(
|
||||
order_no=order_no,
|
||||
customer_id=contract.buyer_customer_id,
|
||||
salesperson_id=user.user_id,
|
||||
company_id=company_id,
|
||||
contract_id=contract_id,
|
||||
total_amount=float(contract.total_amount_incl_tax or 0),
|
||||
order_date=date.today(),
|
||||
)
|
||||
db.add(new_order)
|
||||
await db.flush()
|
||||
|
||||
# 复制合同明细到订单明细
|
||||
active_items = [i for i in (contract.items or []) if not i.is_deleted]
|
||||
for ci in active_items:
|
||||
oi = ErpOrderItem(
|
||||
order_id=new_order.id,
|
||||
sku_id=ci.sku_id,
|
||||
qty=float(ci.qty),
|
||||
unit_price=float(ci.unit_price),
|
||||
sub_total=float(ci.sub_total),
|
||||
)
|
||||
db.add(oi)
|
||||
|
||||
# 回填合同 linked_order_id + 激活状态
|
||||
await db.execute(
|
||||
update(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.values(
|
||||
linked_order_id=new_order.id,
|
||||
status="active",
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"order_id": str(new_order.id), "order_no": order_no}
|
||||
|
||||
|
||||
# ── 数字转中文大写金额 ──────────────────────────────────────
|
||||
def _amount_to_cn(amount: float) -> str:
|
||||
"""将数字金额转换为中文大写"""
|
||||
digits = "零壹贰叁肆伍陆柒捌玖"
|
||||
units = ["", "拾", "佰", "仟"]
|
||||
big_units = ["", "万", "亿"]
|
||||
|
||||
if amount == 0:
|
||||
return "零元整"
|
||||
|
||||
yuan = int(round(amount * 100))
|
||||
jiao = (yuan % 100) // 10
|
||||
fen = yuan % 10
|
||||
yuan_part = yuan // 100
|
||||
|
||||
result = ""
|
||||
if yuan_part > 0:
|
||||
s = str(yuan_part)
|
||||
n = len(s)
|
||||
for i, ch in enumerate(s):
|
||||
d = int(ch)
|
||||
pos = n - i - 1
|
||||
big_pos = pos // 4
|
||||
unit_pos = pos % 4
|
||||
if d != 0:
|
||||
result += digits[d] + units[unit_pos]
|
||||
else:
|
||||
if result and not result.endswith("零"):
|
||||
result += "零"
|
||||
if unit_pos == 0 and big_pos > 0:
|
||||
result = result.rstrip("零") + big_units[big_pos]
|
||||
result = result.rstrip("零") + "元"
|
||||
else:
|
||||
result = ""
|
||||
|
||||
if jiao == 0 and fen == 0:
|
||||
result += "整"
|
||||
else:
|
||||
if jiao > 0:
|
||||
result += digits[jiao] + "角"
|
||||
if fen > 0:
|
||||
result += digits[fen] + "分"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def generate_contract_docx(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> bytes:
|
||||
"""纯代码生成合同 Word 文档(紧凑排版,2 页以内)"""
|
||||
import io
|
||||
from docx import Document as DocxDocument
|
||||
from docx.shared import Pt, Cm, Emu, RGBColor
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from app.models.sys import SysCompany
|
||||
|
||||
# ── 1) 数据准备 ─────────────────────────────────────────
|
||||
contract = (await db.execute(
|
||||
select(ErpContract)
|
||||
.where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
.options(*_contract_load_options())
|
||||
)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
seller = (await db.execute(
|
||||
select(SysCompany).where(SysCompany.id == contract.seller_company_id)
|
||||
)).scalar_one_or_none()
|
||||
seller_info = (seller.full_info or {}) if seller else {}
|
||||
|
||||
buyer = contract.buyer_customer
|
||||
buyer_billing = {}
|
||||
if buyer and hasattr(buyer, "billing_info") and buyer.billing_info:
|
||||
buyer_billing = buyer.billing_info
|
||||
|
||||
total_incl = float(contract.total_amount_incl_tax or 0)
|
||||
sign_date_str = (contract.sign_date or date.today()).strftime("%Y年%m月%d日")
|
||||
buyer_name = buyer_billing.get("company_name") or (buyer.name if buyer else "")
|
||||
seller_name = seller_info.get("company_name") or (seller.name if seller else "")
|
||||
items = [i for i in (contract.items or []) if not i.is_deleted]
|
||||
|
||||
# ── 2) 创建文档 ─────────────────────────────────────────
|
||||
doc = DocxDocument()
|
||||
|
||||
# 页边距:上下2cm 左右2.5cm(紧凑)
|
||||
for section in doc.sections:
|
||||
section.top_margin = Cm(2)
|
||||
section.bottom_margin = Cm(1.5)
|
||||
section.left_margin = Cm(2.5)
|
||||
section.right_margin = Cm(2.5)
|
||||
|
||||
# ── 辅助函数 ─────────────────────────────────────────────
|
||||
# 小四 = 12pt, 1.5倍行距 = 18pt
|
||||
def add_para(text: str, font_size: int = 12, bold: bool = False,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT, space_before: int = 0,
|
||||
space_after: int = 0, font_name: str = "宋体"):
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = align
|
||||
p.paragraph_format.space_before = Pt(space_before)
|
||||
p.paragraph_format.space_after = Pt(space_after)
|
||||
p.paragraph_format.line_spacing = Pt(18) # 1.5倍行距(12pt×1.5)
|
||||
run = p.add_run(text)
|
||||
run.font.size = Pt(font_size)
|
||||
run.font.bold = bold
|
||||
run.font.name = font_name
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), font_name)
|
||||
return p
|
||||
|
||||
def set_cell(cell, text: str, font_size: int = 12, bold: bool = False,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER):
|
||||
cell.text = ""
|
||||
p = cell.paragraphs[0]
|
||||
p.alignment = align
|
||||
p.paragraph_format.space_before = Pt(0)
|
||||
p.paragraph_format.space_after = Pt(0)
|
||||
p.paragraph_format.line_spacing = Pt(18) # 1.5倍行距
|
||||
run = p.add_run(text)
|
||||
run.font.size = Pt(font_size)
|
||||
run.font.bold = bold
|
||||
run.font.name = "宋体"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
# ── 3) 标题 ──────────────────────────────────────────────
|
||||
add_para("产 品 购 销 合 同", font_size=18, bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4, font_name="黑体")
|
||||
|
||||
add_para(f"合同编号:{contract.contract_no}",
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT, space_after=4)
|
||||
|
||||
# ── 4) 甲乙方信息(紧凑表格) ────────────────────────────
|
||||
info_tbl = doc.add_table(rows=4, cols=4)
|
||||
info_tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
info_tbl.style = "Table Grid"
|
||||
|
||||
info_data = [
|
||||
("买方(甲方)", buyer_name,
|
||||
"卖方(乙方)", seller_name),
|
||||
("税号", buyer_billing.get("tax_id", "") or "",
|
||||
"税号", seller_info.get("tax_id", "") or ""),
|
||||
("地址", buyer_billing.get("address", "") or "",
|
||||
"地址", seller_info.get("address", "") or ""),
|
||||
("开户行 / 账号",
|
||||
f"{buyer_billing.get('bank_name', '') or ''} {buyer_billing.get('bank_account', '') or ''}".strip(),
|
||||
"开户行 / 账号",
|
||||
f"{seller_info.get('bank_name', '') or ''} {seller_info.get('bank_account', '') or ''}".strip()),
|
||||
]
|
||||
for ri, row_data in enumerate(info_data):
|
||||
for ci, val in enumerate(row_data):
|
||||
bold = ri == 0 and ci in (0, 2)
|
||||
set_cell(info_tbl.cell(ri, ci), val, bold=bold,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
|
||||
# ── 5) 一、产品明细 ──────────────────────────────────────
|
||||
add_para("一、产品明细", bold=True, space_before=6, space_after=2)
|
||||
|
||||
cols = 6
|
||||
tbl = doc.add_table(rows=1 + len(items) + 1, cols=cols)
|
||||
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
tbl.style = "Table Grid"
|
||||
|
||||
headers = ["序号", "产品名称", "规格", "数量", "单价(元)", "小计(元)"]
|
||||
for ci, h in enumerate(headers):
|
||||
set_cell(tbl.cell(0, ci), h, bold=True)
|
||||
|
||||
for ri, item in enumerate(items):
|
||||
sku_name = item.sku.name if item.sku else ""
|
||||
sku_spec = item.sku.spec if item.sku else ""
|
||||
set_cell(tbl.cell(ri + 1, 0), str(ri + 1))
|
||||
set_cell(tbl.cell(ri + 1, 1), sku_name, align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(tbl.cell(ri + 1, 2), sku_spec or "-")
|
||||
set_cell(tbl.cell(ri + 1, 3), str(float(item.qty)))
|
||||
set_cell(tbl.cell(ri + 1, 4), f"{float(item.unit_price):,.2f}",
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
set_cell(tbl.cell(ri + 1, 5), f"{float(item.sub_total):,.2f}",
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
|
||||
# 合计行
|
||||
last_row = len(items) + 1
|
||||
set_cell(tbl.cell(last_row, 0), "合计", bold=True)
|
||||
# 合并序号~单价列
|
||||
for ci in range(1, 4):
|
||||
set_cell(tbl.cell(last_row, ci), "")
|
||||
set_cell(tbl.cell(last_row, 4), "", align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
set_cell(tbl.cell(last_row, 5), f"{total_incl:,.2f}", bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
|
||||
# 大写金额
|
||||
add_para(f"合计金额(大写):{_amount_to_cn(total_incl)} (含13%增值税)",
|
||||
bold=True, space_before=2, space_after=2)
|
||||
|
||||
# ── 6) 二、交货及付款条件 ────────────────────────────────
|
||||
add_para("二、交货及付款条件", bold=True, space_before=4, space_after=2)
|
||||
delivery_text = contract.delivery_terms or "按双方约定"
|
||||
add_para(f"1. 货 期:{delivery_text}")
|
||||
add_para(f"2. 交货方式:{contract.shipping_terms or '买方自提'}")
|
||||
add_para(f"3. 付款条件:{contract.payment_terms or '货到付全款'}")
|
||||
|
||||
# ── 7) 三、发票信息 ──────────────────────────────────────
|
||||
add_para("三、发票信息", bold=True, space_before=4, space_after=2)
|
||||
add_para("卖方给买方开具合同金额增值税专用发票(13%增值税)。")
|
||||
|
||||
# ── 8) 四、合同细则 ──────────────────────────────────────
|
||||
add_para("四、合同细则", bold=True, space_before=4, space_after=2)
|
||||
|
||||
# 紧凑输出细则内容
|
||||
terms = [
|
||||
"第一条 质量标准:按照厂家标准执行,由于买方储存不当(如露天暴晒、混入杂质、超过保质期等)或未按产品说明书操作导致的质量问题,卖方不承担责任。",
|
||||
"第二条 卖方对质量负责的条件及期限:自货到12个月。",
|
||||
"第三条 包装标准包装物的供应与回收:产品包装均应采用国家或专业标准保护措施进行包装,以确保产品不受损害为原则,由于包装不善所引起的货物污染、损坏、损失均由卖方负担,采取装箱包装的应在包装箱内附一份详细装箱单和质量合格证,包装物不回收。",
|
||||
"第四条 合理损耗标准及计算方法:标的货物送至买方指定地点前的合理损耗由卖方负责。",
|
||||
"第五条 标的物所有权:在买方付清本合同项下全部货款之前,标的物的所有权仍属于卖方。",
|
||||
"第六条 检验标准、方法、地点及期限:按第二条标准检验。",
|
||||
"第七条 发票信息:卖方给买方开具合同金额增值税专用发票(13%增值税)。",
|
||||
"第八条 本合同解除条件:合同执行完毕。",
|
||||
(
|
||||
"第九条 违约责任:\n"
|
||||
"1、卖方应保证产品质量合格,买方有权在货到后7个工作日内且未开封状态下将卖方产品送质监局或第三方部门检验单位检验,"
|
||||
"送检样品的取样过程必须经卖方现场确认或双方共同封样,否则检验结果无效。检验结果不合格,则所发生的所有检验费用,"
|
||||
"均由卖方承担,买方可根据实际情况选择要求退货或更换。\n"
|
||||
"赔偿限额:卖方对本合同项下违约责任的赔偿总额,以本合同约定的总货款金额为限,"
|
||||
"且不承担任何间接损失(包括但不限于停工损失、利润损失等)。"
|
||||
),
|
||||
(
|
||||
"第十条 合同争议的解决方式:本合同在履行过程中发生的争执,由双方当事人协商解决,"
|
||||
"也可由当地工商行政管理部门调解;协商或调解不成的,按下列第二种方式解决。\n"
|
||||
"(一)提交当地仲裁委员会仲裁;(二)依法向卖方所在地的人民法院起诉。"
|
||||
),
|
||||
"第十一条 本合同一式两份,自双方签字盖章起生效。",
|
||||
(
|
||||
"第十二条 其他约定事项:\n"
|
||||
"1、卖方必须遵守国家有关能源管理的法律、法规;\n"
|
||||
"2、卖方必须执行买方对其提出的对能源控制进行改善的要求;\n"
|
||||
"3、卖方在运输途中和施工作业中的各种行为不应对能源造成浪费或负面影响;\n"
|
||||
"4、如卖方提供货物存在质量问题,买方书面(包括但不限于传真、邮件)通知对方,"
|
||||
"卖方在接到买方书面通知后3个工作日内要给与买方书面回复,否则将视为卖方已经认可买方提出的质量问题;"
|
||||
"如果双方意见产生争议,由卖方负责安排经买方同意的第三方进行检验,否则视为卖方质量问题;\n"
|
||||
"5、未经对方书面同意,不得将合同部分或者全部权利义务转给第三方。\n"
|
||||
"6、如遇战争、原材料短缺、工厂停产、物流管制等不可抗力因素导致货期延长,卖方不承担违约责任。"
|
||||
),
|
||||
]
|
||||
|
||||
for term in terms:
|
||||
add_para(term)
|
||||
|
||||
# ── 9) 签章区 ────────────────────────────────────────────
|
||||
add_para("", space_before=6, space_after=0) # 小间距
|
||||
|
||||
sig_tbl = doc.add_table(rows=4, cols=2)
|
||||
sig_tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
# 去边框
|
||||
for row in sig_tbl.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
paragraph.paragraph_format.space_before = Pt(0)
|
||||
paragraph.paragraph_format.space_after = Pt(0)
|
||||
|
||||
set_cell(sig_tbl.cell(0, 0), "买方(盖章):", bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(0, 1), "卖方(盖章):", bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(1, 0), "授权代表签字:",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(1, 1), "授权代表签字:",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(2, 0), f"日期:{sign_date_str}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(2, 1), f"日期:{sign_date_str}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(3, 0), f"联系电话:{buyer_billing.get('phone', '') or ''}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(3, 1), f"联系电话:{seller_info.get('phone', '') or ''}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
|
||||
# ── 10) 输出 ─────────────────────────────────────────────
|
||||
buffer = io.BytesIO()
|
||||
doc.save(buffer)
|
||||
buffer.seek(0)
|
||||
return buffer.getvalue()
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.crm import (
|
||||
CustomerCreate,
|
||||
@@ -35,6 +36,7 @@ def _to_response(c: CrmCustomer) -> CustomerResponse:
|
||||
address=c.address,
|
||||
ai_score=float(c.ai_score or 0),
|
||||
ai_persona=c.ai_persona,
|
||||
billing_info=c.billing_info,
|
||||
owner_id=c.owner_id,
|
||||
owner_name=c.owner.real_name if c.owner else None,
|
||||
status=c.status,
|
||||
@@ -44,12 +46,48 @@ def _to_response(c: CrmCustomer) -> CustomerResponse:
|
||||
)
|
||||
|
||||
|
||||
# ── 递归查询本部门 + 子部门所有用户 ID ────────────────────
|
||||
async def _get_dept_and_sub_user_ids(
|
||||
db: AsyncSession, dept_id: uuid.UUID
|
||||
) -> list[uuid.UUID]:
|
||||
"""递归获取指定部门及其所有子部门下的用户 ID 列表"""
|
||||
from app.models.sys import SysDepartment, SysUser
|
||||
|
||||
# 收集所有目标部门 ID(递归子部门)
|
||||
dept_ids: list[uuid.UUID] = [dept_id]
|
||||
queue = [dept_id]
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
children = (await db.execute(
|
||||
select(SysDepartment.id).where(
|
||||
SysDepartment.parent_id == current,
|
||||
SysDepartment.is_deleted.is_(False),
|
||||
)
|
||||
)).scalars().all()
|
||||
for child_id in children:
|
||||
dept_ids.append(child_id)
|
||||
queue.append(child_id)
|
||||
|
||||
# 查询这些部门下的所有用户 ID
|
||||
user_ids = (await db.execute(
|
||||
select(SysUser.id).where(
|
||||
SysUser.dept_id.in_(dept_ids),
|
||||
SysUser.is_deleted.is_(False),
|
||||
)
|
||||
)).scalars().all()
|
||||
return list(user_ids)
|
||||
|
||||
|
||||
# ── 权限校验 ─────────────────────────────────────────────
|
||||
def _check_access(customer: CrmCustomer, user: CurrentUserPayload) -> None:
|
||||
def _check_access(customer: CrmCustomer, user: CurrentUserPayload, *, dept_user_ids: list[uuid.UUID] | None = None) -> None:
|
||||
if user.data_scope == "all":
|
||||
return
|
||||
if user.data_scope == "dept_and_sub":
|
||||
return # 简化版:放通本部门
|
||||
# 如果有预查询的部门用户列表,校验 owner 是否在列表内
|
||||
if dept_user_ids is not None:
|
||||
if customer.owner_id not in dept_user_ids:
|
||||
raise ForbiddenException("无权访问该客户(数据权限:本部门及子部门)")
|
||||
return
|
||||
# data_scope == 'self'
|
||||
if customer.owner_id != user.user_id:
|
||||
raise ForbiddenException("无权访问该客户(数据权限:仅本人)")
|
||||
@@ -70,6 +108,7 @@ async def create_customer(
|
||||
phone=body.phone,
|
||||
email=body.email,
|
||||
address=body.address,
|
||||
billing_info=body.billing_info.model_dump() if body.billing_info else None,
|
||||
status=body.status,
|
||||
owner_id=user.user_id,
|
||||
)
|
||||
@@ -98,12 +137,12 @@ async def list_customers(
|
||||
base_where.append(CrmCustomer.owner_id == user.user_id)
|
||||
elif user.data_scope == "dept_and_sub":
|
||||
if user.dept_id is not None:
|
||||
from app.models.sys import SysUser
|
||||
sub = select(SysUser.id).where(
|
||||
SysUser.dept_id == user.dept_id,
|
||||
SysUser.is_deleted.is_(False),
|
||||
)
|
||||
base_where.append(CrmCustomer.owner_id.in_(sub))
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
if dept_user_ids:
|
||||
base_where.append(CrmCustomer.owner_id.in_(dept_user_ids))
|
||||
else:
|
||||
# 部门无用户 → 仅显示自己的
|
||||
base_where.append(CrmCustomer.owner_id == user.user_id)
|
||||
|
||||
if keyword:
|
||||
base_where.append(CrmCustomer.name.ilike(f"%{keyword}%"))
|
||||
@@ -144,7 +183,11 @@ async def get_customer(
|
||||
if customer is None:
|
||||
raise NotFoundException("客户不存在或已被删除")
|
||||
|
||||
_check_access(customer, user)
|
||||
# dept_and_sub 需要先查询部门用户列表
|
||||
dept_user_ids = None
|
||||
if user.data_scope == "dept_and_sub" and user.dept_id:
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
_check_access(customer, user, dept_user_ids=dept_user_ids)
|
||||
return _to_response(customer)
|
||||
|
||||
|
||||
@@ -162,7 +205,10 @@ async def update_customer(
|
||||
if customer is None:
|
||||
raise NotFoundException("客户不存在或已被删除")
|
||||
|
||||
_check_access(customer, user)
|
||||
dept_user_ids = None
|
||||
if user.data_scope == "dept_and_sub" and user.dept_id:
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
_check_access(customer, user, dept_user_ids=dept_user_ids)
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
@@ -193,7 +239,10 @@ async def delete_customer(
|
||||
if customer is None:
|
||||
raise NotFoundException("客户不存在或已被删除")
|
||||
|
||||
_check_access(customer, user)
|
||||
dept_user_ids = None
|
||||
if user.data_scope == "dept_and_sub" and user.dept_id:
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
_check_access(customer, user, dept_user_ids=dept_user_ids)
|
||||
|
||||
await db.execute(
|
||||
update(CrmCustomer)
|
||||
@@ -216,7 +265,10 @@ async def restore_customer(
|
||||
if customer is None:
|
||||
raise NotFoundException("客户不存在或未被归档")
|
||||
|
||||
_check_access(customer, user)
|
||||
dept_user_ids = None
|
||||
if user.data_scope == "dept_and_sub" and user.dept_id:
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
_check_access(customer, user, dept_user_ids=dept_user_ids)
|
||||
|
||||
await db.execute(
|
||||
update(CrmCustomer)
|
||||
@@ -226,6 +278,49 @@ async def restore_customer(
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def transfer_customer(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
customer_id: uuid.UUID,
|
||||
new_owner_id: uuid.UUID,
|
||||
) -> CustomerResponse:
|
||||
"""将客户转移至指定人员名下(仅管理员)"""
|
||||
if user.data_scope != "all":
|
||||
raise ForbiddenException("仅管理员可执行客户转移操作")
|
||||
|
||||
stmt = select(CrmCustomer).where(
|
||||
CrmCustomer.id == customer_id,
|
||||
CrmCustomer.is_deleted.is_(False),
|
||||
)
|
||||
customer = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if customer is None:
|
||||
raise NotFoundException("客户不存在或已被归档")
|
||||
|
||||
if customer.owner_id == new_owner_id:
|
||||
raise BizException(message="目标负责人与当前负责人相同,无需转移")
|
||||
|
||||
# 校验目标用户是否存在
|
||||
from app.models.sys import SysUser
|
||||
target = (await db.execute(
|
||||
select(SysUser).where(SysUser.id == new_owner_id)
|
||||
)).scalar_one_or_none()
|
||||
if target is None:
|
||||
raise NotFoundException("目标负责人不存在")
|
||||
|
||||
old_owner_name = customer.owner.real_name if customer.owner else "(无)"
|
||||
|
||||
await db.execute(
|
||||
update(CrmCustomer)
|
||||
.where(CrmCustomer.id == customer_id)
|
||||
.values(owner_id=new_owner_id, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(customer)
|
||||
|
||||
print(f"[客户转移] {customer.name}: {old_owner_name} → {target.real_name} (操作人: {user.real_name})")
|
||||
return _to_response(customer)
|
||||
|
||||
|
||||
async def get_customer_products(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
@@ -241,7 +336,10 @@ async def get_customer_products(
|
||||
customer = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if customer is None:
|
||||
raise NotFoundException("客户不存在")
|
||||
_check_access(customer, user)
|
||||
dept_user_ids = None
|
||||
if user.data_scope == "dept_and_sub" and user.dept_id:
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
_check_access(customer, user, dept_user_ids=dept_user_ids)
|
||||
|
||||
# 聚合: 该客户所有订单中的 SKU,含总数量、最近下单时间
|
||||
agg_stmt = (
|
||||
@@ -299,12 +397,11 @@ async def search_customers(
|
||||
base_where.append(CrmCustomer.owner_id == user.user_id)
|
||||
elif user.data_scope == "dept_and_sub":
|
||||
if user.dept_id is not None:
|
||||
from app.models.sys import SysUser
|
||||
sub = select(SysUser.id).where(
|
||||
SysUser.dept_id == user.dept_id,
|
||||
SysUser.is_deleted.is_(False),
|
||||
)
|
||||
base_where.append(CrmCustomer.owner_id.in_(sub))
|
||||
dept_user_ids = await _get_dept_and_sub_user_ids(db, user.dept_id)
|
||||
if dept_user_ids:
|
||||
base_where.append(CrmCustomer.owner_id.in_(dept_user_ids))
|
||||
else:
|
||||
base_where.append(CrmCustomer.owner_id == user.user_id)
|
||||
|
||||
# 模糊搜索(名称 / 联系人 / 电话)
|
||||
from sqlalchemy import or_
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
|
||||
from app.models.finance import FinExpenseDetail, FinExpenseRecord, FinInvoicePool
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.finance import (
|
||||
ExpenseBriefResponse, ExpenseCreate, ExpenseDetailResponse,
|
||||
@@ -84,12 +85,13 @@ async def _release_invoices(db: AsyncSession, expense_id: uuid.UUID, now: dateti
|
||||
|
||||
# ── Service Functions ────────────────────────────────────
|
||||
|
||||
async def create_invoice(db: AsyncSession, user: CurrentUserPayload, body: InvoiceCreate) -> InvoiceResponse:
|
||||
async def create_invoice(db: AsyncSession, user: CurrentUserPayload, body: InvoiceCreate, company_id: uuid.UUID) -> InvoiceResponse:
|
||||
invoice = FinInvoicePool(
|
||||
uploader_id=user.user_id, file_url=body.file_url,
|
||||
merchant_name=body.merchant_name, amount=body.amount,
|
||||
invoice_date=body.invoice_date, type=body.type,
|
||||
ai_extracted_data=body.ai_extracted_data, is_used=False,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(invoice)
|
||||
await db.commit()
|
||||
@@ -101,8 +103,11 @@ async def list_invoices(
|
||||
db: AsyncSession, user: CurrentUserPayload,
|
||||
page: int = 1, size: int = 20,
|
||||
inv_type: str | None = None, is_used: bool | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> InvoiceListResponse:
|
||||
where = [FinInvoicePool.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
where.append(FinInvoicePool.company_id == company_id)
|
||||
if user.data_scope == "self":
|
||||
where.append(FinInvoicePool.uploader_id == user.user_id)
|
||||
elif user.data_scope == "dept_and_sub":
|
||||
@@ -135,7 +140,7 @@ async def void_invoice(db: AsyncSession, user: CurrentUserPayload, invoice_id: u
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_expense(db: AsyncSession, user: CurrentUserPayload, body: ExpenseCreate) -> ExpenseResponse:
|
||||
async def create_expense(db: AsyncSession, user: CurrentUserPayload, body: ExpenseCreate, company_id: uuid.UUID) -> ExpenseResponse:
|
||||
invoice_ids = [item.invoice_id for item in body.items]
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
@@ -154,6 +159,7 @@ async def create_expense(db: AsyncSession, user: CurrentUserPayload, body: Expen
|
||||
system_no = await _generate_expense_no(db)
|
||||
expense = FinExpenseRecord(
|
||||
system_no=system_no, applicant_id=user.user_id,
|
||||
company_id=company_id,
|
||||
total_amount=body.total_amount, status="submitted", remark=body.remark,
|
||||
)
|
||||
db.add(expense)
|
||||
@@ -184,8 +190,11 @@ async def list_expenses(
|
||||
db: AsyncSession, user: CurrentUserPayload,
|
||||
page: int = 1, size: int = 20,
|
||||
status: str | None = None, applicant_id: uuid.UUID | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> ExpenseListResponse:
|
||||
where = [FinExpenseRecord.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
where.append(FinExpenseRecord.company_id == company_id)
|
||||
if user.data_scope == "self":
|
||||
where.append(FinExpenseRecord.applicant_id == user.user_id)
|
||||
elif user.data_scope == "dept_and_sub":
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
发票结构化解析器 — OFD / XML 零算力提取
|
||||
OFD 文件本质是 ZIP 包含 XML,直接解包提取发票字段。
|
||||
XML 电子发票(数电票)直接 XPath 提取。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_ofd_invoice(file_bytes: bytes) -> dict:
|
||||
"""
|
||||
解析 OFD 电子发票文件。
|
||||
OFD = ZIP 压缩包,内含 XML 描述文件。
|
||||
提取发票关键字段,返回结构化 dict。
|
||||
"""
|
||||
result: dict = {}
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
# 收集所有 XML 内容
|
||||
all_text = ""
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".xml"):
|
||||
try:
|
||||
xml_bytes = zf.read(name)
|
||||
xml_text = xml_bytes.decode("utf-8", errors="replace")
|
||||
all_text += xml_text + "\n"
|
||||
|
||||
# 尝试从 XML 标签中提取结构化数据
|
||||
extracted = _extract_from_xml_text(xml_text)
|
||||
if extracted:
|
||||
result.update(extracted)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 如果解析出了字段就直接返回
|
||||
if result.get("merchant") or result.get("amount"):
|
||||
return {"success": True, "data": result}
|
||||
|
||||
# 降级:把所有 XML 文本当纯文本返回,交给 LLM 处理
|
||||
if all_text.strip():
|
||||
return {"success": True, "data": {"raw_text": all_text[:8000]}, "needs_llm": True}
|
||||
|
||||
return {"success": False, "data": {}, "error": "OFD 文件中未找到有效 XML 内容"}
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
return {"success": False, "data": {}, "error": "OFD 文件格式损坏或不是有效的 OFD 文件"}
|
||||
except Exception as e:
|
||||
return {"success": False, "data": {}, "error": f"OFD 解析失败: {e}"}
|
||||
|
||||
|
||||
def parse_xml_invoice(file_bytes: bytes) -> dict:
|
||||
"""
|
||||
解析 XML 格式电子发票(数电票)。
|
||||
直接从 XML 标签提取所有发票字段。
|
||||
"""
|
||||
try:
|
||||
xml_text = file_bytes.decode("utf-8", errors="replace")
|
||||
result = _extract_from_xml_text(xml_text)
|
||||
|
||||
if result and (result.get("merchant") or result.get("amount")):
|
||||
return {"success": True, "data": result}
|
||||
|
||||
# 降级:XML 结构未匹配预设标签,交给 LLM
|
||||
if xml_text.strip():
|
||||
return {"success": True, "data": {"raw_text": xml_text[:8000]}, "needs_llm": True}
|
||||
|
||||
return {"success": False, "data": {}, "error": "XML 文件内容为空"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "data": {}, "error": f"XML 解析失败: {e}"}
|
||||
|
||||
|
||||
def parse_zip_invoices(file_bytes: bytes) -> list[dict]:
|
||||
"""
|
||||
解析 ZIP 压缩包中的所有 XML 发票文件。
|
||||
返回列表,每个元素 = {"filename": str, "success": bool, "data": dict, ...}
|
||||
支持系统导出的 ZIP 格式(内含多个 XML 发票)。
|
||||
"""
|
||||
results: list[dict] = []
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
xml_names = [n for n in zf.namelist() if n.lower().endswith(".xml")]
|
||||
if not xml_names:
|
||||
return [{"filename": "(zip)", "success": False, "data": {}, "error": "ZIP 包中未找到 XML 文件"}]
|
||||
|
||||
for name in xml_names:
|
||||
try:
|
||||
xml_bytes = zf.read(name)
|
||||
result = parse_xml_invoice(xml_bytes)
|
||||
result["filename"] = os.path.basename(name)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append({"filename": os.path.basename(name), "success": False, "data": {}, "error": str(e)})
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
return [{"filename": "(zip)", "success": False, "data": {}, "error": "不是有效的 ZIP 文件"}]
|
||||
except Exception as e:
|
||||
return [{"filename": "(zip)", "success": False, "data": {}, "error": f"ZIP 解析失败: {e}"}]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── 内部工具函数 ──────────────────────────────────────
|
||||
|
||||
# 常见发票 XML 标签名映射(兼容多种数电票 XML 格式)
|
||||
_FIELD_PATTERNS = {
|
||||
"merchant": [
|
||||
"SalesName", "SellerName", "销售方名称", "销方名称",
|
||||
"开票方", "Seller", "salername", "xfmc",
|
||||
],
|
||||
"buyer": [
|
||||
"BuyerName", "PurchaserName", "购买方名称", "购方名称",
|
||||
"Buyer", "buyername", "gfmc",
|
||||
],
|
||||
"amount": [
|
||||
"TotalAmount", "Amount", "InvoiceAmount", "金额",
|
||||
"合计金额", "价税合计", "jshj", "hjje",
|
||||
],
|
||||
"tax_amount": [
|
||||
"TotalTax", "TaxAmount", "Tax", "税额",
|
||||
"合计税额", "hjse",
|
||||
],
|
||||
"date": [
|
||||
"IssueDate", "InvoiceDate", "BillingDate", "开票日期",
|
||||
"kprq",
|
||||
],
|
||||
"invoice_code": [
|
||||
"InvoiceCode", "发票代码", "fpdm",
|
||||
],
|
||||
"invoice_number": [
|
||||
"InvoiceNumber", "InvoiceNo", "发票号码", "fphm",
|
||||
],
|
||||
"items": [
|
||||
"GoodsName", "ItemName", "商品名称", "货物名称", "spmc",
|
||||
],
|
||||
"tax_rate": [
|
||||
"TaxRate", "税率", "sl",
|
||||
],
|
||||
"remark": [
|
||||
"Remark", "备注", "bz",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _extract_from_xml_text(xml_text: str) -> Optional[dict]:
|
||||
"""从 XML 文本中用多种策略提取发票字段。"""
|
||||
result: dict = {}
|
||||
|
||||
# 策略 1: 正则匹配 <TagName>Value</TagName> 格式
|
||||
for field, tag_names in _FIELD_PATTERNS.items():
|
||||
for tag in tag_names:
|
||||
# 匹配 <Tag>value</Tag> 或 <ns:Tag>value</ns:Tag>
|
||||
pattern = rf'<(?:\w+:)?{re.escape(tag)}[^>]*>([^<]+)</(?:\w+:)?{re.escape(tag)}>'
|
||||
match = re.search(pattern, xml_text, re.IGNORECASE)
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
if value:
|
||||
# 数字字段转数值
|
||||
if field in ("amount", "tax_amount"):
|
||||
try:
|
||||
result[field] = float(value)
|
||||
except ValueError:
|
||||
result[field] = value
|
||||
else:
|
||||
result[field] = value
|
||||
break # 找到一个就跳到下一个字段
|
||||
|
||||
# 策略 2: 尝试 ElementTree 解析
|
||||
if not result:
|
||||
try:
|
||||
# 移除 XML 声明中可能的编码问题
|
||||
cleaned = re.sub(r'<\?xml[^?]*\?>', '', xml_text).strip()
|
||||
if cleaned:
|
||||
root = ET.fromstring(cleaned)
|
||||
_extract_from_element(root, result)
|
||||
except ET.ParseError:
|
||||
pass
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _extract_from_element(elem: ET.Element, result: dict, depth: int = 0):
|
||||
"""递归遍历 XML 元素树提取字段。"""
|
||||
if depth > 10:
|
||||
return
|
||||
|
||||
tag_local = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
|
||||
|
||||
for field, tag_names in _FIELD_PATTERNS.items():
|
||||
if field not in result:
|
||||
for tn in tag_names:
|
||||
if tag_local.lower() == tn.lower():
|
||||
text = (elem.text or "").strip()
|
||||
if text:
|
||||
if field in ("amount", "tax_amount"):
|
||||
try:
|
||||
result[field] = float(text)
|
||||
except ValueError:
|
||||
result[field] = text
|
||||
else:
|
||||
result[field] = text
|
||||
break
|
||||
|
||||
for child in elem:
|
||||
_extract_from_element(child, result, depth + 1)
|
||||
@@ -72,11 +72,12 @@ async def ocr_image(
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "/no_think\n" + prompt,
|
||||
"content": prompt,
|
||||
"images": [image_base64], # Ollama vision 格式
|
||||
},
|
||||
],
|
||||
"stream": False,
|
||||
"think": False, # 关闭思考模式:稳定输出、避免死循环、提速 2-5x
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"num_predict": 2000,
|
||||
@@ -87,19 +88,18 @@ async def ocr_image(
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
if resp.status_code != 200:
|
||||
print(f"[OCR] 3090 返回 {resp.status_code}: {resp.text[:200]}")
|
||||
return {"success": False, "data": {}, "error": f"VL 模型返回 {resp.status_code}"}
|
||||
detail = resp.text[:200]
|
||||
print(f"[OCR] 3090 返回 {resp.status_code}: {detail}")
|
||||
if "model runner" in detail:
|
||||
return {"success": False, "data": {}, "error": "AI OCR 模型进程崩溃,请联系管理员重启 Ollama 服务"}
|
||||
return {"success": False, "data": {}, "error": f"AI OCR 服务异常 (HTTP {resp.status_code}),请稍后重试"}
|
||||
|
||||
data = resp.json()
|
||||
# Qwen3.5 的 CoT 推理放在 message.thinking,最终结果在 message.content
|
||||
content = data.get("message", {}).get("content", "")
|
||||
thinking = data.get("message", {}).get("thinking", "")
|
||||
|
||||
# 优先从 content 提取 JSON,回退到 thinking
|
||||
for text_source in [content, thinking]:
|
||||
if not text_source:
|
||||
continue
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
|
||||
# 关闭思考模式后,结果直接在 content(无 thinking 字段)
|
||||
if content:
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
|
||||
json_match = re.search(r'\{[\s\S]*\}', cleaned)
|
||||
if json_match:
|
||||
try:
|
||||
@@ -107,16 +107,14 @@ async def ocr_image(
|
||||
print(f"[OCR] 解析成功: {list(result.keys())}")
|
||||
return {"success": True, "data": result}
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
pass
|
||||
|
||||
# 没有提取到 JSON,返回原始文本
|
||||
raw = content or thinking
|
||||
print(f"[OCR] 未能提取 JSON, 内容长度: content={len(content)}, thinking={len(thinking)}")
|
||||
return {"success": True, "data": {"raw_text": raw[:2000]}}
|
||||
print(f"[OCR] 未能提取 JSON, content 长度: {len(content)}")
|
||||
return {"success": True, "data": {"raw_text": content[:2000]}}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
print("[OCR] 3090 超时(60s)")
|
||||
return {"success": False, "data": {}, "error": "VL 模型响应超时"}
|
||||
print("[OCR] 3090 超时(120s)")
|
||||
return {"success": False, "data": {}, "error": "AI OCR 响应超时(120s),模型可能负载过高,请稍后重试"}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[OCR] JSON 解析失败: {e}")
|
||||
return {"success": False, "data": {}, "error": f"JSON 解析失败: {e}"}
|
||||
@@ -172,11 +170,11 @@ async def extract_invoice_from_text(
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"/no_think\n{prompt}\n\n--- 以下是发票文本内容 ---\n\n{truncated}",
|
||||
# 不传 images —— 纯文本模式
|
||||
"content": f"{prompt}\n\n--- 以下是发票文本内容 ---\n\n{truncated}",
|
||||
},
|
||||
],
|
||||
"stream": False,
|
||||
"think": False, # 关闭思考模式
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"num_predict": 2000,
|
||||
@@ -192,12 +190,9 @@ async def extract_invoice_from_text(
|
||||
|
||||
data = resp.json()
|
||||
content = data.get("message", {}).get("content", "")
|
||||
thinking = data.get("message", {}).get("thinking", "")
|
||||
|
||||
for text_source in [content, thinking]:
|
||||
if not text_source:
|
||||
continue
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
|
||||
if content:
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
|
||||
json_match = re.search(r'\{[\s\S]*\}', cleaned)
|
||||
if json_match:
|
||||
try:
|
||||
@@ -205,11 +200,10 @@ async def extract_invoice_from_text(
|
||||
print(f"[TextExtract] AI 提取成功: {list(result.keys())}")
|
||||
return {"success": True, "data": result}
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
pass
|
||||
|
||||
raw = content or thinking
|
||||
print(f"[TextExtract] 未能提取 JSON, 内容: {raw[:200]}")
|
||||
return {"success": True, "data": {"raw_text": raw[:2000]}}
|
||||
print(f"[TextExtract] 未能提取 JSON, content: {content[:200]}")
|
||||
return {"success": True, "data": {"raw_text": content[:2000]}}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
print("[TextExtract] 3090 超时")
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
OCR 后台 Worker — asyncio 协程,FastAPI lifespan 启动
|
||||
策略 C: 工作时间限流(1并发 + 60s间隔),17:00-20:00 BJT 全速
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.database import async_session_factory
|
||||
from app.models.finance import FinInvoicePool, FinOcrTask
|
||||
|
||||
|
||||
class OcrWorker:
|
||||
"""后台 OCR 任务处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.current_task_id: uuid.UUID | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
print("[OcrWorker] 启动 — 策略 C: 工作时间限流, 17-20 BJT 全速")
|
||||
|
||||
async def stop(self):
|
||||
self.running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
print("[OcrWorker] 已停止")
|
||||
|
||||
async def _run_loop(self):
|
||||
"""主循环:每 10 秒检查一次队列"""
|
||||
while self.running:
|
||||
try:
|
||||
task = await self._pick_next_task()
|
||||
if task:
|
||||
await self._process_task(task)
|
||||
# 限流:非高峰期间隔 60s
|
||||
if not self._is_peak_time():
|
||||
await asyncio.sleep(60)
|
||||
else:
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[OcrWorker] 循环异常: {e}")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
def _is_peak_time(self) -> bool:
|
||||
"""17:00-20:00 BJT = 09:00-12:00 UTC"""
|
||||
utc_hour = datetime.utcnow().hour
|
||||
return 9 <= utc_hour < 12
|
||||
|
||||
async def _pick_next_task(self) -> dict | None:
|
||||
"""从 DB 获取优先级最高的 pending 任务"""
|
||||
async with async_session_factory() as db:
|
||||
stmt = (
|
||||
select(FinOcrTask)
|
||||
.where(
|
||||
FinOcrTask.status == "pending",
|
||||
FinOcrTask.is_deleted.is_(False),
|
||||
FinOcrTask.retry_count < FinOcrTask.max_retries,
|
||||
)
|
||||
.order_by(FinOcrTask.priority, FinOcrTask.created_at)
|
||||
.limit(1)
|
||||
)
|
||||
task = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not task:
|
||||
return None
|
||||
|
||||
# 标记为 processing
|
||||
task.status = "processing"
|
||||
task.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
self.current_task_id = task.id
|
||||
return {
|
||||
"id": task.id,
|
||||
"file_url": task.file_url,
|
||||
"file_ext": task.file_ext,
|
||||
"original_name": task.original_name,
|
||||
"uploader_id": task.uploader_id,
|
||||
"company_id": task.company_id,
|
||||
"inv_type": task.inv_type,
|
||||
"retry_count": task.retry_count,
|
||||
}
|
||||
|
||||
async def _process_task(self, task_info: dict):
|
||||
"""执行 OCR 并更新"""
|
||||
task_id = task_info["id"]
|
||||
file_url = task_info["file_url"]
|
||||
file_ext = task_info["file_ext"]
|
||||
print(f"[OcrWorker] 处理任务 {task_id} ({task_info['original_name']}, {file_ext})")
|
||||
|
||||
try:
|
||||
# 读取文件
|
||||
file_path = file_url.lstrip("/")
|
||||
if not os.path.exists(file_path):
|
||||
await self._mark_failed(task_id, f"文件不存在: {file_path}")
|
||||
return
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
ocr_data = {}
|
||||
message = ""
|
||||
|
||||
# PDF 处理
|
||||
if file_ext == ".pdf":
|
||||
ocr_data, message = await self._process_pdf(file_bytes)
|
||||
# 图片处理
|
||||
elif file_ext in (".png", ".jpg", ".jpeg"):
|
||||
ocr_data, message = await self._process_image(file_bytes)
|
||||
else:
|
||||
await self._mark_failed(task_id, f"不支持的文件格式: {file_ext}")
|
||||
return
|
||||
|
||||
if ocr_data and (ocr_data.get("merchant") or ocr_data.get("amount")):
|
||||
# OCR 成功 → 自动入池
|
||||
await self._mark_success_and_pool(task_id, task_info, ocr_data)
|
||||
print(f"[OcrWorker] ✅ {task_info['original_name']} 入池成功")
|
||||
else:
|
||||
# OCR 完成但没提取到关键字段
|
||||
await self._mark_failed(
|
||||
task_id,
|
||||
message or "AI 未能提取发票关键字段(开票方/金额),请手动录入",
|
||||
ocr_data,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[OcrWorker] ❌ 任务 {task_id} 异常: {e}")
|
||||
await self._mark_failed(task_id, str(e))
|
||||
|
||||
self.current_task_id = None
|
||||
|
||||
async def _process_pdf(self, file_bytes: bytes) -> tuple[dict, str]:
|
||||
"""PDF: 先尝试文本提取,失败降级 Vision OCR"""
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text() + "\n"
|
||||
doc.close()
|
||||
text = text.strip()
|
||||
|
||||
if len(text) > 50:
|
||||
from app.services.ocr_service import extract_invoice_from_text
|
||||
result = await extract_invoice_from_text(text, "invoice")
|
||||
if result.get("success") and result.get("data"):
|
||||
return result["data"], "PDF 文本解析成功"
|
||||
|
||||
# 降级: 扫描件 → Vision OCR
|
||||
doc2 = fitz.open(stream=file_bytes, filetype="pdf")
|
||||
pix = doc2[0].get_pixmap(dpi=150)
|
||||
ocr_bytes = pix.tobytes("png")
|
||||
doc2.close()
|
||||
return await self._vision_ocr(ocr_bytes)
|
||||
|
||||
except Exception as e:
|
||||
return {}, f"PDF 处理失败: {e}"
|
||||
|
||||
async def _process_image(self, file_bytes: bytes) -> tuple[dict, str]:
|
||||
"""图片: Vision OCR"""
|
||||
return await self._vision_ocr(file_bytes)
|
||||
|
||||
async def _vision_ocr(self, image_bytes: bytes) -> tuple[dict, str]:
|
||||
"""调用 3090 Vision OCR"""
|
||||
import base64
|
||||
from app.services.ocr_service import ocr_image
|
||||
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
result = await ocr_image(image_b64, "invoice")
|
||||
if result.get("success"):
|
||||
return result.get("data", {}), "Vision OCR 成功"
|
||||
return {}, result.get("error", "OCR 失败")
|
||||
|
||||
async def _mark_success_and_pool(self, task_id: uuid.UUID, task_info: dict, ocr_data: dict):
|
||||
"""标记成功 + 自动入池"""
|
||||
async with async_session_factory() as db:
|
||||
merchant = ocr_data.get("merchant") or ocr_data.get("merchant_name") or "(AI 提取)"
|
||||
amount = 0
|
||||
try:
|
||||
amount = float(ocr_data.get("amount", 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
invoice_date_str = ocr_data.get("date")
|
||||
invoice_date = None
|
||||
if invoice_date_str:
|
||||
try:
|
||||
from datetime import date as dt_date
|
||||
invoice_date = dt_date.fromisoformat(invoice_date_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
inv = FinInvoicePool(
|
||||
uploader_id=task_info["uploader_id"],
|
||||
company_id=task_info["company_id"],
|
||||
file_url=task_info["file_url"],
|
||||
merchant_name=merchant,
|
||||
amount=amount,
|
||||
invoice_date=invoice_date,
|
||||
type=task_info["inv_type"],
|
||||
ai_extracted_data=ocr_data,
|
||||
is_used=False,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
|
||||
await db.execute(
|
||||
update(FinOcrTask)
|
||||
.where(FinOcrTask.id == task_id)
|
||||
.values(
|
||||
status="success",
|
||||
ocr_result=ocr_data,
|
||||
invoice_pool_id=inv.id,
|
||||
error_message=None,
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def _mark_failed(self, task_id: uuid.UUID, error: str, partial_data: dict | None = None):
|
||||
"""标记失败 + retry_count+1"""
|
||||
async with async_session_factory() as db:
|
||||
task = (await db.execute(
|
||||
select(FinOcrTask).where(FinOcrTask.id == task_id)
|
||||
)).scalar_one_or_none()
|
||||
if not task:
|
||||
return
|
||||
|
||||
new_retry = task.retry_count + 1
|
||||
new_status = "failed" if new_retry >= task.max_retries else "pending"
|
||||
|
||||
await db.execute(
|
||||
update(FinOcrTask)
|
||||
.where(FinOcrTask.id == task_id)
|
||||
.values(
|
||||
status=new_status,
|
||||
retry_count=new_retry,
|
||||
error_message=error,
|
||||
ocr_result=partial_data or task.ocr_result,
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
if new_status == "pending":
|
||||
print(f"[OcrWorker] ⚠️ 任务 {task_id} 第 {new_retry} 次重试入队")
|
||||
else:
|
||||
print(f"[OcrWorker] ❌ 任务 {task_id} 已达最大重试次数,标记失败")
|
||||
|
||||
|
||||
# 单例
|
||||
ocr_worker = OcrWorker()
|
||||
@@ -16,6 +16,7 @@ from app.core.exceptions import BizException, ForbiddenException, NotFoundExcept
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.models.erp import ProductSku
|
||||
from app.models.order import ErpOrder, ErpOrderItem
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.order import (
|
||||
OrderBriefResponse,
|
||||
@@ -156,6 +157,7 @@ async def create_order(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
body: OrderCreate,
|
||||
company_id: uuid.UUID,
|
||||
) -> OrderResponse:
|
||||
# 校验客户存在
|
||||
cust = (
|
||||
@@ -193,6 +195,7 @@ async def create_order(
|
||||
order_no=order_no,
|
||||
customer_id=body.customer_id,
|
||||
salesperson_id=user.user_id,
|
||||
company_id=company_id,
|
||||
total_amount=total,
|
||||
shipping_state="pending",
|
||||
payment_state="unpaid",
|
||||
@@ -236,8 +239,11 @@ async def list_orders(
|
||||
shipping_state: str | None = None,
|
||||
payment_state: str | None = None,
|
||||
keyword: str | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> OrderListResponse:
|
||||
where: list[Any] = [ErpOrder.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
where.append(ErpOrder.company_id == company_id)
|
||||
|
||||
if user.data_scope == "self":
|
||||
where.append(ErpOrder.salesperson_id == user.user_id)
|
||||
@@ -284,13 +290,17 @@ async def get_order(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
order_id: uuid.UUID,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> OrderResponse:
|
||||
order = (
|
||||
await db.execute(
|
||||
select(ErpOrder).where(
|
||||
where_clause = [
|
||||
ErpOrder.id == order_id,
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
)
|
||||
]
|
||||
if company_id:
|
||||
where_clause.append(ErpOrder.company_id == company_id)
|
||||
order = (
|
||||
await db.execute(
|
||||
select(ErpOrder).where(*where_clause)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if order is None:
|
||||
|
||||
@@ -14,7 +14,8 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import BizException, NotFoundException
|
||||
from app.models.erp import InventoryFlow, ProductCategory, ProductSku
|
||||
from app.models.erp import ErpSkuInventory, InventoryFlow, ProductCategory, ProductSku
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.erp import (
|
||||
CategoryCreate,
|
||||
@@ -31,7 +32,10 @@ from app.schemas.erp import (
|
||||
|
||||
# ── ORM → Response ───────────────────────────────────────
|
||||
|
||||
def _sku_to_response(s: ProductSku) -> SkuResponse:
|
||||
def _sku_to_response(
|
||||
s: ProductSku,
|
||||
inv: ErpSkuInventory | None = None,
|
||||
) -> SkuResponse:
|
||||
return SkuResponse(
|
||||
id=s.id,
|
||||
sku_code=s.sku_code,
|
||||
@@ -40,8 +44,8 @@ def _sku_to_response(s: ProductSku) -> SkuResponse:
|
||||
category_name=s.category.name if s.category else None,
|
||||
spec=s.spec,
|
||||
standard_price=float(s.standard_price or 0),
|
||||
stock_qty=float(s.stock_qty or 0),
|
||||
warning_threshold=float(s.warning_threshold or 0),
|
||||
stock_qty=float(inv.stock_qty) if inv else 0.0,
|
||||
warning_threshold=float(inv.warning_threshold) if inv else 0.0,
|
||||
unit=s.unit,
|
||||
status=s.status,
|
||||
created_at=s.created_at,
|
||||
@@ -200,11 +204,13 @@ async def delete_category(db: AsyncSession, cat_id: uuid.UUID) -> None:
|
||||
|
||||
async def list_skus(
|
||||
db: AsyncSession,
|
||||
company_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
category_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
) -> SkuListResponse:
|
||||
"""LEFT JOIN erp_sku_inventory 获取当前公司库存,COALESCE 兜底为 0"""
|
||||
where: list[Any] = [ProductSku.is_deleted.is_(False)]
|
||||
if category_id:
|
||||
where.append(ProductSku.category_id == category_id)
|
||||
@@ -218,24 +224,31 @@ async def list_skus(
|
||||
await db.execute(select(func.count()).select_from(ProductSku).where(*where))
|
||||
).scalar() or 0
|
||||
|
||||
# LEFT JOIN erp_sku_inventory 带出当前公司库存
|
||||
stmt = (
|
||||
select(ProductSku)
|
||||
select(ProductSku, ErpSkuInventory)
|
||||
.outerjoin(
|
||||
ErpSkuInventory,
|
||||
(ErpSkuInventory.sku_id == ProductSku.id)
|
||||
& (ErpSkuInventory.company_id == company_id),
|
||||
)
|
||||
.where(*where)
|
||||
.order_by(ProductSku.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
return SkuListResponse(
|
||||
total=total,
|
||||
items=[_sku_to_response(s) for s in rows],
|
||||
items=[_sku_to_response(sku, inv) for sku, inv in rows],
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
async def create_sku(db: AsyncSession, body: SkuCreate) -> SkuResponse:
|
||||
"""创建 SKU(不创建库存行,LEFT JOIN 查询自动兜底为 0)"""
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(ProductSku.id).where(
|
||||
@@ -253,8 +266,6 @@ async def create_sku(db: AsyncSession, body: SkuCreate) -> SkuResponse:
|
||||
category_id=body.category_id,
|
||||
spec=body.spec,
|
||||
standard_price=body.standard_price,
|
||||
stock_qty=body.stock_qty,
|
||||
warning_threshold=body.warning_threshold,
|
||||
unit=body.unit,
|
||||
status=body.status,
|
||||
)
|
||||
@@ -299,7 +310,9 @@ async def create_inventory_flow(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
body: InventoryFlowCreate,
|
||||
company_id: uuid.UUID,
|
||||
) -> InventoryFlowResponse:
|
||||
"""库存变更(upsert erp_sku_inventory + 写流水)"""
|
||||
sku = (
|
||||
await db.execute(
|
||||
select(ProductSku).where(
|
||||
@@ -310,35 +323,74 @@ async def create_inventory_flow(
|
||||
if sku is None:
|
||||
raise NotFoundException("产品 SKU 不存在")
|
||||
|
||||
if body.change_qty < 0:
|
||||
current_stock = float(sku.stock_qty or 0)
|
||||
if current_stock + body.change_qty < 0:
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
# ── upsert: 查找或创建当前公司的库存行 ──
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory)
|
||||
.where(
|
||||
ErpSkuInventory.sku_id == body.sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if inv is None:
|
||||
# 首次操作该 SKU:自动创建 0 库存行
|
||||
inv = ErpSkuInventory(
|
||||
sku_id=body.sku_id,
|
||||
company_id=company_id,
|
||||
stock_qty=0,
|
||||
warning_threshold=0,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
# 重新锁行
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory)
|
||||
.where(ErpSkuInventory.id == inv.id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
# ── 校验库存 ──
|
||||
current_stock = float(inv.stock_qty or 0)
|
||||
if body.change_qty < 0 and current_stock + body.change_qty < 0:
|
||||
raise BizException(
|
||||
message=f"库存不足:当前库存 {current_stock},请求出库 {abs(body.change_qty)}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
# ── 更新库存 ──
|
||||
await db.execute(
|
||||
update(ErpSkuInventory)
|
||||
.where(ErpSkuInventory.id == inv.id)
|
||||
.values(
|
||||
stock_qty=ErpSkuInventory.stock_qty + Decimal(str(body.change_qty)),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
# ── 写流水 ──
|
||||
flow = InventoryFlow(
|
||||
sku_id=body.sku_id,
|
||||
company_id=company_id,
|
||||
change_qty=body.change_qty,
|
||||
reason=body.reason,
|
||||
remark=body.remark,
|
||||
purchase_unit_price=body.purchase_unit_price if body.change_qty > 0 else 0,
|
||||
is_special_zero_cost=body.is_special_zero_cost if body.change_qty > 0 else False,
|
||||
operator_id=user.user_id,
|
||||
)
|
||||
db.add(flow)
|
||||
await db.flush()
|
||||
|
||||
await db.execute(
|
||||
update(ProductSku)
|
||||
.where(ProductSku.id == body.sku_id)
|
||||
.values(
|
||||
stock_qty=ProductSku.stock_qty + Decimal(str(body.change_qty)),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
except BizException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise BizException(code=500, message=f"库存变更事务失败: {e!s}") from e
|
||||
@@ -352,9 +404,11 @@ async def create_inventory_flow(
|
||||
async def get_inventory_flows(
|
||||
db: AsyncSession,
|
||||
sku_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""获取单个 SKU 在当前公司的库存流水"""
|
||||
sku = (
|
||||
await db.execute(
|
||||
select(ProductSku).where(
|
||||
@@ -365,8 +419,19 @@ async def get_inventory_flows(
|
||||
if sku is None:
|
||||
raise NotFoundException("产品 SKU 不存在")
|
||||
|
||||
# 查当前公司库存
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory).where(
|
||||
ErpSkuInventory.sku_id == sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
where: list[Any] = [
|
||||
InventoryFlow.sku_id == sku_id,
|
||||
InventoryFlow.company_id == company_id,
|
||||
InventoryFlow.is_deleted.is_(False),
|
||||
]
|
||||
|
||||
@@ -389,7 +454,7 @@ async def get_inventory_flows(
|
||||
"total": total,
|
||||
"sku_code": sku.sku_code,
|
||||
"sku_name": sku.name,
|
||||
"current_stock": float(sku.stock_qty or 0),
|
||||
"current_stock": float(inv.stock_qty) if inv else 0.0,
|
||||
"items": [_flow_to_response(f).model_dump(mode="json") for f in flows],
|
||||
"page": page,
|
||||
"size": size,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
库存与利润核算 Service 层
|
||||
- MWA 入库事务(悲观锁 FOR UPDATE + 零元隔离)
|
||||
- 订单利润快照
|
||||
- 利润报表聚合
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select, update, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import BizException, NotFoundException
|
||||
from app.models.erp import ErpSkuInventory, InventoryFlow, ProductSku
|
||||
from app.models.cost import ErpOrderItemCost
|
||||
from app.models.order import ErpOrder, ErpOrderItem
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
|
||||
|
||||
# ── MWA 入库事务 ────────────────────────────────────────
|
||||
async def process_inbound_with_mwa(
|
||||
db: AsyncSession,
|
||||
sku_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
qty: float,
|
||||
purchase_unit_price: float,
|
||||
operator_id: uuid.UUID | None = None,
|
||||
remark: str | None = None,
|
||||
is_special_zero_cost: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
入库事务(悲观锁 + MWA)
|
||||
1. SELECT ... FOR UPDATE 锁定库存行
|
||||
2. 如果非零元特殊,计算新 MWA
|
||||
3. 更新库存 + 记录流水
|
||||
"""
|
||||
# 悲观锁获取库存记录
|
||||
inv_stmt = (
|
||||
select(ErpSkuInventory)
|
||||
.where(
|
||||
ErpSkuInventory.sku_id == sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
inv = (await db.execute(inv_stmt)).scalar_one_or_none()
|
||||
|
||||
if inv is None:
|
||||
# 首次入库,创建库存记录
|
||||
inv = ErpSkuInventory(
|
||||
sku_id=sku_id,
|
||||
company_id=company_id,
|
||||
stock_qty=0,
|
||||
mwa_unit_cost=0,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
# 重新锁定
|
||||
inv = (await db.execute(inv_stmt)).scalar_one()
|
||||
|
||||
old_qty = float(inv.stock_qty or 0)
|
||||
old_mwa = float(inv.mwa_unit_cost or 0)
|
||||
new_qty = old_qty + qty
|
||||
|
||||
# MWA 计算(零元特殊入库不参与)
|
||||
if is_special_zero_cost or purchase_unit_price == 0:
|
||||
new_mwa = old_mwa # 保持原有 MWA
|
||||
else:
|
||||
if new_qty > 0:
|
||||
new_mwa = (old_qty * old_mwa + qty * purchase_unit_price) / new_qty
|
||||
else:
|
||||
new_mwa = purchase_unit_price
|
||||
|
||||
# 更新库存
|
||||
inv.stock_qty = new_qty
|
||||
inv.mwa_unit_cost = round(new_mwa, 4)
|
||||
inv.updated_at = datetime.utcnow()
|
||||
|
||||
# 记录流水
|
||||
flow = InventoryFlow(
|
||||
sku_id=sku_id,
|
||||
company_id=company_id,
|
||||
flow_type="in",
|
||||
change_qty=qty,
|
||||
reason="purchase_in",
|
||||
purchase_unit_price=purchase_unit_price,
|
||||
is_special_zero_cost=is_special_zero_cost,
|
||||
operator_id=operator_id,
|
||||
remark=remark or f"入库 {qty} 件 @ ¥{purchase_unit_price}",
|
||||
)
|
||||
db.add(flow)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"sku_id": str(sku_id),
|
||||
"old_qty": old_qty,
|
||||
"new_qty": new_qty,
|
||||
"old_mwa": old_mwa,
|
||||
"new_mwa": round(new_mwa, 4),
|
||||
"is_special_zero_cost": is_special_zero_cost,
|
||||
}
|
||||
|
||||
|
||||
# ── 订单明细成本快照 ────────────────────────────────────
|
||||
async def snapshot_order_item_costs(
|
||||
db: AsyncSession,
|
||||
order_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> list[dict]:
|
||||
"""为订单的所有明细行锚定 MWA 成本快照"""
|
||||
items_stmt = select(ErpOrderItem).where(
|
||||
ErpOrderItem.order_id == order_id,
|
||||
ErpOrderItem.is_deleted.is_(False),
|
||||
)
|
||||
items = (await db.execute(items_stmt)).scalars().all()
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
# 查当前 MWA
|
||||
inv = (await db.execute(
|
||||
select(ErpSkuInventory).where(
|
||||
ErpSkuInventory.sku_id == item.sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
mwa_cost = float(inv.mwa_unit_cost or 0) if inv else 0
|
||||
sell_price = float(item.unit_price or 0)
|
||||
qty = float(item.qty or 0)
|
||||
profit = (sell_price - mwa_cost) * qty
|
||||
profit_rate = (sell_price - mwa_cost) / sell_price if sell_price > 0 else 0
|
||||
|
||||
# 检查是否已有快照
|
||||
existing = (await db.execute(
|
||||
select(ErpOrderItemCost).where(
|
||||
ErpOrderItemCost.order_item_id == item.id
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.purchase_unit_price = mwa_cost
|
||||
existing.profit_amount = round(profit, 2)
|
||||
existing.profit_rate = round(profit_rate, 4)
|
||||
else:
|
||||
cost_snap = ErpOrderItemCost(
|
||||
order_item_id=item.id,
|
||||
purchase_unit_price=mwa_cost,
|
||||
profit_amount=round(profit, 2),
|
||||
profit_rate=round(profit_rate, 4),
|
||||
)
|
||||
db.add(cost_snap)
|
||||
|
||||
results.append({
|
||||
"sku_id": str(item.sku_id),
|
||||
"qty": qty,
|
||||
"sell_price": sell_price,
|
||||
"mwa_cost": mwa_cost,
|
||||
"profit": round(profit, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return results
|
||||
|
||||
|
||||
# ── 利润报表 ────────────────────────────────────────────
|
||||
async def get_profit_report(
|
||||
db: AsyncSession,
|
||||
company_id: uuid.UUID,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> dict:
|
||||
"""聚合利润报表"""
|
||||
base_where = [
|
||||
ErpOrder.company_id == company_id,
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
]
|
||||
if start_date:
|
||||
base_where.append(ErpOrder.order_date >= start_date)
|
||||
if end_date:
|
||||
base_where.append(ErpOrder.order_date <= end_date)
|
||||
|
||||
# 聚合:每笔订单的利润
|
||||
stmt = (
|
||||
select(
|
||||
ErpOrder.id.label("order_id"),
|
||||
ErpOrder.order_no,
|
||||
ErpOrder.order_date,
|
||||
ErpOrder.total_amount,
|
||||
func.sum(ErpOrderItemCost.profit_amount).label("total_profit"),
|
||||
)
|
||||
.join(ErpOrderItem, ErpOrderItem.order_id == ErpOrder.id)
|
||||
.join(ErpOrderItemCost, ErpOrderItemCost.order_item_id == ErpOrderItem.id)
|
||||
.where(*base_where)
|
||||
.group_by(ErpOrder.id, ErpOrder.order_no, ErpOrder.order_date, ErpOrder.total_amount)
|
||||
.order_by(ErpOrder.order_date.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
orders = []
|
||||
total_revenue = 0
|
||||
total_profit = 0
|
||||
for r in rows:
|
||||
revenue = float(r.total_amount or 0)
|
||||
profit = float(r.total_profit or 0)
|
||||
total_revenue += revenue
|
||||
total_profit += profit
|
||||
orders.append({
|
||||
"order_id": str(r.order_id),
|
||||
"order_no": r.order_no,
|
||||
"order_date": r.order_date.isoformat() if r.order_date else None,
|
||||
"revenue": revenue,
|
||||
"profit": profit,
|
||||
"profit_rate": round(profit / revenue * 100, 2) if revenue > 0 else 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"total_revenue": round(total_revenue, 2),
|
||||
"total_profit": round(total_profit, 2),
|
||||
"overall_profit_rate": round(total_profit / total_revenue * 100, 2) if total_revenue > 0 else 0,
|
||||
"orders": orders,
|
||||
}
|
||||
@@ -12,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import BizException, NotFoundException
|
||||
from app.models.finance import FinSalesInvoice
|
||||
from app.models.sys import SysUser
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.sales_invoice import (
|
||||
SalesInvoiceCreate,
|
||||
@@ -45,6 +47,7 @@ async def create_invoice(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
body: SalesInvoiceCreate,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> SalesInvoiceResponse:
|
||||
# 检查发票号唯一性
|
||||
existing = (await db.execute(
|
||||
@@ -56,7 +59,7 @@ async def create_invoice(
|
||||
if existing:
|
||||
raise BizException(message=f"发票号 {body.invoice_number} 已存在")
|
||||
|
||||
inv = FinSalesInvoice(
|
||||
kwargs: dict = dict(
|
||||
issuer=body.issuer,
|
||||
receiver_customer_id=body.receiver_customer_id,
|
||||
invoice_number=body.invoice_number,
|
||||
@@ -65,6 +68,9 @@ async def create_invoice(
|
||||
remark=body.remark,
|
||||
created_by=user.user_id,
|
||||
)
|
||||
if company_id is not None:
|
||||
kwargs["company_id"] = company_id
|
||||
inv = FinSalesInvoice(**kwargs)
|
||||
db.add(inv)
|
||||
await db.commit()
|
||||
await db.refresh(inv)
|
||||
@@ -80,8 +86,11 @@ async def list_invoices(
|
||||
payment_status: str | None = None,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> SalesInvoiceListResponse:
|
||||
conditions = [FinSalesInvoice.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
conditions.append(FinSalesInvoice.company_id == company_id)
|
||||
|
||||
if invoice_number:
|
||||
conditions.append(FinSalesInvoice.invoice_number.ilike(f"%{invoice_number}%"))
|
||||
|
||||
@@ -22,6 +22,7 @@ async def create_log(
|
||||
customer_id: str | None = None,
|
||||
contact_ids: list[str] | None = None,
|
||||
log_date: date | None = None,
|
||||
company_ids: list[uuid.UUID] | None = None,
|
||||
) -> dict:
|
||||
"""创建销售日志"""
|
||||
log = SalesLog(
|
||||
@@ -30,6 +31,7 @@ async def create_log(
|
||||
contact_ids=contact_ids or [],
|
||||
content=content,
|
||||
log_date=log_date or date.today(),
|
||||
involved_company_ids=company_ids or [],
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
@@ -46,9 +48,17 @@ async def list_logs(
|
||||
user_id: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> dict:
|
||||
"""查询销售日志列表"""
|
||||
"""查询销售日志列表(按 involved_company_ids 包含过滤)"""
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.models.sys import SysUser
|
||||
|
||||
conditions = [SalesLog.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
# ARRAY contains: 过滤涉及当前公司的日志
|
||||
conditions.append(SalesLog.involved_company_ids.any(company_id))
|
||||
|
||||
# 数据权限
|
||||
if user.data_scope == "self":
|
||||
@@ -69,24 +79,107 @@ async def list_logs(
|
||||
count_stmt = select(func.count()).select_from(SalesLog).where(where)
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
# data
|
||||
# data — LEFT JOIN customer + user to get names
|
||||
Author = aliased(SysUser)
|
||||
stmt = (
|
||||
select(SalesLog)
|
||||
select(
|
||||
SalesLog,
|
||||
CrmCustomer.name.label("customer_name"),
|
||||
Author.real_name.label("author_name"),
|
||||
)
|
||||
.outerjoin(CrmCustomer, SalesLog.customer_id == CrmCustomer.id)
|
||||
.outerjoin(Author, SalesLog.salesperson_id == Author.id)
|
||||
.where(where)
|
||||
.order_by(desc(SalesLog.created_at))
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
items = []
|
||||
for log, cust_name, auth_name in rows:
|
||||
d = _to_dict(log)
|
||||
d["customer_name"] = cust_name
|
||||
d["author_name"] = auth_name
|
||||
items.append(d)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"items": [_to_dict(r) for r in rows],
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
async def update_log(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
log_id: uuid.UUID,
|
||||
content: str | None = None,
|
||||
customer_id: str | None = None,
|
||||
contact_ids: list[str] | None = None,
|
||||
log_date: str | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> dict:
|
||||
"""编辑销售日志 — 员工只能改自己的,管理员可改所有"""
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.models.sys import SysUserCompany
|
||||
|
||||
log = await db.get(SalesLog, log_id)
|
||||
if not log or log.is_deleted:
|
||||
raise Exception("日志不存在")
|
||||
|
||||
# 权限检查
|
||||
if user.data_scope != "all" and log.salesperson_id != user.user_id:
|
||||
raise Exception("您无权编辑此日志")
|
||||
|
||||
if content is not None:
|
||||
log.content = content
|
||||
if contact_ids is not None:
|
||||
log.contact_ids = contact_ids
|
||||
if log_date is not None:
|
||||
log.log_date = date.fromisoformat(log_date)
|
||||
|
||||
# 更新客户关联 + 自动重算 involved_company_ids
|
||||
if customer_id is not None:
|
||||
log.customer_id = uuid.UUID(customer_id) if customer_id else None
|
||||
# 重新关联公司
|
||||
resolved = set(log.involved_company_ids or [])
|
||||
if company_id:
|
||||
resolved.add(company_id)
|
||||
if customer_id:
|
||||
cust = await db.get(CrmCustomer, uuid.UUID(customer_id))
|
||||
if cust and cust.owner_id:
|
||||
stmt = select(SysUserCompany.company_id).where(
|
||||
SysUserCompany.user_id == cust.owner_id
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
for cid in rows:
|
||||
resolved.add(cid)
|
||||
log.involved_company_ids = list(resolved)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return _to_dict(log)
|
||||
|
||||
|
||||
async def delete_log(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
log_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""软删除销售日志 — 员工只能删自己的,管理员可删所有"""
|
||||
log = await db.get(SalesLog, log_id)
|
||||
if not log or log.is_deleted:
|
||||
raise Exception("日志不存在")
|
||||
|
||||
if user.data_scope != "all" and log.salesperson_id != user.user_id:
|
||||
raise Exception("您无权删除此日志")
|
||||
|
||||
log.is_deleted = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def trigger_persona_workflow(
|
||||
log_id: uuid.UUID,
|
||||
customer_id: uuid.UUID,
|
||||
@@ -157,6 +250,7 @@ def _to_dict(log: SalesLog) -> dict:
|
||||
"salesperson_id": str(log.salesperson_id),
|
||||
"customer_id": str(log.customer_id) if log.customer_id else None,
|
||||
"contact_ids": log.contact_ids or [],
|
||||
"involved_company_ids": [str(c) for c in (log.involved_company_ids or [])],
|
||||
"content": log.content,
|
||||
"log_date": log.log_date.isoformat() if log.log_date else None,
|
||||
"ai_processed": log.ai_processed,
|
||||
|
||||
@@ -10,9 +10,11 @@ from typing import Any
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
|
||||
from app.models.erp import InventoryFlow, ProductSku
|
||||
from app.models.erp import ErpSkuInventory, InventoryFlow, ProductSku
|
||||
from app.models.order import ErpOrder, ErpOrderItem
|
||||
from app.models.shipping import ErpShippingItem, ErpShippingRecord
|
||||
from app.models.sys import SysUser
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.shipping import (
|
||||
ShippingBriefResponse, ShippingCreate, ShippingItemResponse,
|
||||
@@ -75,10 +77,15 @@ def _check_shipping_access(order: ErpOrder, user: CurrentUserPayload) -> None:
|
||||
|
||||
async def create_shipping(
|
||||
db: AsyncSession, user: CurrentUserPayload, body: ShippingCreate,
|
||||
company_id: uuid.UUID,
|
||||
) -> tuple[ShippingResponse, str]:
|
||||
"""返回 (response, new_shipping_state)"""
|
||||
"""返回 (response, new_shipping_state)。库存从 erp_sku_inventory 扣减"""
|
||||
order = (await db.execute(
|
||||
select(ErpOrder).where(ErpOrder.id == body.order_id, ErpOrder.is_deleted.is_(False))
|
||||
select(ErpOrder).where(
|
||||
ErpOrder.id == body.order_id,
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.company_id == company_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise NotFoundException("订单不存在")
|
||||
@@ -114,6 +121,7 @@ async def create_shipping(
|
||||
carrier=body.carrier, tracking_no=body.tracking_no,
|
||||
status="transit", ship_date=body.ship_date or date.today(),
|
||||
remark=body.remark, operator_id=user.user_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
@@ -125,22 +133,41 @@ async def create_shipping(
|
||||
)
|
||||
db.add(si)
|
||||
|
||||
result = await db.execute(
|
||||
update(ProductSku).where(
|
||||
ProductSku.id == item.sku_id,
|
||||
ProductSku.stock_qty >= item.shipped_qty,
|
||||
).values(
|
||||
stock_qty=ProductSku.stock_qty - Decimal(str(item.shipped_qty)),
|
||||
# ── 从 erp_sku_inventory 扣减库存(行锁) ──
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory)
|
||||
.where(
|
||||
ErpSkuInventory.sku_id == item.sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
current_stock = float(inv.stock_qty) if inv else 0
|
||||
if current_stock < item.shipped_qty:
|
||||
raise BizException(
|
||||
message=f"库存不足无法发货: SKU {item.sku_id},"
|
||||
f"当前库存 {current_stock},请求出库 {item.shipped_qty}"
|
||||
)
|
||||
|
||||
if inv is None:
|
||||
# 不应出现此情况,但防御性处理
|
||||
raise BizException(message=f"SKU {item.sku_id} 在当前公司无库存记录")
|
||||
|
||||
await db.execute(
|
||||
update(ErpSkuInventory)
|
||||
.where(ErpSkuInventory.id == inv.id)
|
||||
.values(
|
||||
stock_qty=ErpSkuInventory.stock_qty - Decimal(str(item.shipped_qty)),
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
sku = (await db.execute(select(ProductSku).where(ProductSku.id == item.sku_id))).scalar_one_or_none()
|
||||
current_stock = float(sku.stock_qty) if sku else 0
|
||||
raise BizException(message=f"库存不足无法发货: SKU {item.sku_id},当前库存 {current_stock},请求出库 {item.shipped_qty}")
|
||||
|
||||
db.add(InventoryFlow(
|
||||
sku_id=item.sku_id, change_qty=-item.shipped_qty,
|
||||
sku_id=item.sku_id, company_id=company_id,
|
||||
change_qty=-item.shipped_qty,
|
||||
reason="shipment", remark=f"订单发货出库 - 发货单 {shipping_no}",
|
||||
operator_id=user.user_id,
|
||||
))
|
||||
@@ -178,8 +205,11 @@ async def list_shipping(
|
||||
db: AsyncSession, user: CurrentUserPayload,
|
||||
page: int = 1, size: int = 20,
|
||||
order_no: str | None = None, tracking_no: str | None = None,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> ShippingListResponse:
|
||||
where: list[Any] = [ErpShippingRecord.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
where.append(ErpShippingRecord.company_id == company_id)
|
||||
if user.data_scope == "self":
|
||||
my_orders = select(ErpOrder.id).where(ErpOrder.salesperson_id == user.user_id, ErpOrder.is_deleted.is_(False))
|
||||
where.append(ErpShippingRecord.order_id.in_(my_orders))
|
||||
@@ -203,9 +233,13 @@ async def list_shipping(
|
||||
|
||||
async def get_shipping_by_order(
|
||||
db: AsyncSession, user: CurrentUserPayload, order_id: uuid.UUID,
|
||||
company_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
where_clause = [ErpOrder.id == order_id, ErpOrder.is_deleted.is_(False)]
|
||||
if company_id:
|
||||
where_clause.append(ErpOrder.company_id == company_id)
|
||||
order = (await db.execute(
|
||||
select(ErpOrder).where(ErpOrder.id == order_id, ErpOrder.is_deleted.is_(False))
|
||||
select(ErpOrder).where(*where_clause)
|
||||
)).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise NotFoundException("订单不存在")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
@@ -31,3 +31,4 @@ Pillow>=10.0.0
|
||||
|
||||
# Excel 导入/导出
|
||||
openpyxl>=3.1.0
|
||||
python-docx>=1.1.0
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
# tests package
|
||||
@@ -0,0 +1 @@
|
||||
# tests/api package
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
鉴权模块测试 —— /api/auth
|
||||
覆盖: 登录 / me / 改密 / Token 校验 / 错误场景
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from tests.conftest import make_auth_headers, ADMIN_USER_ID, SALES_USER_ID
|
||||
|
||||
|
||||
class TestLogin:
|
||||
"""POST /api/auth/login"""
|
||||
|
||||
async def test_login_success(self, client: AsyncClient, seed_data):
|
||||
"""正确账密 → 200 + access_token"""
|
||||
resp = await client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "admin123"
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 200
|
||||
assert "access_token" in body["data"]
|
||||
assert body["message"] == "登录成功"
|
||||
|
||||
async def test_login_wrong_password(self, client: AsyncClient, seed_data):
|
||||
"""错误密码 → 401"""
|
||||
resp = await client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "wrongpass"
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
assert "密码错误" in resp.json()["message"]
|
||||
|
||||
async def test_login_nonexistent_user(self, client: AsyncClient, seed_data):
|
||||
"""不存在的用户 → 401"""
|
||||
resp = await client.post("/api/auth/login", json={
|
||||
"username": "nobody", "password": "123456"
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_login_empty_fields(self, client: AsyncClient, seed_data):
|
||||
"""空字段 → 422 参数校验失败"""
|
||||
resp = await client.post("/api/auth/login", json={
|
||||
"username": "", "password": ""
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestGetMe:
|
||||
"""GET /api/auth/me"""
|
||||
|
||||
async def test_me_success(self, client: AsyncClient, admin_headers):
|
||||
"""合法 Token → 200 + 用户信息"""
|
||||
resp = await client.get("/api/auth/me", headers=admin_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["username"] == "admin"
|
||||
assert data["data_scope"] == "all"
|
||||
|
||||
async def test_me_no_token(self, client: AsyncClient, seed_data):
|
||||
"""无 Token → 422 (Header 缺失)"""
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_me_invalid_token(self, client: AsyncClient, seed_data):
|
||||
"""伪造 Token → 401"""
|
||||
resp = await client.get("/api/auth/me", headers={
|
||||
"Authorization": "Bearer fake-token-xxx"
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestChangePassword:
|
||||
"""PUT /api/auth/password"""
|
||||
|
||||
async def test_change_password_success(self, client: AsyncClient, admin_headers):
|
||||
"""正确旧密码 + 合法新密码 → 200"""
|
||||
resp = await client.put("/api/auth/password", headers=admin_headers, json={
|
||||
"old_password": "admin123",
|
||||
"new_password": "newpass999"
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "密码修改成功" in resp.json()["message"]
|
||||
|
||||
async def test_change_password_wrong_old(self, client: AsyncClient, admin_headers):
|
||||
"""旧密码错误 → 400"""
|
||||
resp = await client.put("/api/auth/password", headers=admin_headers, json={
|
||||
"old_password": "wrongold",
|
||||
"new_password": "newpass999"
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user