This commit is contained in:
2026-03-25 23:59:29 +08:00
parent 7964ce1033
commit 23e5df0364
46 changed files with 4168 additions and 254 deletions
@@ -0,0 +1,147 @@
{
"$schema": "https://json.schemastore.org/design-tokens.json",
"name": "Gemold UI",
"version": "2.0.0",
"modes": ["light", "dark"],
"tokens": {
"color": {
"brand": {
"50": { "value": "#eef2ff" },
"100": { "value": "#e0e7ff" },
"200": { "value": "#c7d2fe" },
"300": { "value": "#a5b4fc" },
"400": { "value": "#818cf8" },
"500": { "value": "#6366f1" },
"600": { "value": "#4f46e5" },
"700": { "value": "#4338ca" },
"800": { "value": "#3730a3" },
"900": { "value": "#312e81" }
},
"neutral": {
"50": { "value": "#fafafa" },
"100": { "value": "#f4f4f5" },
"200": { "value": "#e4e4e7" },
"300": { "value": "#d4d4d8" },
"400": { "value": "#a1a1aa" },
"500": { "value": "#71717a" },
"600": { "value": "#52525b" },
"700": { "value": "#3f3f46" },
"800": { "value": "#27272a" },
"900": { "value": "#18181b" }
},
"semantic": {
"success": { "value": "#16a34a" },
"warning": { "value": "#d97706" },
"danger": { "value": "#dc2626" },
"info": { "value": "{color.brand.600}" }
},
"surface": {
"bgPrimary": {
"value": {
"light": "#ffffff",
"dark": "#0a1020"
}
},
"bgSecondary": {
"value": {
"light": "#f8fafc",
"dark": "#0b1326"
}
},
"bgTertiary": {
"value": {
"light": "#f1f5f9",
"dark": "#0d1930"
}
},
"borderDefault": {
"value": {
"light": "rgba(15, 23, 42, 0.12)",
"dark": "rgba(255, 255, 255, 0.10)"
}
}
},
"text": {
"primary": {
"value": {
"light": "#111827",
"dark": "#f3f4f6"
}
},
"secondary": {
"value": {
"light": "#374151",
"dark": "#d1d5db"
}
},
"tertiary": {
"value": {
"light": "#6b7280",
"dark": "#9ca3af"
}
}
}
},
"typography": {
"fontFamily": {
"sans": {
"value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
},
"mono": {
"value": "'SF Mono', Monaco, Consolas, monospace"
}
},
"fontSize": {
"xs": { "value": "0.75rem" },
"sm": { "value": "0.875rem" },
"base": { "value": "1rem" },
"lg": { "value": "1.125rem" },
"xl": { "value": "1.25rem" },
"2xl": { "value": "1.5rem" },
"3xl": { "value": "1.875rem" }
},
"fontWeight": {
"normal": { "value": 400 },
"medium": { "value": 500 },
"semibold": { "value": 600 },
"bold": { "value": 700 }
}
},
"spacing": {
"1": { "value": "0.25rem" },
"2": { "value": "0.5rem" },
"3": { "value": "0.75rem" },
"4": { "value": "1rem" },
"5": { "value": "1.25rem" },
"6": { "value": "1.5rem" },
"8": { "value": "2rem" },
"10": { "value": "2.5rem" },
"12": { "value": "3rem" }
},
"radius": {
"sm": { "value": "0.5rem" },
"md": { "value": "0.75rem" },
"lg": { "value": "1rem" },
"xl": { "value": "1.25rem" },
"full": { "value": "9999px" }
},
"shadow": {
"xs": { "value": "0 1px 2px rgba(15, 23, 42, 0.06)" },
"sm": { "value": "0 2px 10px rgba(15, 23, 42, 0.08)" },
"md": { "value": "0 12px 30px rgba(15, 23, 42, 0.12)" }
},
"motion": {
"easing": {
"standard": { "value": "cubic-bezier(0.4, 0, 0.2, 1)" }
},
"duration": {
"fast": { "value": "140ms" },
"normal": { "value": "180ms" },
"slow": { "value": "260ms" }
},
"reducedMotion": {
"value": "prefers-reduced-motion: reduce"
}
}
}
}
@@ -0,0 +1,49 @@
## 1. Product Overview
对你现有前端进行「全量视觉与交互」重构:统一设计体系与设计令牌(Design Tokens),并达到 WCAG 2.1 AA。
交付要求包含像素级验收标准、灰度发布策略与一键回滚预案,确保可控上线与风险可管理。
## 2. Core Features
### 2.1 User Roles
| 角色 | 参与方式 | 核心权限 |
|------|----------|----------|
| 设计师 | 项目成员 | 维护设计体系规范;输出像素级标注与验收基线 |
| 前端开发 | 项目成员 | 落地设计令牌与组件;改造页面交互;接入自动化验收 |
| QA/测试 | 项目成员 | 执行无障碍与视觉回归验收;管理缺陷闭环 |
| 发布负责人 | 项目成员 | 配置灰度、监控指标、执行回滚 |
### 2.2 Feature Module
本次重构需求由以下最小页面集合承载(现有业务页面不新增路由,仅整体替换视觉与交互实现):
1. **全站现有业务页面(整体改造范围)**:统一设计体系应用、交互一致性、无障碍支持、像素级对齐。
2. **设计体系与组件库(内部)**:设计令牌预览、组件展示/用法、可访问性与交互规范说明。
3. **灰度发布与验收面板(内部)**:灰度配置、验收结果汇总、回滚操作与审计。
### 2.3 Page Details
| Page Name | Module Name | Feature description |
|-----------|-------------|---------------------|
| 全站现有业务页面(整体改造范围) | 设计体系应用 | 应用统一色板/字体/间距/圆角/阴影等令牌;保证跨页面一致性与可复用性 |
| 全站现有业务页面(整体改造范围) | 交互一致性 | 统一表单校验/错误提示/加载态/空态/弹窗/提示条等行为与文案;保持可预测的交互反馈 |
| 全站现有业务页面(整体改造范围) | WCAG 2.1 AA | 支持键盘可达与可见焦点;语义化结构与必要 ARIA;颜色对比度达标;支持减少动效偏好 |
| 全站现有业务页面(整体改造范围) | 像素级验收 | 以设计基线为准对齐尺寸/间距/字号/行高;定义容差规则与截图基线;记录差异与闭环 |
| 设计体系与组件库(内部) | Design Tokens | 浏览/搜索令牌(颜色、排版、间距、动效、z-index 等);展示令牌到 CSS 变量/类名/组件 props 的映射 |
| 设计体系与组件库(内部) | 组件与模式 | 展示基础组件与复合模式(表单、表格、弹窗等);提供交互状态(hover/focus/disabled/loading/invalid) |
| 设计体系与组件库(内部) | 无障碍规范 | 提供组件级 a11y 清单(键盘、读屏、对比度、焦点顺序);给出 Do/Don’t 与示例 |
| 灰度发布与验收面板(内部) | 灰度发布 | 配置灰度策略(按环境/用户段/百分比/白名单);实时显示启用状态与版本信息 |
| 灰度发布与验收面板(内部) | 验收总览 | 汇总自动化 a11y 扫描、视觉回归、关键路径冒烟结果;支持按版本/页面/组件查看 |
| 灰度发布与验收面板(内部) | 回滚与审计 | 一键关闭灰度/回滚到上个稳定版本;记录操作人、时间、原因、影响范围 |
## 3. Core Process
**设计→开发→测试→灰度→全量→回滚(可选)**
- 设计师流程:沉淀设计体系(颜色/排版/栅格/动效/组件状态)→ 输出令牌字典与组件规范 → 提供像素级标注与验收基线截图。
- 前端开发流程:令牌工程化(生成 CSS 变量/主题)→ 组件库落地并替换旧组件 → 分批改造现有页面(优先核心路径)→ 接入自动化 a11y 与视觉回归。
- QA/测试流程:执行无障碍检查(键盘、读屏、对比度)→ 执行视觉回归与关键路径冒烟 → 缺陷闭环并冻结验收基线。
- 发布负责人流程:开启小流量灰度→ 观察关键指标与错误率 → 分阶段扩大→ 全量;如异常则关闭灰度并回滚。
```mermaid
graph TD
A["全站现有业务页面"] --> B["设计体系与组件库(内部)"]
B --> A
A --> C["灰度发布与验收面板(内部)"]
C --> A
C --> D["回滚到上一稳定版本"]
```
@@ -0,0 +1,89 @@
# 交互走查(UI v2)
## 1. 动效参数(Motion Tokens)
- Easing
- `--ease-default`: `cubic-bezier(0.4, 0, 0.2, 1)`
- Duration
- `--duration-fast`: `140ms`(hover/press/focus 的轻量过渡)
- `--duration-normal`: `180ms`(弹窗/抽屉等容器级过渡)
- `--duration-slow`: `260ms`(页面级淡入、复杂布局切换)
- Reduced Motion
- 当 `prefers-reduced-motion: reduce`:所有 transition/animation 时长强制为 `1ms`,并关闭平滑滚动。
## 2. 微交互规范(Micro-interactions)
- Button
- Hover:轻微提升(如 `translateY(-1px)`)+ 阴影增强
- Active:回落(`translateY(0)`)+ 阴影减弱
- Focus-visible:3px 可见焦点环(UI v2 使用 indigo 系)
- Input / Select
- Focus:边框高亮 + 3px 焦点环
- Invalid:`aria-invalid="true"` 时显示错误态(颜色 + 文案)
- Loading
- 页面级:使用非阻塞 Loading(避免焦点丢失/Tab 被截断)
- 按钮级:loading 状态应禁用重复提交并提示“处理中”
## 3. 键盘与焦点顺序(Tab Order)
### 3.1 全局
- 焦点顺序必须与视觉顺序一致(从左到右、从上到下)。
- 页面切换(路由变化)后:
- 默认把焦点设置到页面标题 `h1`(若可实现)或第一个可交互元素。
### 3.2 组件级
- 导航链接(`a`)
- `Tab` 聚焦
- `Enter` 触发导航
- Button(`button`)
- `Tab` 聚焦
- `Enter/Space` 触发
- 输入框(`input`/`textarea`)
- `Tab` 聚焦
- `Esc` 不应清空内容(除非明确说明)
- Select(`select`)
- `Tab` 聚焦
- `Arrow` 在打开态变更选项
### 3.3 模态框(Modal)
- 打开时:
- 焦点移动到第一个可交互控件(或标题 + 关闭按钮)。
- 关闭时:
- 焦点回到触发打开的按钮。
- `Esc`:关闭(如果业务允许)。
## 4. 无障碍标签与语义(WCAG 2.1 AA 关键点)
### 4.1 颜色对比
- 正文、按钮文字、表单占位与边框需满足 AA 对比度要求。
- 通过令牌层保证:`--text-primary/secondary/tertiary` 与 `--bg-*` 配对达标。
### 4.2 表单
- 每个输入控件必须有可见 `label`。
- 错误提示与输入框绑定:
- 输入框:`aria-invalid="true"`
- 错误文本:为其生成 `id` 并用 `aria-describedby` 关联
- 提交失败:聚焦到第一个错误字段并播报错误(可通过 `aria-live`)。
### 4.3 图标按钮
- 若按钮仅图标,必须提供可读名称:
- `aria-label="关闭"` / `title="关闭"`
### 4.4 通知(Toast/Alert)
- 错误类:建议 `role="alert"`(立即播报)
- 信息类:建议 `role="status"`(非打断播报)
## 5. 像素级验收建议(与工程联动)
- 验收基线:
- 每个关键页面至少 1 张桌面端基线截图(含主要状态:默认/空态/加载/错误)。
- 容差:
- 像素级对比误差 ≤ 1px(文本抗锯齿允许极小差异,建议在工具中配置阈值)。
- 关键路径:
- 登录 → 进入首页 → 打开核心模块 → 创建/编辑/删除关键实体(若业务允许)。
## 6. 灰度与回滚交互(前端开关)
- UI 版本
- `document.documentElement.dataset.ui`:`v1` / `v2`
- 本地覆盖:`localStorage.gemold_ui_version`
- 灰度比例:`localStorage.gemold_ui_rollout_percent`(0–100)
- 主题
- `document.documentElement.dataset.theme`:`light` / `dark`
- 本地覆盖:`localStorage.gemold_theme`
- 内部控制台
- `/_release`:提供切换 v1/v2、设置灰度比例与“一键回滚”
@@ -0,0 +1,59 @@
## 1.Architecture design
```mermaid
graph TD
U["User Browser"] --> FE["Vue 3 Frontend (CDN)"]
FE --> TOK["Design Tokens (JSON + CSS Variables)"]
TOK --> CSSV["CSS Variables / Theme Styles"]
FE --> DS["Internal Design System & Release Pages"]
FE --> QA["A11y + Visual Regression (Optional Tooling)"]
subgraph "Frontend Layer"
FE
DS
CSSV
end
subgraph "Build/QA Tooling"
TOK
QA
end
```
## 2.Technology Description
- Frontend: Vue@3(CDN)+ vue-router@4(CDN),单文件静态应用(`static/index.html` + `static/vue-app.js`)
- Styling: CSS Variables(`static/style.css`),通过 `data-ui`/`data-theme` 做主题与版本切换
- Design Tokens: `./.trae/documents/DesignTokens_Gemold_UIv2.json`(令牌源)+ `static/style.css`(运行时变量)
- A11y: 工程内置 focus-visible 与 reduced-motion;建议在 CI 中补充 axe-core/Playwright 扫描(本仓库暂不强制引入 Node 工具链)
- Pixel-level: 建议 Playwright 截图对比作为像素级验收基线(同上,CI 可选)
- Release: 纯前端 Feature Flags(localStorage + 稳定分桶)+ 内部控制台 `/_release`
- Backend: 不新增后端;如后端可下发配置,可替换本地灰度为服务端策略
## 3.Route definitions
| Route | Purpose |
|-------|---------|
| (保持现有路由不变) | 业务页面继续对外提供服务,仅替换视觉/交互实现 |
| /_design-system | 内部设计体系与组件库预览(建议仅开发/预发可用) |
| /_release | 内部灰度发布与验收面板(建议仅预发/生产受控可用) |
## 4.API definitions (If it includes backend services)
- Backend: None
## 6.Data model(if applicable)
- No database changes
---
### 关键工程约束(实现要点)
1) **Design Tokens 单一事实源**:颜色/排版/间距/圆角/阴影/动效时长等以 token 定义;组件与页面不得硬编码魔法值。
2) **WCAG 2.1 AA 落地**:
- 颜色对比度:令牌层保证正文/交互控件在常见背景下达标;
- 键盘与焦点:全站可 Tab 导航且焦点可见;
- 语义与读屏:优先语义标签;必要处补 ARIA;
- 动效:支持 prefers-reduced-motion。
3) **像素级验收**:
- 以“设计基线截图 + 视觉回归”作为可重复验收手段;
- 定义容差策略(例如仅允许极小抗锯齿差异)与忽略区域(时间戳/随机数)。
4) **灰度发布与回滚**:
- Feature Flag 策略:环境开关(dev/staging/prod)+ 百分比灰度(基于用户标识做稳定哈希)+ 白名单;
- 回滚手段:立即关闭 flag(热回滚体验)+ 回退到上一稳定构建产物(部署回滚);
- 监控信号:前端错误率、关键交互成功率、性能指标(LCP/CLS 等)作为扩大/停止灰度依据。
@@ -0,0 +1,108 @@
# 页面设计说明(桌面端优先)
## 全局样式(适用于全站与内部页面)
- Layout:桌面端以 12 栅格 + 8px spacing scale;内容最大宽度(例如 1200–1440)并居中;二级页面可用左侧栏 + 右侧内容区。
- Responsive:
- Desktop(≥1200):完整导航与并列布局
- Tablet(768–1199):侧栏可折叠;表格降级为分组卡片
- Mobile(<768):纵向堆叠;主操作按钮置底吸附(仅必要场景)
- Global Design Tokens(以 CSS Variables 注入):
- Color:brand/neutral/semantic(success/warning/danger/info)
- Typography:font family、字号阶梯、行高、字重
- Spacing/Radii/Shadows/Z-index
- Motion:duration/easing;遵循 prefers-reduced-motion
- 通用交互状态:hover / active / focus-visible / disabled / loading / invalid;focus ring 颜色与对比度满足可见性。
- 无障碍通用规则:
- 键盘:所有可操作元素可达;焦点顺序与视觉顺序一致
- 读屏:图标按钮必须有可读名称;表单必须有 label 与错误关联
- 对比度:正文/控件/边框按 AA 标准设计(在令牌层保障)
---
## 页面 1:全站现有业务页面(整体改造规范)
### Layout
- 使用「页面壳(App Shell)」统一:顶栏(品牌/导航/用户区)+ 内容区(栅格)+ 可选页脚。
- 页面间距、卡片、表单、表格、弹窗等均由组件库提供,页面不再自定义样式细节。
### Meta Information
- Title:沿用现有页面标题规则(不更改信息架构)。
- Description/OG:沿用现有策略;若无则保持空以避免误配。
### Page Structure(通用结构)
1. 顶栏:主导航、面包屑(如原有)、全局搜索(如原有)、用户菜单(如原有)
2. 内容区:
- 标题区(H1 + 次要说明)
- 主内容(表单/表格/卡片/图表等现有模块)
3. 全局反馈:Toast/Inline Alert;全屏 Loading 避免阻塞键盘焦点
### Sections & Components(交互要点)
- 表单:
- 必填标识、错误提示(文本 + aria-describedby)、提交后聚焦到首个错误
- 输入控件状态一致(hover/focus/invalid/disabled)
- 表格:
- 可点击行/排序/分页等交互保持一致反馈;空态提供下一步指引
- 弹窗/抽屉:
- focus trap;Esc 关闭;关闭按钮可读名称;关闭后焦点回到触发点
- 动效:仅用于状态过渡与层级变化;支持 reduced motion
---
## 页面 2:设计体系与组件库(内部)
### Layout
- 左侧栏(固定宽度):分类导航(Tokens / Components / Patterns / A11y)
- 右侧内容:文档内容区 + 组件预览区(可上下分栏)
- 顶部工具条:主题切换(浅色/深色)、缩放预览(100%/125%)、搜索
### Meta Information
- Title:设计体系与组件库
- Description:设计令牌、组件状态与无障碍规范的单一入口
### Page Structure
1. Tokens 区:令牌分组列表 + 详情面板
2. Components 区:组件目录 + 交互状态矩阵 + 代码用法
3. Patterns 区:表单/表格/弹窗等复合模式(推荐组合)
4. A11y 区:组件级检查清单与示例
### Sections & Components(关键元素)
- Token 浏览器:
- 支持按名称/别名搜索;展示值(HEX/px/ms 等)与使用建议
- 展示“映射关系”:token → CSS variable → Tailwind theme → 组件 props
- 组件预览:
- 每个组件提供状态面板:default/hover/focus/disabled/loading/invalid
- 提供键盘操作说明(Tab/Enter/Space/Arrow)与读屏提示
- 规范说明:
- 明确“禁止事项”(例如页面层硬编码颜色)与迁移策略(旧组件替换路径)
---
## 页面 3:灰度发布与验收面板(内部)
### Layout
- 顶部:版本信息条(当前版本、上个稳定版本、灰度开关状态)
- 主体:左右分栏
- 左:灰度策略配置
- 右:验收与监控摘要
- 底部:操作审计表(仅追加写入展示)
### Meta Information
- Title:灰度发布与验收
- Description:灰度配置、验收汇总与回滚操作入口
### Page Structure
1. 灰度配置卡片
2. 验收结果汇总
3. 回滚操作区(高风险操作)
4. 审计与记录
### Sections & Components(关键交互)
- 灰度配置:
- 环境选择(预发/生产)
- 百分比滑杆(0–100)+ 白名单输入
- 变更预览:显示预计影响范围(如可估算)与生效时间
- 验收汇总:
- A11y:阻断项(AA 不通过)必须红色拦截并禁止扩大灰度
- 视觉回归:按页面/组件维度展示 diff;可查看基线截图与当前截图
- 冒烟:关键路径用例通过率
- 回滚:
- 两步确认(输入“ROLLBACK”)+ 原因必填
- 支持“关闭灰度开关(热回滚)”与“回退构建产物(部署回滚)”两种动作
- 审计:记录操作人、时间、版本、策略变更、回滚原因(只读)
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
检查 FastAPI 返回的时间格式
"""
import asyncio
import sys
from pathlib import Path
import json
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text, select
from config.settings import settings
from models.database import SalesOrder, Customer
async def check():
"""检查 API 返回的时间格式"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async with AsyncSession(engine) as session:
# 查询销售订单
result = await session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.order_by(SalesOrder.id.desc())
.limit(1)
)
row = result.first()
if row:
order, customer = row
print(f"订单号: {order.order_no}")
print(f"created_at (Python): {order.created_at}")
print(f"created_at (ISO格式): {order.created_at.isoformat() if order.created_at else 'N/A'}")
print(f"created_at (带时区): {order.created_at.isoformat() if order.created_at else 'N/A'}")
print(f"order_date (Python): {order.order_date}")
print(f"order_date (ISO格式): {order.order_date.isoformat() if order.order_date else 'N/A'}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check())
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
检查销售订单的 created_at 字段
"""
import asyncio
import sys
from pathlib import Path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from config.settings import settings
async def check():
"""检查数据库中的 created_at 字段"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(
settings.DATABASE_URL,
echo=True
)
async with engine.begin() as conn:
# 查询销售订单的 created_at 字段
result = await conn.execute(text("SELECT id, order_no, created_at, order_date FROM sales_orders ORDER BY id DESC LIMIT 5"))
rows = result.fetchall()
print("\n销售订单数据:")
for row in rows:
print(f"ID: {row[0]}, 订单号: {row[1]}, created_at: {row[2]}, order_date: {row[3]}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check())
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
检查数据库时区设置
"""
import asyncio
import sys
from pathlib import Path
from datetime import datetime
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from config.settings import settings
async def check():
"""检查数据库时区设置"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async with engine.begin() as conn:
# 检查数据库时区设置
result = await conn.execute(text("SHOW timezone"))
timezone = result.scalar()
print(f"数据库时区: {timezone}")
# 检查数据库当前时间
result = await conn.execute(text("SELECT now()"))
db_now = result.scalar()
print(f"数据库当前时间: {db_now}")
# 检查 Python 当前时间
python_now = datetime.now()
print(f"Python 当前时间: {python_now}")
# 检查数据库当前时间(转换为本地时区)
result = await conn.execute(text("SELECT now() AT TIME ZONE 'Asia/Shanghai'"))
db_now_local = result.scalar()
print(f"数据库当前时间 (Asia/Shanghai): {db_now_local}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check())
+26
View File
@@ -0,0 +1,26 @@
# Confluence 归档目录结构(建议)
## 业务流程
- 01 端到端流程(客户订单→采购→到货→生产→交付)
- 02 单据状态机与异常回退
- 03 角色分工与交接点
## 数据模型
- 01 ER 关系与主外键
- 02 字段字典与枚举口径
- 03 索引策略与约束脚本
## 规则验证
- 01 新增/修改模具订单规则
- 02 采购联动与版本策略
- 03 交付后冻结(应用层+DB层)
- 04 零成品库存说明与财务影响
## 性能优化
- 01 慢 SQL 清单与索引优化
- 02 缓存策略与幂等策略
- 03 分区/分库分表方案与灰度切换
+37
View File
@@ -0,0 +1,37 @@
# 交付物清单(本次产出)
## 1. 分析报告(Markdown)
- `docs/MOLD_ERP_ANALYSIS_REPORT.md`
- `docs/ZERO_FINISHED_INVENTORY_CERTIFICATE.md`
- `docs/PERFORMANCE_SCALABILITY_PLAN.md`
- `docs/PERFORMANCE_BENCHMARKS.md`
- `docs/UAT_CHECKLIST.md`
- `docs/INTERFACE_INTEGRATION_CATALOG_TEMPLATE.md`
- `docs/CONFLUENCE_ARCHIVE_STRUCTURE.md`
## 2. 数据库脚本(PostgreSQL)
- 冻结交付后模具订单(头+明细):`scripts/db/001_freeze_delivered_sales_orders.sql`
- 约束与索引补齐:`scripts/db/002_indexes_and_constraints.sql`
- 数据修复示例(状态归一):`scripts/db/003_data_fixups.sql`
- 分区模板(按月):`scripts/db/010_partitioning_template.sql`
- RLS 模板(按 org_id):`scripts/db/011_rls_template.sql`
- 审计追溯(old/new + 操作者/IP/UA):`scripts/db/020_audit_trail.sql`
- 审计留存清理(180 天):`scripts/db/021_audit_retention.sql`
- 慢 SQL 采样(pg_stat_statements):`scripts/db/030_pg_stat_statements.sql`
## 3. 自动化测试(pytest)
- 交付后冻结:`tests/test_sales_order_delivered_freeze.py`
- 订单/采购/库存主链路用例(参数化 ≥30):`tests/test_api_inventory_orders.py`
## 4. 代码加固点(已落地)
- 交付后冻结:应用层拒绝更新/删除/改状态/领料(`status=delivered`)。
- 状态一致性:避免写入未允许的 `pending` 状态。
- 性能优化:BOM 需求计算去 N+1;库存扣减使用原子更新降低并发超卖风险。
## 5. Word + PDF 导出建议
- 建议使用 pandoc 将 `docs/MOLD_ERP_ANALYSIS_REPORT.md` 导出为 docx/pdf,并将生成物作为 CI 产物归档。
@@ -0,0 +1,22 @@
# 外部接口与集成盘点模板(ERP/财务/供应商门户/客户门户)
## 1. 接口清单
| 接口名称 | 调用方向 | 协议 | 鉴权 | 频率 | 单次数据量 | 幂等键 | 超时 | 重试策略 | 死信/补偿 | 负责人 |
|---|---|---|---|---|---|---|---|---|---|---|
| | | HTTP/REST / MQ / SFTP | OAuth2/JWT/签名 | | | order_no/txn_no | | | | |
## 2. 字段映射表
| 业务对象 | 源系统.源字段 | 目标系统.目标字段 | 类型转换 | 单位换算 | 币种换算 | 枚举映射 | 空值策略 | 校验规则 |
|---|---|---|---|---|---|---|---|---|
| 客户订单 | | | | | | | | |
| 采购订单 | | | | | | | | |
| 到货/入库 | | | | | | | | |
| 财务收付 | | | | | | | | |
## 3. 失败重试与对账
| 场景 | 失败判定 | 重试间隔 | 最大重试 | 幂等保障 | 对账口径 | 补偿脚本 |
|---|---|---|---|---|---|---|
| | | | | | | |
+295
View File
@@ -0,0 +1,295 @@
# 模具制造进销存核心模块分析报告(代码基线:geMoldInsight)
## 0. 范围与术语映射
- 客户订单(Customer Order):本仓库实现为 SalesOrder(销售订单),其业务语义更贴近“模具订单/按单生产订单”。对应表:`sales_orders`、`sales_order_items`。
- 物料采购订单(Purchase Order):本仓库实现为 PurchaseOrder。对应表:`purchase_orders`、`purchase_order_items`。
- 物料清单(BOM):本仓库实现为 `product_materials`(ProductMaterial),以“成品(模具)→ 物料”的多行明细表达。
- 成品库存:系统数据模型存在 `products.item_type="finished"`,但库存管理接口仅面向 `item_type="material"`,且“交付”不产生任何成品入库/出库流水;因此可视为“零成品库存”设计。
## 1. 业务流程梳理
### 1.1 端到端流程图(客户下单→交付)
```mermaid
flowchart LR
A[客户下单\nSalesOrder: POST /api/sales-orders] --> B{BOM 是否存在?}
B -- 否 --> B1[订单创建成功\nproduction_status=bom_missing\n不扣料]
B -- 是 --> C[计算物料需求\n按 BOM×订单数量×(1+loss_rate)]
C --> D{默认仓库库存是否足够?}
D -- 否 --> D1[创建失败/回滚\n提示缺料明细]
D -- 是 --> E[创建时自动扣减物料\nInventory.quantity-=需求\nStockMovement=issue_to_production]
B1 --> F[物料需求计划\nGET /api/sales-orders/{id}/production-plan]
E --> F
F --> G[采购下单(手工)\nPurchaseOrder: POST /api/purchase-orders]
G --> H[到货入库\nPOST /api/purchase-orders/{id}/receive\nStockMovement=purchase_in]
H --> I[生产领料(可选)\nPOST /api/sales-orders/{id}/issue-materials]
I --> J[交付客户\nPATCH /api/sales-orders/{id}/status=delivered]
```
### 1.2 节点级状态、角色、输入输出、异常与回退
| 节点 | 负责角色 | 单据/状态 | 输入 | 输出 | 异常分支 | 回退机制 |
|---|---|---|---|---|---|---|
| 客户下单 | 销售/项目 | SalesOrder.status=manufacturing(创建时强制) | customer_id、成品(模具)明细、交期 | sales_orders + sales_order_items | 默认仓库未配置;BOM 存在但库存不足(创建失败) | 事务回滚;不会产生库存/流水 |
| 物料需求计算 | 计划/工艺 | production-plan | 订单明细 + BOM | 需求汇总(含 loss_rate)+ 缺口 | BOM 缺失(返回空计划) | 无(纯查询) |
| 采购申请 | 采购 | 未实现专用单据 | 需求缺口 | 采购单(手工创建) | 无 | 无 |
| 供应商采购 | 采购 | PurchaseOrder.status=draft | supplier_id + 物料行 | purchase_orders + items | 物料不是 material;cost_price<=0 | 事务回滚 |
| 到货质检 | 质检 | 未实现专用质检单 | 到货信息 | 无 | 无 | 无 |
| 到货入库 | 仓库 | PurchaseOrder.status=partial_received/received | 入库仓库、入库数量 | Inventory.quantity 增加;StockMovement=purchase_in;received_quantity 累加 | 入库超收;仓库不存在/停用 | 事务回滚 |
| 模具生产/领料 | 生产/仓库 | production_status=material_issued | 发料仓库 | Inventory.quantity 扣减;StockMovement=issue_to_production | BOM 缺失;库存不足;已自动扣料禁止重复领料 | 若后续“修改/删除订单”,会回补已扣料(return_from_production) |
| 交付客户 | 销售/项目 | status=delivered | order_id | status 更新 | 状态值非法 | 无 |
关键落地差异(与“采购申请/到货质检/生产工序/交付签收”相比):
- 采购申请、到货质检、生产工序、交付签收均未建模为独立单据;当前系统以“库存流水 + 订单状态”覆盖部分过程。
## 2. 数据模型与字段审计
### 2.1 核心表字段、主外键关系、索引概览(以 ORM 为准)
以下字段来源于 [database.py](file:///d:/project/geMoldInsight/src/models/database.py)。
#### 客户订单(SalesOrder / 模具订单)
- 表:`sales_orders`
- PK:`id`
- 关键字段:`order_no`(unique,index)、`customer_id`(FK,index)、`status`、`production_status`(index)、`production_no`(index)、`planned_material_cost`、`actual_material_cost`、`total_amount`、`received_amount`、`delivery_date`、`operator_id`(FK)
- 表:`sales_order_items`
- PK:`id`
- FK:`order_id`(→sales_orders.id)、`product_id`(→products.id)
- 关键字段:`quantity`、`delivered_quantity`、`unit_price`、`amount`
#### 物料清单(BOM)
- 表:`product_materials`
- PK:`id`
- FK:`finished_product_id`(→products.id,index)、`material_product_id`(→products.id,index)
- 关键字段:`quantity`、`loss_rate`
- 约束:唯一约束 `uq_product_material_unique(finished_product_id, material_product_id)`
#### 采购订单(PurchaseOrder)
- 表:`purchase_orders`
- PK:`id`
- 关键字段:`order_no`(unique,index)、`supplier_id`(FK,index)、`status`、`total_amount`、`paid_amount`、`expected_date`、`operator_id`(FK)
- 表:`purchase_order_items`
- PK:`id`
- FK:`order_id`(→purchase_orders.id)、`product_id`(→products.id)
- 关键字段:`quantity`、`received_quantity`、`unit_price`、`amount`
#### 供应商/客户主数据
- 表:`suppliers`:`code`(unique,index)、`name`、`is_active`
- 表:`customers`:`code`(unique,index)、`name`、`credit_limit`
#### 到货明细表(现状映射)
系统未实现独立“到货单/质检单”表:
- 到货数量沉淀在 `purchase_order_items.received_quantity`
- 到货批次/明细流水沉淀在 `stock_movements`(其中 `movement_type="purchase_in"`、`reference_type="purchase_order"`)
### 2.2 问题验证与整改清单(以代码检出为依据)
#### A. 缺失约束/索引(高优先)
- `inventory` 缺少数据库层唯一约束 `(product_id, warehouse_id)`:接口层有重复校验,但并发下可能插入重复行。
- `purchase_order_items.order_id / product_id`、`sales_order_items.order_id / product_id` 缺少索引:高数据量下会拖慢订单详情/联动查询。
- `stock_movements.warehouse_id`、`stock_movements(reference_type, reference_id)` 缺少索引:对“按单追溯”与仓库流水分页不友好。
- 修复脚本:见 `scripts/db/002_indexes_and_constraints.sql`。
#### B. 枚举值不一致(中高优先)
- `sales_orders.status`:
- DB 默认 `draft`
- 创建/更新强制 `manufacturing`
- 发料接口可能写入 `pending`(但状态更新接口不允许 `pending`)
- `sales_orders.production_status`:
- DB 默认 `not_started`
- 业务中出现 `bom_missing/material_issued/completed`,但缺少统一枚举约束
- 数据修复:`pending→manufacturing` 示例脚本见 `scripts/db/003_data_fixups.sql`。
#### C. 金额精度风险(高优先)
- 金额/单价字段大量使用 `Float`(IEEE 754),在财务口径下会出现精度丢失与累计误差。
- 建议将以下字段迁移为 `NUMERIC(18,4)` 或 `NUMERIC(18,2)`:
- `products.cost_price/sale_price`
- `purchase_orders.total_amount/paid_amount`
- `purchase_order_items.unit_price/amount`
- `sales_orders.planned_material_cost/actual_material_cost/total_amount/received_amount`
- `sales_order_items.unit_price/amount`
- `stock_movements.unit_price/total_amount`
#### D. 冗余字段(中优先)
- `sales_orders.received_amount` 与 `purchase_orders.paid_amount` 可能与财务分摊表(`finance_allocations`)重复表达;若以财务分摊为准,应明确哪一方为“源数据”。
## 3. 业务规则验证(以当前实现为准)
### 3.1 新增模具订单是否自动触发物料需求计算
- 结论:会触发“物料需求计算”,并在满足条件时会进一步触发“自动扣料”。
- 证据:
- 创建销售单后调用 `_issue_materials_for_order_creation()`,内部调用 `_build_material_plan()` 进行需求汇总,并按默认仓库扣减库存(`issue_to_production`)。
- 需求计算考虑 `loss_rate`;不考虑安全库存、在途量。
覆盖情况:
- 损耗率:已支持(`qty * (1 + loss_rate)`)。
- 安全库存:未支持(`products.min_stock` 仅用于低库存查询,不参与需求计算)。
- 在途量:未支持(系统无“采购在途数量”的统一口径,需通过采购单未收数量推导,但当前逻辑未做)。
### 3.2 修改模具订单对已生成采购订单的联动策略
- 结论:当前系统不存在“客户订单↔采购订单”的显式关联,因此无法实现联动策略与版本控制。
- 现状:
- 修改销售单会回补该销售单曾扣减的物料(按 `stock_movements` 追溯),然后删除旧明细、重建明细、重新计算并扣料。
- 采购单创建完全独立,未记录来源销售单/需求行。
- 建议(整改方向):
- 增加 `sales_order_material_demands`(需求行)并记录来源、版本号;采购单行引用需求行;修改订单时按版本触发“采购重确认”。
- 为 `sales_orders` 增加 `row_version`(int/bigint)乐观锁;更新时带版本号校验。
### 3.3 禁止对已交付模具订单进行任何字段修改(双重约束)
- 应用层:已实现(PUT/DELETE/PATCH status/issue-materials)对 `status="delivered"` 的拒绝。
- 代码位置:[sales_order_routes.py](file:///d:/project/geMoldInsight/src/api/inventory/sales_order_routes.py)
- 数据库层(PostgreSQL):提供触发器脚本,禁止更新/删除订单头与增删改订单明细。
- 脚本:`scripts/db/001_freeze_delivered_sales_orders.sql`
## 4. 库存与成本逻辑审查
### 4.1 库存扣减策略
- 到货入库:`purchase-orders/{id}/receive` 会实时写入 `Inventory.quantity += receive_quantity`,并记 `StockMovement=purchase_in`。
- 生产领料:
- 创建模具订单时,若 BOM 存在且库存充足,会实时扣减库存并记 `StockMovement=issue_to_production`。
- 也支持按单领料接口(若未自动扣料)。
- 结论:系统是“实时更新库存现存量”,不是“到货时更新快照”。
### 4.2 成本核算方式与附加成本
- 现状:无移动加权/FIFO/标准成本核算引擎。
- 采购单明细单价强制取 `products.cost_price`;入库流水记录 `unit_price`,但不会更新 `products.cost_price` 的移动平均。
- 发料成本按 `products.cost_price` 估值,不含运费、关税、质检费等分摊。
- 建议:
- 增加“入库批次成本层”或“库存成本层”表以支持 FIFO/移动加权;
- 增加 `purchase_landed_costs`(关税/运费/质检费)并按行/金额比例分摊到入库批次。
### 4.3 “零成品库存”证明与对月结影响
证明要点(以代码行为为准):
- 库存查询接口仅返回物料(`item_type=="material"`),成品库存不在日常库存口径。
- 交付(`status=delivered`)仅更新订单状态,不产生成品入库/出库、也没有销售出库流水。
- 系统未实现销售退货/成品出库/成品调拨等模块。
对财务月结影响(需补齐口径):
- 目前可获得的成本数据是“发料成本(按物料成本价)”,更接近制造费用的材料消耗口径;
- 若要形成完整月结(在制/完工/成本结转),需补齐工序报工、完工入库(或完工确认)、制造费用分摊与结转规则。
## 5. 接口与集成盘点
- 对外系统集成(ERP/财务/供应商/客户门户):当前代码未发现任何外部同步实现(无外部 HTTP client、无 webhook、无 MQ、无定时同步、无文件交换)。
- 系统对外提供接口:FastAPI REST(HTTP/JSON),前缀 `/api`。
建议交付模板(用于后续对接):
- 《接口清单》:接口名、协议、调用方、频率、数据量、重试/幂等键(例如 `order_no`)。
- 《字段映射表》:源字段、目标字段、单位/币种换算、枚举映射、空值策略。
## 6. 权限与审计
### 6.1 RBAC 现状与推荐矩阵
现状:
- 仅实现“管理员 is_superuser”级别拦截;大部分写接口只要求登录。
- 数据模型已存在 Role/Permission 以及 `has_permission()`,但未在业务路由中落地。
推荐矩阵(目标态,需用 permission_code 落地):
| 角色 | 客户订单 | 采购订单 | 库存/流水 | 主数据(产品/客户/供应商) | 财务 | 用户/角色 |
|---|---|---|---|---|---|---|
| 销售 | C/R/U | R | R | R | R | - |
| 采购 | R | C/R/U | R | R | R | - |
| 仓库 | R | R | C/R/U | R | - | - |
| 财务 | R | R | R | R | C/R/U | - |
| 管理员 | C/R/U/D | C/R/U/D | C/R/U/D | C/R/U/D | C/R/U/D | C/R/U/D |
按“表级 CRUD”口径(用于落地 RBAC 与审计范围界定):
| 表 | 销售 | 采购 | 仓库 | 财务 | 管理员 |
|---|---|---|---|---|---|
| customers | R | R | R | R | C/R/U/D |
| suppliers | R | R | R | R | C/R/U/D |
| products | R | R | R | R | C/R/U/D |
| product_materials | R | R | R | R | C/R/U/D |
| sales_orders / sales_order_items | C/R/U | R | R | R | C/R/U/D |
| purchase_orders / purchase_order_items | R | C/R/U | R | R | C/R/U/D |
| inventory | R | R | C/R/U | R | C/R/U/D |
| stock_movements | R | R | C/R | R | C/R/U/D |
### 6.2 RLS(行级安全)检查与建议
- 现状:订单/进销存未体现“事业部/组织”字段,数据库未启用 RLS;部分接口甚至未鉴权(与业务无关的历史记录接口)。
- 建议:
- 增加 `org_id` 字段至订单、主数据、库存等关键表;
- Postgres 层启用 RLS,并通过应用设置 `SET app.current_org_id = ...` 绑定会话变量实现隔离。
- 参考脚本:`scripts/db/011_rls_template.sql`
### 6.3 操作日志 180 天可追溯
- 现状:存在 `user_activities` 与 `system_logs` 表,但缺少统一落库策略与留存/归档脚本。
- 建议:
- 关键操作(登录、下单、收货、领料、交付、权限变更)落库记录 old/new、操作人、时间、IP、UA;
- 增加分区表或归档表,并提供 180 天归档/清理策略。
- 参考脚本:`scripts/db/020_audit_trail.sql`(依赖应用层设置会话变量 current_user_id/ip/ua)
## 7. 性能与扩展性评估(方向性建议)
### 7.1 千万级订单量分库分表(建议)
- 优先建议:先做 PostgreSQL 原生分区(比跨库路由成本低)
- 拆分键:`sales_orders.order_date` / `purchase_orders.order_date` 按月 Range 分区
- 历史迁移:`INSERT INTO ... SELECT ... WHERE order_date < ...`,并校验行数/校验和
- 灰度切换:读写双写→读切换→写切换→下线旧表
- 若必须分库:按 `org_id + 时间` 组合路由(hash(org_id)→库,时间→表)
- 分区模板脚本:`scripts/db/010_partitioning_template.sql`
### 7.2 慢 SQL 风险点与优化脚本
- 高风险点:
- 订单详情按 order_id 读取明细、追溯库存流水、汇总库存,若缺少索引会退化为全表扫。
- BOM 需求计算需要避免 N+1(已在 `_build_material_plan` 做批量化)。
- 索引脚本:见 `scripts/db/002_indexes_and_constraints.sql`。
### 7.3 并发下新增/修改模具订单锁冲突
风险源:
- 并发创建订单并同时扣减同一物料库存,会竞争同一 `inventory` 行。
- 并发修改订单会先回补再扣减,冲突窗口更大。
建议:
- 在扣减库存时采用“原子更新”语句:`UPDATE inventory SET quantity = quantity - :qty WHERE id=:id AND quantity >= :qty`,并检查 affected_rows,实现乐观并发控制;
- 或引入 `row_version`(乐观锁)到 inventory/sales_orders。
## 8. 测试与验收标准(仓库内已落地的最小集合)
- 已新增接口自动化用例(示例,覆盖“交付后冻结”核心规则):
- `tests/test_sales_order_delivered_freeze.py`
- `tests/test_api_inventory_orders.py`(参数化用例,覆盖正常/异常/边界/幂等与约束)
- 建议在 CI 中运行:
- `pytest -q`
- 将 `scripts/db/*.sql` 纳入数据库初始化后的约束/索引校验步骤
## 9. 交付物结构建议
- 报告(建议以 Markdown→Word/PDF 转换)
- `docs/MOLD_ERP_ANALYSIS_REPORT.md`
- `docs/UAT_CHECKLIST.md`
- `docs/INTERFACE_INTEGRATION_CATALOG_TEMPLATE.md`
- `docs/CONFLUENCE_ARCHIVE_STRUCTURE.md`
- 可执行脚本
- `scripts/db/001_freeze_delivered_sales_orders.sql`
- `scripts/db/002_indexes_and_constraints.sql`
- `scripts/db/003_data_fixups.sql`
- `scripts/db/010_partitioning_template.sql`
- `scripts/db/011_rls_template.sql`
- `scripts/db/020_audit_trail.sql`
如需输出 Word+PDF,可使用 pandoc 将本报告导出为 `.docx` 与 `.pdf`,并将生成物纳入 CI 产物归档。
+30
View File
@@ -0,0 +1,30 @@
# 性能基准定义(建议)
## 1. 核心接口基准
| 场景 | 接口 | 指标 |
|---|---|---|
| 单订单创建 | POST /api/sales-orders | RT <= 300ms;P95 <= 500ms |
| 单订单修改 | PUT /api/sales-orders/{id} | RT <= 300ms;P95 <= 500ms |
| 采购收货入库 | POST /api/purchase-orders/{id}/receive | RT <= 300ms;P95 <= 500ms |
| 物料计划计算 | GET /api/sales-orders/{id}/production-plan | RT <= 300ms;P95 <= 500ms |
## 2. 压测资源与容量门槛
| 并发/吞吐 | CPU | 内存 | 错误率 |
|---|---|---|---|
| 200 TPS(持续) | <= 60% | <= 70% | <= 0.1% |
## 3. 测试数据要求
- 物料数 >= 10万
- 客户订单量 >= 1000万(或等比例分区仿真数据)
- 采购单量 >= 1000万
- 库存流水量 >= 1亿(用于验证索引与分区收益)
## 4. 报告产出
- pg_stat_statements TopN
- 接口 RT 分布(P50/P95/P99)
- 锁等待与死锁统计
- 索引命中率与缓存命中率
+50
View File
@@ -0,0 +1,50 @@
# 性能与扩展性评估补充(模具订单/采购主线)
## 1. 高并发冲突面与加固点
### 1.1 新增/修改模具订单的锁冲突来源
- 冲突对象:同一仓库同一物料的 `inventory` 行。
- 冲突操作:创建订单自动扣料、按单领料、修改订单回补再扣减。
### 1.2 已落地的并发加固
- 对库存扣减采用原子更新(`UPDATE ... SET quantity = quantity - :qty WHERE quantity >= :qty RETURNING quantity`),避免“读-改-写”丢失更新与超卖。
## 2. 慢 SQL 发现与清单产出方法
### 2.1 生产环境抓取
- 启用 `pg_stat_statements` 并定期采样均值/最大耗时 TopN。
- 参考脚本:`scripts/db/030_pg_stat_statements.sql`。
### 2.2 代码层高风险查询点(需要重点盯)
- 订单列表/详情:按 `customer_id/status/order_date` 过滤 + 明细表 join。
- 库存流水:按 `warehouse_id/created_at` 分页、按 `reference_type/reference_id` 追溯。
- 物料需求计算:必须避免 N+1(已将 BOM 查询批量化)。
## 3. 索引与查询重写(可执行脚本)
- `scripts/db/002_indexes_and_constraints.sql` 覆盖:
- 订单头状态/客户组合索引
- 明细表 `order_id/product_id` 索引
- 流水 `warehouse_id+created_at`、`reference_type+reference_id` 索引
- 库存唯一约束与关键 check 约束
## 4. 千万级订单量扩展路线
### 4.1 优先方案:PostgreSQL 分区
- 表:`sales_orders`、`purchase_orders` 按 `order_date` 月分区。
- 参考模板:`scripts/db/010_partitioning_template.sql`。
### 4.2 分库分表(必要时)
- 拆分键:`org_id`(库路由)+ `order_date`(表分区)。
- 灰度步骤:双写→读切换→写切换→数据校验→下线旧库。
## 5. 缓存建议(可选)
- 读多写少:产品/BOM/供应商/客户可使用本地缓存(Caffeine 类似策略在 Python 可用 LRU/TTL)或 Redis。
- 幂等:新增订单建议引入 `idempotency_key`(请求头/字段),避免重复提交造成重复扣料与重复下单。
+40
View File
@@ -0,0 +1,40 @@
# UAT 验收清单(模具制造进销存主线)
## 1. 业务流程签字
| 模块 | 场景 | 验收点 | 结果 | 业务签字 | 日期 |
|---|---|---|---|---|---|
| 客户订单 | 新增模具订单 | 订单创建成功、生成单号、金额正确 | | | |
| 客户订单 | 修改模具订单 | 允许字段范围、回补/重扣一致 | | | |
| 采购订单 | 创建采购单 | 单价取物料成本价、金额汇总正确 | | | |
| 采购订单 | 到货入库 | 不超收、部分收/全收状态正确、库存增量正确 | | | |
| 生产 | 发料/扣料 | 库存不足阻断、流水可追溯 | | | |
| 交付 | 交付后冻结 | delivered 后禁止任何修改/删除 | | | |
## 2. 数据准确性签字
| 校验项 | SQL/口径 | 预期 | 实际 | 数据签字 | 日期 |
|---|---|---|---|---|---|
| 采购单金额 | sum(items.amount) = order.total_amount | 一致 | | | |
| 收货累计 | received_quantity <= quantity | 成立 | | | |
| 库存不为负 | inventory.quantity >= 0 | 成立 | | | |
| 发料可追溯 | stock_movements.reference_type/reference_id | 完整 | | | |
## 3. 性能报告签字
| 指标 | 目标 | 实测 | 结论 | 性能签字 | 日期 |
|---|---|---|---|---|---|
| 单订单创建 RT | <=300ms | | | | |
| 95th RT | <=500ms | | | | |
| 200 TPS CPU | <=60% | | | | |
| 200 TPS 内存 | <=70% | | | | |
| 错误率 | <=0.1% | | | | |
## 4. 安全与审计签字
| 项 | 目标 | 实测 | 结论 | 安全签字 | 日期 |
|---|---|---|---|---|---|
| RBAC | 角色权限矩阵落地 | | | | |
| RLS | 事业部数据隔离 | | | | |
| 操作日志 | old/new、操作者、IP、UA、180 天 | | | | |
| 渗透测试 | 报告归档 | | | | |
@@ -0,0 +1,29 @@
# “零成品库存”证明报告(geMoldInsight)
## 1. 结论
系统不设置成品入库、成品出库、销售退货等成品库存模块;系统库存口径仅覆盖“物料(material)”,成品(finished/模具)仅作为订单交付对象,不进入库存核算链路。
## 2. 证据链(以当前实现为准)
- 产品主数据区分 `item_type=material/finished`,但库存查询接口仅返回 `material`。
- 采购入库与生产领料均记录在 `stock_movements`,且仅对物料库存表 `inventory` 做数量增减;不存在任何“成品入库/出库”类型的库存流水。
- 交付动作仅更新订单状态为 `delivered`,不产生库存变动。
## 3. 为什么必须不设成品库存模块
- 业务模型为“按单定制模具”,交付对象为项目成果物,不形成可复用的成品库存池。
- 生产过程的核心管理对象是“物料消耗与生产状态”,而非“成品数量周转”。
## 4. 对财务月结的影响评估
- 当前系统可提供的成本数据主要来自物料消耗估值(按物料成本价),可用于材料成本归集与追溯。
- 若财务要求形成完整月结闭环(在制/完工/成本结转/收入确认),仍需补齐:
- 完工确认(或完工入库的替代凭证口径)
- 制造费用与人工的分摊规则
- 交付节点的收入确认与应收口径
## 5. 风险与建议
- 风险:成本使用 `Float` 精度,且缺少移动加权/FIFO/附加成本分摊,财务口径会出现偏差。
- 建议:引入 NUMERIC 金额精度、入库批次成本层与附加成本分摊表,并定义“交付即完工确认”的财务口径或引入完工单据。
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
数据库迁移脚本:将 actual_delivery_date 和 actual_payment_date 字段从 DATE 改为 TIMESTAMP
"""
import asyncio
import sys
from pathlib import Path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from config.settings import settings
async def migrate():
"""执行数据库迁移"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(
settings.DATABASE_URL,
echo=True
)
async with engine.begin() as conn:
# 修改日期字段类型
alter_sql = """
ALTER TABLE sales_orders
ALTER COLUMN actual_delivery_date TYPE TIMESTAMP,
ALTER COLUMN actual_payment_date TYPE TIMESTAMP
"""
await conn.execute(text(alter_sql))
print("成功将实际交付和实际收款日期字段类型改为 TIMESTAMP")
await engine.dispose()
print("迁移完成")
if __name__ == "__main__":
asyncio.run(migrate())
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
数据库迁移脚本:将销售订单中的日期字段从 TIMESTAMP 改为 DATE 类型
"""
import asyncio
import sys
from pathlib import Path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from config.settings import settings
async def migrate():
"""执行数据库迁移"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(
settings.DATABASE_URL,
echo=True
)
async with engine.begin() as conn:
# 修改日期字段类型
alter_sql = """
ALTER TABLE sales_orders
ALTER COLUMN delivery_date TYPE DATE,
ALTER COLUMN manufacturing_date TYPE DATE,
ALTER COLUMN actual_delivery_date TYPE DATE,
ALTER COLUMN actual_payment_date TYPE DATE
"""
await conn.execute(text(alter_sql))
print("成功将销售订单的日期字段类型改为 DATE")
await engine.dispose()
print("迁移完成")
if __name__ == "__main__":
asyncio.run(migrate())
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""
数据库迁移脚本:将采购订单的预计到货日期字段从 TIMESTAMP 改为 DATE 类型
"""
import asyncio
import sys
from pathlib import Path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from config.settings import settings
async def migrate():
"""执行数据库迁移"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(
settings.DATABASE_URL,
echo=True
)
async with engine.begin() as conn:
# 修改日期字段类型
alter_sql = """
ALTER TABLE purchase_orders
ALTER COLUMN expected_date TYPE DATE
"""
await conn.execute(text(alter_sql))
print("成功将采购订单的预计到货日期字段类型改为 DATE")
await engine.dispose()
print("迁移完成")
if __name__ == "__main__":
asyncio.run(migrate())
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
数据库迁移脚本:为 sales_orders 表添加时间字段
"""
import asyncio
import sys
from pathlib import Path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from config.settings import settings
async def migrate():
"""执行数据库迁移"""
if not settings.DATABASE_URL:
print("错误:未配置数据库连接")
return
engine = create_async_engine(
settings.DATABASE_URL,
echo=True
)
async with engine.begin() as conn:
# 检查列是否存在
check_sql = """
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'sales_orders'
AND column_name = 'manufacturing_date'
"""
result = await conn.execute(text(check_sql))
if result.scalar():
print("列 manufacturing_date 已存在,跳过迁移")
else:
# 添加新列
alter_sql = """
ALTER TABLE sales_orders
ADD COLUMN manufacturing_date TIMESTAMP,
ADD COLUMN actual_delivery_date TIMESTAMP,
ADD COLUMN actual_payment_date TIMESTAMP
"""
await conn.execute(text(alter_sql))
print("成功添加时间字段到 sales_orders 表")
await engine.dispose()
print("迁移完成")
if __name__ == "__main__":
asyncio.run(migrate())
+1
View File
@@ -75,6 +75,7 @@ loguru>=0.7.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.24.0
aiosqlite>=0.19.0
# ============================================
# 开发工具
@@ -0,0 +1,56 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_language WHERE lanname = 'plpgsql') THEN
CREATE LANGUAGE plpgsql;
END IF;
END $$;
CREATE OR REPLACE FUNCTION trg_block_delivered_sales_orders_head()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.status = 'delivered' THEN
RAISE EXCEPTION '已交付的销售订单禁止修改或删除';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS block_delivered_sales_orders_head ON sales_orders;
CREATE TRIGGER block_delivered_sales_orders_head
BEFORE UPDATE OR DELETE ON sales_orders
FOR EACH ROW
EXECUTE FUNCTION trg_block_delivered_sales_orders_head();
CREATE OR REPLACE FUNCTION trg_block_delivered_sales_orders_items()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_status text;
BEGIN
SELECT status INTO v_status
FROM sales_orders
WHERE id = COALESCE(NEW.order_id, OLD.order_id);
IF v_status = 'delivered' THEN
RAISE EXCEPTION '已交付的销售订单明细禁止变更';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS block_delivered_sales_orders_items ON sales_order_items;
CREATE TRIGGER block_delivered_sales_orders_items
BEFORE INSERT OR UPDATE OR DELETE ON sales_order_items
FOR EACH ROW
EXECUTE FUNCTION trg_block_delivered_sales_orders_items();
@@ -0,0 +1,65 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'uq_inventory_product_warehouse'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT uq_inventory_product_warehouse UNIQUE (product_id, warehouse_id);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_inventory_qty_nonnegative'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT ck_inventory_qty_nonnegative CHECK (quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_purchase_order_items_qty'
) THEN
ALTER TABLE purchase_order_items
ADD CONSTRAINT ck_purchase_order_items_qty CHECK (quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_sales_order_items_qty'
) THEN
ALTER TABLE sales_order_items
ADD CONSTRAINT ck_sales_order_items_qty CHECK (quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_purchase_orders_status ON purchase_orders (status);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_supplier_status ON purchase_orders (supplier_id, status);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_order_id ON purchase_order_items (order_id);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_product_id ON purchase_order_items (product_id);
CREATE INDEX IF NOT EXISTS idx_sales_orders_status ON sales_orders (status);
CREATE INDEX IF NOT EXISTS idx_sales_orders_customer_status ON sales_orders (customer_id, status);
CREATE INDEX IF NOT EXISTS idx_sales_order_items_order_id ON sales_order_items (order_id);
CREATE INDEX IF NOT EXISTS idx_sales_order_items_product_id ON sales_order_items (product_id);
CREATE INDEX IF NOT EXISTS idx_inventory_warehouse_product ON inventory (warehouse_id, product_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_warehouse_created ON stock_movements (warehouse_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_stock_movements_reference ON stock_movements (reference_type, reference_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_reference_no ON stock_movements (reference_no);
+3
View File
@@ -0,0 +1,3 @@
UPDATE sales_orders
SET status = 'manufacturing'
WHERE status = 'pending';
+37
View File
@@ -0,0 +1,37 @@
DO $$
BEGIN
IF to_regclass('sales_orders_p') IS NULL THEN
EXECUTE $q$
CREATE TABLE sales_orders_p (
LIKE sales_orders INCLUDING ALL
) PARTITION BY RANGE (order_date)
$q$;
END IF;
IF to_regclass('purchase_orders_p') IS NULL THEN
EXECUTE $q$
CREATE TABLE purchase_orders_p (
LIKE purchase_orders INCLUDING ALL
) PARTITION BY RANGE (order_date)
$q$;
END IF;
END $$;
DO $$
DECLARE
d date := date_trunc('month', now())::date;
next_d date := (date_trunc('month', now()) + interval '1 month')::date;
part_name text;
BEGIN
part_name := format('sales_orders_p_%s', to_char(d, 'YYYYMM'));
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF sales_orders_p FOR VALUES FROM (%L) TO (%L)',
part_name, d, next_d
);
part_name := format('purchase_orders_p_%s', to_char(d, 'YYYYMM'));
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF purchase_orders_p FOR VALUES FROM (%L) TO (%L)',
part_name, d, next_d
);
END $$;
+28
View File
@@ -0,0 +1,28 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sales_orders' AND column_name = 'org_id'
) THEN
ALTER TABLE sales_orders ADD COLUMN org_id bigint NOT NULL DEFAULT 0;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'purchase_orders' AND column_name = 'org_id'
) THEN
ALTER TABLE purchase_orders ADD COLUMN org_id bigint NOT NULL DEFAULT 0;
END IF;
END $$;
ALTER TABLE sales_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchase_orders ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS sales_orders_org_isolation ON sales_orders;
CREATE POLICY sales_orders_org_isolation
ON sales_orders
USING (org_id::text = current_setting('app.current_org_id', true));
DROP POLICY IF EXISTS purchase_orders_org_isolation ON purchase_orders;
CREATE POLICY purchase_orders_org_isolation
ON purchase_orders
USING (org_id::text = current_setting('app.current_org_id', true));
+65
View File
@@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS audit_log (
id bigserial PRIMARY KEY,
table_name text NOT NULL,
op text NOT NULL,
pk jsonb,
old_data jsonb,
new_data jsonb,
actor_id bigint,
ip text,
ua text,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_table_time ON audit_log (table_name, changed_at DESC);
CREATE OR REPLACE FUNCTION audit_row_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_actor_id bigint;
v_ip text;
v_ua text;
v_pk jsonb;
BEGIN
v_actor_id := NULLIF(current_setting('app.current_user_id', true), '')::bigint;
v_ip := NULLIF(current_setting('app.current_ip', true), '');
v_ua := NULLIF(current_setting('app.current_ua', true), '');
IF TG_OP = 'INSERT' THEN
v_pk := jsonb_build_object('id', NEW.id);
INSERT INTO audit_log(table_name, op, pk, old_data, new_data, actor_id, ip, ua)
VALUES (TG_TABLE_NAME, TG_OP, v_pk, NULL, to_jsonb(NEW), v_actor_id, v_ip, v_ua);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
v_pk := jsonb_build_object('id', NEW.id);
INSERT INTO audit_log(table_name, op, pk, old_data, new_data, actor_id, ip, ua)
VALUES (TG_TABLE_NAME, TG_OP, v_pk, to_jsonb(OLD), to_jsonb(NEW), v_actor_id, v_ip, v_ua);
RETURN NEW;
ELSE
v_pk := jsonb_build_object('id', OLD.id);
INSERT INTO audit_log(table_name, op, pk, old_data, new_data, actor_id, ip, ua)
VALUES (TG_TABLE_NAME, TG_OP, v_pk, to_jsonb(OLD), NULL, v_actor_id, v_ip, v_ua);
RETURN OLD;
END IF;
END;
$$;
DROP TRIGGER IF EXISTS audit_sales_orders ON sales_orders;
CREATE TRIGGER audit_sales_orders
AFTER INSERT OR UPDATE OR DELETE ON sales_orders
FOR EACH ROW
EXECUTE FUNCTION audit_row_change();
DROP TRIGGER IF EXISTS audit_purchase_orders ON purchase_orders;
CREATE TRIGGER audit_purchase_orders
AFTER INSERT OR UPDATE OR DELETE ON purchase_orders
FOR EACH ROW
EXECUTE FUNCTION audit_row_change();
DROP TRIGGER IF EXISTS audit_stock_movements ON stock_movements;
CREATE TRIGGER audit_stock_movements
AFTER INSERT OR UPDATE OR DELETE ON stock_movements
FOR EACH ROW
EXECUTE FUNCTION audit_row_change();
+2
View File
@@ -0,0 +1,2 @@
DELETE FROM audit_log
WHERE changed_at < (now() - interval '180 days');
+12
View File
@@ -0,0 +1,12 @@
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
calls,
total_exec_time,
mean_exec_time,
max_exec_time,
rows,
query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 50;
+2
View File
@@ -25,6 +25,7 @@ from .purchase_order_routes import router as purchase_order_router
from .sales_order_routes import router as sales_order_router
from .dashboard_routes import router as dashboard_router
from .finance_routes import router as finance_router
from .material_routes import router as material_router
inventory_router = APIRouter(prefix="/api", tags=["进销存"])
@@ -36,6 +37,7 @@ inventory_router.include_router(inventory_management_router)
inventory_router.include_router(stock_movement_router)
inventory_router.include_router(purchase_order_router)
inventory_router.include_router(sales_order_router)
inventory_router.include_router(material_router)
inventory_router.include_router(dashboard_router)
inventory_router.include_router(finance_router)
+327
View File
@@ -0,0 +1,327 @@
"""
物料管理路由模块
提供物料价格历史和供应商关联管理功能,包括:
- 物料价格历史记录
- 物料供应商关联管理
- 物料价格趋势分析
路由前缀: /api/materials
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional, List
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import User, Product, MaterialPriceHistory, MaterialSupplier, Supplier
from .schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
MaterialPriceTrendResponse
)
router = APIRouter(prefix="/materials", tags=["物料管理"])
async def _get_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
return product
@router.post("/{product_id}/price-history", response_model=MaterialPriceHistoryResponse, status_code=201)
async def add_material_price_history(
product_id: int,
price_data: MaterialPriceHistoryCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
if price_data.supplier_id:
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == price_data.supplier_id, Supplier.is_active == True)
)
if not supplier_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="供应商不存在")
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark
)
db_session.add(price_history)
await db_session.commit()
await db_session.refresh(price_history)
# 更新产品的成本价格为最新价格
product.cost_price = price_data.price
await db_session.commit()
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=price_history.supplier.name if price_history.supplier else None,
remark=price_history.remark,
created_at=price_history.created_at
)
@router.get("/{product_id}/price-history", response_model=List[MaterialPriceHistoryResponse])
async def get_material_price_history(
product_id: int,
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
price_history_list = result.scalars().all()
return [
MaterialPriceHistoryResponse(
id=ph.id,
product_id=ph.product_id,
product_sku=product.sku,
product_name=product.name,
price=ph.price,
effective_date=ph.effective_date,
supplier_id=ph.supplier_id,
supplier_name=ph.supplier.name if ph.supplier else None,
remark=ph.remark,
created_at=ph.created_at
)
for ph in price_history_list
]
@router.get("/{product_id}/price-trend", response_model=MaterialPriceTrendResponse)
async def get_material_price_trend(
product_id: int,
months: int = Query(6, ge=1, le=24),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 计算价格趋势
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
price_history_list = result.scalars().all()
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
prices = [ph.price for ph in reversed(price_history_list)]
dates = [ph.effective_date for ph in reversed(price_history_list)]
# 计算价格变化
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
{
"date": ph.effective_date,
"price": ph.price,
"supplier_name": ph.supplier.name if ph.supplier else None
}
for ph in price_history_list
]
)
@router.post("/{product_id}/suppliers", response_model=MaterialSupplierResponse, status_code=201)
async def add_material_supplier(
product_id: int,
supplier_data: MaterialSupplierCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_data.supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400, detail="供应商不存在")
# 检查是否已存在关联
existing = await db_session.execute(
select(MaterialSupplier)
.where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
# 如果设置为主要供应商,将其他供应商设置为非主要
if supplier_data.is_primary:
await db_session.execute(
MaterialSupplier.__table__.update()
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity
)
db_session.add(material_supplier)
await db_session.commit()
await db_session.refresh(material_supplier)
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier.name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at
)
@router.get("/{product_id}/suppliers", response_model=List[MaterialSupplierResponse])
async def get_material_suppliers(
product_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
supplier_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=ms.supplier_id,
supplier_name=ms.supplier.name if ms.supplier else None,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in supplier_list
]
@router.delete("/suppliers/{supplier_id}")
async def remove_material_supplier(
supplier_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.commit()
return {"message": "物料供应商关联已删除"}
@router.get("/suppliers/{supplier_id}/materials", response_model=List[MaterialSupplierResponse])
async def get_supplier_materials(
supplier_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
material_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=ms.product.sku if ms.product else None,
product_name=ms.product.name if ms.product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in material_list
]
+19 -10
View File
@@ -144,16 +144,25 @@ async def _apply_order_items(
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
if product.item_type != "material":
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
item = PurchaseOrderItem(
order_id=order.id,
product_id=item_data.product_id,
quantity=item_data.quantity,
unit_price=item_data.unit_price,
amount=item_data.quantity * item_data.unit_price,
remark=item_data.remark
)
db_session.add(item)
total_amount += item.amount
# 使用物料的成本价格作为单价,忽略前端提交的单价
unit_price = product.cost_price or 0
if unit_price <= 0:
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置成本价格,请在物料管理界面设置")
try:
item = PurchaseOrderItem(
order_id=order.id,
product_id=item_data.product_id,
quantity=int(item_data.quantity),
unit_price=float(unit_price),
amount=float(item_data.quantity) * float(unit_price),
remark=item_data.remark or None
)
db_session.add(item)
total_amount += item.amount
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
return total_amount
+66 -35
View File
@@ -10,7 +10,7 @@
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from sqlalchemy import select, func, delete, update
from typing import Optional, List
from math import ceil
@@ -51,6 +51,9 @@ def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesO
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
production_status=order.production_status or "not_started",
production_no=order.production_no,
@@ -79,6 +82,9 @@ async def _build_sales_order_detail_response(
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
production_status=order.production_status or "not_started",
production_no=order.production_no,
@@ -125,27 +131,28 @@ async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> t
if not order_items:
return [], 0
finished_ids = list({int(i.product_id) for i in order_items})
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(finished_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_rows = bom_result.all()
if not bom_rows:
return [], 0
bom_by_finished_id = {}
for bom, material in bom_rows:
bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material))
required_qty_map = {}
for order_item in order_items:
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id == order_item.product_id)
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_items = bom_result.all()
if not bom_items:
continue
bom_items = bom_by_finished_id.get(int(order_item.product_id)) or []
for bom, material in bom_items:
qty = float(order_item.quantity) * float(bom.quantity or 0) * (1 + float(bom.loss_rate or 0))
entry = required_qty_map.setdefault(
material.id,
{
"material": material,
"required_qty": 0.0
}
)
entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": 0.0})
entry["required_qty"] += qty
if not required_qty_map:
@@ -220,18 +227,19 @@ async def _issue_materials_for_order_creation(
movement_count = 0
for item in plan_items:
inv_result = await db_session.execute(
select(Inventory)
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
inventory = inv_result.scalar_one_or_none()
if not inventory or inventory.quantity < item.required_quantity:
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
before_qty = inventory.quantity
inventory.quantity -= item.required_quantity
after_qty = inventory.quantity
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
@@ -401,10 +409,15 @@ async def create_sales_order(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
from datetime import datetime
now = datetime.now()
order = SalesOrder(
order_no=generate_order_no("SO"),
customer_id=order_data.customer_id,
order_date=now,
delivery_date=order_data.delivery_date,
manufacturing_date=now.date(),
created_at=now,
remark=order_data.remark,
operator_id=current_user.id,
status="manufacturing"
@@ -441,6 +454,8 @@ async def update_sales_order(
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止修改")
await _rollback_issued_materials(db_session, order, current_user)
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
@@ -475,6 +490,17 @@ async def update_sales_order_status(
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
order, customer = await _get_sales_order_with_customer(db_session, order_id)
# 只禁止从已交付状态改为非已收款状态
if order.status == "delivered" and payload.status != "paid":
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款状态")
# 根据状态更新相应的日期字段
from datetime import datetime
if payload.status == "delivered" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
elif payload.status == "paid" and not order.actual_payment_date:
order.actual_payment_date = datetime.now()
order.status = payload.status
await db_session.commit()
await db_session.refresh(order)
@@ -488,6 +514,8 @@ async def delete_sales_order(
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
await _rollback_issued_materials(db_session, order, current_user)
await db_session.delete(order)
await db_session.commit()
@@ -522,6 +550,8 @@ async def issue_sales_order_materials(
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料")
if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产")
if order.production_status == "material_issued":
@@ -548,18 +578,19 @@ async def issue_sales_order_materials(
actual_material_cost = 0.0
movement_count = 0
for item in plan_items:
inv_result = await db_session.execute(
select(Inventory)
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
inventory = inv_result.scalar_one_or_none()
if not inventory or inventory.quantity < item.required_quantity:
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足")
before_qty = inventory.quantity
inventory.quantity -= item.required_quantity
after_qty = inventory.quantity
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
@@ -586,7 +617,7 @@ async def issue_sales_order_materials(
order.planned_material_cost = round(float(planned_material_cost), 4)
order.actual_material_cost = round(float(actual_material_cost), 4)
if order.status == "draft":
order.status = "pending"
order.status = "manufacturing"
await db_session.commit()
+9
View File
@@ -47,6 +47,13 @@ from .finance_schemas import (
PartnerProductStatementItemResponse,
FinancePartnerProductStatementResponse
)
from .material_schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
MaterialPriceTrendResponse
)
__all__ = [
"ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate",
@@ -67,4 +74,6 @@ __all__ = [
"FinanceSummaryResponse", "ReceivableItemResponse", "PayableItemResponse",
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
]
@@ -0,0 +1,80 @@
"""
物料管理相关数据模型
定义物料价格历史和物料供应商关联的数据结构
"""
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, List, Dict
class MaterialPriceHistoryCreate(BaseModel):
"""创建物料价格历史的请求模型"""
price: float = Field(..., gt=0, description="物料价格")
supplier_id: Optional[int] = Field(None, description="供应商ID")
remark: Optional[str] = Field(None, description="备注")
class MaterialPriceHistoryResponse(BaseModel):
"""物料价格历史的响应模型"""
id: int
product_id: int
product_sku: str
product_name: str
price: float
effective_date: datetime
supplier_id: Optional[int]
supplier_name: Optional[str]
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class MaterialSupplierCreate(BaseModel):
"""创建物料供应商关联的请求模型"""
supplier_id: int = Field(..., description="供应商ID")
is_primary: bool = Field(False, description="是否为主要供应商")
contact_person: Optional[str] = Field(None, description="联系人")
contact_phone: Optional[str] = Field(None, description="联系电话")
lead_time: Optional[int] = Field(None, ge=1, description="交货周期(天)")
min_order_quantity: Optional[int] = Field(None, ge=1, description="最小订购量")
class MaterialSupplierResponse(BaseModel):
"""物料供应商关联的响应模型"""
id: int
product_id: int
product_sku: str
product_name: str
supplier_id: int
supplier_name: str
is_primary: bool
contact_person: Optional[str]
contact_phone: Optional[str]
lead_time: Optional[int]
min_order_quantity: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PriceHistoryItem(BaseModel):
"""价格历史项"""
date: datetime
price: float
supplier_name: Optional[str]
class MaterialPriceTrendResponse(BaseModel):
"""物料价格趋势的响应模型"""
product_id: int
product_sku: str
product_name: str
current_price: float
price_change: float
price_change_percent: float
price_history: List[PriceHistoryItem]
@@ -1,20 +1,20 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from datetime import datetime, date
class PurchaseOrderItemCreate(BaseModel):
product_id: int
quantity: int
unit_price: float
quantity: int = Field(..., gt=0, description="采购数量")
unit_price: Optional[float] = Field(None, description="单价(可选,后端自动使用物料成本价格)")
remark: Optional[str] = None
class PurchaseOrderCreate(BaseModel):
supplier_id: int
expected_date: Optional[datetime] = None
expected_date: Optional[date] = None
remark: Optional[str] = None
items: List[PurchaseOrderItemCreate]
items: List[PurchaseOrderItemCreate] = Field(min_length=1)
class PurchaseOrderResponse(BaseModel):
@@ -22,7 +22,7 @@ class PurchaseOrderResponse(BaseModel):
order_no: str
supplier_name: str
order_date: datetime
expected_date: Optional[datetime]
expected_date: Optional[date]
status: str
total_amount: float
paid_amount: float
@@ -52,10 +52,10 @@ class PurchaseOrderDetailResponse(PurchaseOrderResponse):
class PurchaseOrderReceiveItem(BaseModel):
item_id: int
receive_quantity: int
receive_quantity: int = Field(gt=0)
class PurchaseOrderReceiveRequest(BaseModel):
warehouse_id: Optional[int] = None
items: List[PurchaseOrderReceiveItem]
items: List[PurchaseOrderReceiveItem] = Field(min_length=1)
remark: Optional[str] = None
@@ -1,6 +1,6 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from datetime import datetime, date
class SalesOrderItemCreate(BaseModel):
@@ -9,16 +9,16 @@ class SalesOrderItemCreate(BaseModel):
product_name: Optional[str] = None
product_category: Optional[str] = None
product_unit: Optional[str] = "件"
quantity: int
unit_price: float
quantity: int = Field(gt=0)
unit_price: float = Field(ge=0)
remark: Optional[str] = None
class SalesOrderCreate(BaseModel):
customer_id: int
delivery_date: Optional[datetime] = None
delivery_date: Optional[date] = None
remark: Optional[str] = None
items: List[SalesOrderItemCreate]
items: List[SalesOrderItemCreate] = Field(min_length=1)
class SalesOrderResponse(BaseModel):
@@ -26,7 +26,10 @@ class SalesOrderResponse(BaseModel):
order_no: str
customer_name: str
order_date: datetime
delivery_date: Optional[datetime]
delivery_date: Optional[date]
manufacturing_date: Optional[date]
actual_delivery_date: Optional[datetime]
actual_payment_date: Optional[datetime]
status: str
production_status: str
production_no: Optional[str]
+4 -3
View File
@@ -118,7 +118,7 @@ async def create_admin_user(session):
logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}")
async def init_database():
async def init_database(keep_connected: bool = True):
"""初始化数据库"""
try:
await db_manager.connect()
@@ -155,7 +155,8 @@ async def init_database():
print(f"数据库初始化失败: {e}")
return False
finally:
await db_manager.disconnect()
if not keep_connected:
await db_manager.disconnect()
async def ensure_schema_updates():
@@ -184,4 +185,4 @@ async def ensure_schema_updates():
if __name__ == "__main__":
asyncio.run(init_database())
asyncio.run(init_database(keep_connected=False))
+35 -6
View File
@@ -39,7 +39,6 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import asyncio
from api.routes import router
from api.auth_routes import router as auth_router
from api.inventory import inventory_router
from utils.logger import setup_logging
@@ -58,7 +57,7 @@ app = FastAPI(
async def startup_event():
"""应用启动时初始化数据库和RustFS"""
# 初始化数据库
success = await init_database()
success = await init_database(keep_connected=True)
if success:
print("[OK] 数据库初始化成功")
else:
@@ -102,18 +101,38 @@ app.mount("/html", StaticFiles(directory=html_output_dir), name="html")
app.include_router(auth_router)
app.include_router(inventory_router)
app.include_router(router, prefix="/api")
try:
from api.routes import router as moldinsight_router
except Exception as e:
moldinsight_router = None
print(f"[WARN] MoldInsight路由未加载: {e}")
if moldinsight_router is not None:
app.include_router(moldinsight_router, prefix="/api")
@app.get("/health")
@app.post("/health")
async def health():
from database.database import db_manager
from sqlalchemy import text
db_ok = False
db_error = None
try:
if not db_manager.is_connected:
await db_manager.connect()
async with db_manager.engine.begin() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception as e:
db_ok = False
db_error = str(e)
return {
"status": "healthy",
"service": "gemold",
"version": "4.0.0",
"database_connected": db_manager.is_connected
"database_connected": db_ok,
"database_error": db_error
}
@@ -135,6 +154,12 @@ async def inventory():
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/login")
async def login():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/users")
async def users():
from fastapi.responses import FileResponse
@@ -144,9 +169,13 @@ async def users():
if __name__ == "__main__":
import uvicorn
from config.settings import settings
reload_enabled = os.getenv("UVICORN_RELOAD", "0").lower() in {"1", "true", "yes", "on"}
print("启动 Gemold 模具制造管理系统 v4.0...")
print(f"访问 http://localhost:{settings.PORT}")
print(f"Python可执行文件: {sys.executable}")
print(f"进程ID: {os.getpid()}")
print(f"热重载: {reload_enabled}")
print("功能模块:")
print(" - 首页仪表盘")
print(" - 用户管理")
@@ -157,5 +186,5 @@ if __name__ == "__main__":
"main:app",
host=settings.HOST,
port=settings.PORT,
reload=True
)
reload=reload_enabled
)
+48 -4
View File
@@ -1,9 +1,9 @@
# models/database.py
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from datetime import datetime
from datetime import datetime, date
Base = declarative_base()
@@ -536,6 +536,47 @@ class ProductMaterial(Base):
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
class MaterialPriceHistory(Base):
"""物料价格历史表"""
__tablename__ = "material_price_history"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
price = Column(Float, nullable=False)
effective_date = Column(DateTime, default=func.now(), index=True)
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
remark = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
product = relationship("Product", backref="price_history")
supplier = relationship("Supplier", backref="price_history")
def __repr__(self):
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
class MaterialSupplier(Base):
"""物料供应商关联表"""
__tablename__ = "material_suppliers"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
is_primary = Column(Boolean, default=False)
contact_person = Column(String(100), nullable=True)
contact_phone = Column(String(50), nullable=True)
lead_time = Column(Integer, nullable=True) # 交货周期(天)
min_order_quantity = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
product = relationship("Product", backref="suppliers")
supplier = relationship("Supplier", backref="materials")
def __repr__(self):
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
class Supplier(Base):
"""供应商表"""
__tablename__ = "suppliers"
@@ -663,7 +704,7 @@ class PurchaseOrder(Base):
order_no = Column(String(50), unique=True, index=True, nullable=False)
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
order_date = Column(DateTime, default=func.now())
expected_date = Column(DateTime, nullable=True)
expected_date = Column(Date, nullable=True)
status = Column(String(20), default="draft")
total_amount = Column(Float, default=0)
paid_amount = Column(Float, default=0)
@@ -706,7 +747,10 @@ class SalesOrder(Base):
order_no = Column(String(50), unique=True, index=True, nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
order_date = Column(DateTime, default=func.now())
delivery_date = Column(DateTime, nullable=True)
delivery_date = Column(Date, nullable=True)
manufacturing_date = Column(Date, nullable=True)
actual_delivery_date = Column(DateTime, nullable=True)
actual_payment_date = Column(DateTime, nullable=True)
status = Column(String(20), default="draft")
production_status = Column(String(20), default="not_started", index=True)
production_no = Column(String(50), nullable=True, index=True)
+60 -1
View File
@@ -4,6 +4,64 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemold - 模具制造管理系统</title>
<script>
(function () {
var params = new URLSearchParams(location.search);
var uiParam = params.get('ui');
var themeParam = params.get('theme');
var storedUi = null;
try { storedUi = localStorage.getItem('gemold_ui_version'); } catch (e) {}
var storedPercent = null;
try { storedPercent = localStorage.getItem('gemold_ui_rollout_percent'); } catch (e) {}
var percent = storedPercent ? Number(storedPercent) : 0;
if (!Number.isFinite(percent) || percent < 0) percent = 0;
if (percent > 100) percent = 100;
var uid = null;
try { uid = localStorage.getItem('gemold_uid'); } catch (e) {}
if (!uid) {
uid = 'u_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
try { localStorage.setItem('gemold_uid', uid); } catch (e) {}
}
function hash32(str) {
var h = 2166136261;
for (var i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function chooseUiVersion() {
if (uiParam === 'v1' || uiParam === 'v2') return uiParam;
if (storedUi === 'v1' || storedUi === 'v2') return storedUi;
if (percent <= 0) return 'v1';
var bucket = hash32(uid) % 100;
return bucket < percent ? 'v2' : 'v1';
}
function chooseTheme() {
var storedTheme = null;
try { storedTheme = localStorage.getItem('gemold_theme'); } catch (e) {}
if (themeParam === 'light' || themeParam === 'dark') return themeParam;
if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme;
return 'light';
}
var ui = chooseUiVersion();
var theme = chooseTheme();
document.documentElement.dataset.ui = ui;
document.documentElement.dataset.theme = theme;
try {
if (uiParam === 'v1' || uiParam === 'v2') localStorage.setItem('gemold_ui_version', uiParam);
if (themeParam === 'light' || themeParam === 'dark') localStorage.setItem('gemold_theme', themeParam);
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.css">
<link rel="stylesheet" href="/static/style.css?v=20260317-01">
</head>
<body>
@@ -18,6 +76,7 @@
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
<script src="/static/vue-app.js?v=20260317-02"></script>
<script src="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.js"></script>
<script src="/static/vue-app.js?v=20260324-01"></script>
</body>
</html>
+591 -3
View File
@@ -115,6 +115,111 @@
--duration-slow: 300ms;
}
:root[data-ui="v2"] {
--primary-50: #eef2ff;
--primary-100: #e0e7ff;
--primary-200: #c7d2fe;
--primary-300: #a5b4fc;
--primary-400: #818cf8;
--primary-500: #6366f1;
--primary-600: #4f46e5;
--primary-700: #4338ca;
--primary-800: #3730a3;
--primary-900: #312e81;
--gray-50: #fafafa;
--gray-100: #f4f4f5;
--gray-200: #e4e4e7;
--gray-300: #d4d4d8;
--gray-400: #a1a1aa;
--gray-500: #71717a;
--gray-600: #52525b;
--gray-700: #3f3f46;
--gray-800: #27272a;
--gray-900: #18181b;
--success: #16a34a;
--success-bg: #f0fdf4;
--warning: #d97706;
--warning-bg: #fffbeb;
--error: #dc2626;
--error-bg: #fef2f2;
--info: var(--primary-600);
--info-bg: var(--primary-50);
--text-primary: #111827;
--text-secondary: #374151;
--text-tertiary: #6b7280;
--text-muted: #9ca3af;
--bg-primary: #ffffff;
--bg-secondary: #ffffff;
--bg-tertiary: #f8fafc;
--bg-elevated: #ffffff;
--bg-overlay: rgba(15, 23, 42, 0.5);
--border-light: rgba(15, 23, 42, 0.08);
--border-default: rgba(15, 23, 42, 0.12);
--border-strong: rgba(15, 23, 42, 0.18);
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-sm: 0 2px 10px rgba(15, 23, 42, 0.08);
--shadow-md: 0 12px 30px rgba(15, 23, 42, 0.12);
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.14);
--duration-fast: 140ms;
--duration-normal: 180ms;
--duration-slow: 260ms;
}
html[data-ui="v2"] body {
background: var(--bg-primary);
}
:root[data-theme="dark"] {
--text-primary: #f3f4f6;
--text-secondary: #d1d5db;
--text-tertiary: #9ca3af;
--text-muted: #6b7280;
--bg-primary: #0b1220;
--bg-secondary: #0f172a;
--bg-tertiary: #111c33;
--bg-elevated: #0f172a;
--bg-overlay: rgba(0, 0, 0, 0.6);
--border-light: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--border-strong: rgba(255, 255, 255, 0.16);
}
:root[data-ui="v2"][data-theme="dark"] {
--primary-50: rgba(99, 102, 241, 0.12);
--primary-100: rgba(99, 102, 241, 0.18);
--bg-primary: #0a1020;
--bg-secondary: #0b1326;
--bg-tertiary: #0d1930;
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 1ms;
--duration-normal: 1ms;
--duration-slow: 1ms;
}
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 1ms !important;
animation-duration: 1ms !important;
}
}
/* ================================
CSS Reset & Base
================================ */
@@ -299,6 +404,15 @@ body {
margin-bottom: var(--space-6);
}
:root[data-ui="v2"] .card {
border-color: var(--border-default);
box-shadow: var(--shadow-sm);
}
:root[data-ui="v2"][data-theme="dark"] .card {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.card-header {
display: flex;
align-items: center;
@@ -336,6 +450,29 @@ body {
text-decoration: none;
}
:root[data-ui="v2"] .btn {
border-radius: var(--radius-lg);
}
.btn:focus-visible,
.nav-item:focus-visible,
.form-input:focus-visible,
.inventory-menu-item:focus-visible,
.inventory-menu-title:focus-visible,
.modal-close:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
}
:root[data-theme="dark"] .btn:focus-visible,
:root[data-theme="dark"] .nav-item:focus-visible,
:root[data-theme="dark"] .form-input:focus-visible,
:root[data-theme="dark"] .inventory-menu-item:focus-visible,
:root[data-theme="dark"] .inventory-menu-title:focus-visible,
:root[data-theme="dark"] .modal-close:focus-visible {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.35);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%);
color: white;
@@ -853,9 +990,10 @@ optgroup {
.data-table th,
.data-table td {
padding: var(--space-3) var(--space-4);
padding: var(--space-4) var(--space-4);
text-align: left;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
.data-table th {
@@ -1403,6 +1541,18 @@ optgroup {
.hero-title {
font-size: var(--text-2xl);
}
.inventory-layout {
flex-direction: column;
gap: var(--space-4);
}
.inventory-sidebar {
width: 100%;
flex: 0 0 auto;
position: static;
top: auto;
}
}
/* ================================
@@ -1513,6 +1663,151 @@ select.form-input {
box-shadow: var(--shadow-sm);
}
.inventory-layout {
display: flex;
gap: var(--space-6);
align-items: flex-start;
}
.inventory-sidebar {
width: 260px;
flex: 0 0 260px;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xs);
padding: var(--space-4);
position: sticky;
top: 80px;
}
.inventory-menu {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.inventory-menu-group {
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-primary);
}
.inventory-menu-title {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-primary);
background: var(--bg-secondary);
border: none;
cursor: pointer;
}
.inventory-menu-caret {
color: var(--text-tertiary);
}
.inventory-menu-items {
padding: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.inventory-menu-item {
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
}
.inventory-menu-item:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.inventory-menu-item.active {
background: var(--primary-50);
color: var(--primary-700);
}
.inventory-content {
flex: 1 1 auto;
min-width: 0;
}
.inventory-content-header {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xs);
padding: var(--space-4) var(--space-5);
margin-bottom: var(--space-6);
}
.inventory-content-title {
font-size: var(--text-sm);
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.inventory-breadcrumb {
font-weight: var(--font-semibold);
color: var(--text-secondary);
}
.inventory-breadcrumb-sep {
color: var(--text-muted);
}
.inventory-breadcrumb-current {
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.inline-alert {
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
border: 1px solid var(--border-default);
background: var(--bg-primary);
margin-bottom: var(--space-6);
}
.inline-alert-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
margin-bottom: var(--space-1);
}
.inline-alert-message {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.inline-alert-warning {
border-color: rgba(234, 179, 8, 0.35);
background: var(--warning-bg);
}
.inline-alert-warning .inline-alert-title {
color: #a16207;
}
.tab-item {
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
@@ -1532,6 +1827,53 @@ select.form-input {
border-bottom-color: var(--primary-500);
}
/* ================================
日期选择器样式
================================ */
.air-datetime-input {
appearance: none;
-webkit-appearance: none;
color: var(--text-primary);
min-height: 44px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-3) calc(var(--space-4) + 28px) var(--space-3) var(--space-4);
background: var(--bg-primary);
cursor: pointer;
}
.air-datetime-input:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.datetime-field {
position: relative;
display: flex;
align-items: center;
}
.datetime-native {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.air-datepicker {
z-index: 2005 !important;
}
.datetime-field-icon {
position: absolute;
right: var(--space-4);
color: var(--text-tertiary);
pointer-events: none;
}
/* ================================
工具类
================================ */
@@ -1560,6 +1902,63 @@ select.form-input {
.hidden { display: none; }
/* 下拉菜单样式 */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-toggle {
position: relative;
padding-right: 24px;
}
.dropdown-toggle::after {
content: '▼';
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: var(--space-2);
min-width: 120px;
z-index: 1000;
display: none;
}
.dropdown:hover .dropdown-menu {
display: block;
}
.dropdown-item {
display: block;
width: 100%;
padding: var(--space-2) var(--space-3);
text-align: left;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-sm);
color: var(--text-primary);
transition: all var(--duration-fast) var(--ease-default);
}
.dropdown-item:hover {
background: var(--bg-secondary);
color: var(--primary-600);
}
/* ================================
统计卡片 - 现代风格
================================ */
@@ -1758,7 +2157,7 @@ select.form-input {
background: var(--bg-primary);
border-radius: var(--radius-xl);
width: 100%;
max-width: 480px;
max-width: 1000px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
@@ -1893,9 +2292,10 @@ select.form-input {
.data-table th,
.data-table td {
padding: var(--space-3) var(--space-4);
padding: var(--space-4) var(--space-4);
text-align: left;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
.data-table th {
@@ -2149,6 +2549,194 @@ select.form-input {
overflow: hidden;
}
/* 表格输入框样式 */
.data-table td input.form-input {
width: 100%;
min-width: 150px;
padding: 8px 12px;
font-size: 14px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
transition: all var(--duration-fast) var(--ease-default);
}
.data-table td input.form-input:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px var(--primary-100);
}
.data-table td input[type="number"] {
text-align: right;
min-width: 150px;
}
.data-table td input.form-input[placeholder*="备注"] {
min-width: 250px;
}
/* 销售订单表格优化 */
.sales-order-table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
}
.sales-order-table th,
.sales-order-table td {
padding: var(--space-3) var(--space-3);
text-align: left;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
.sales-order-table th {
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-tertiary);
background: var(--bg-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sales-order-table th:first-child {
width: 350px;
}
.sales-order-table th:nth-child(2),
.sales-order-table th:nth-child(3) {
width: 120px;
}
.sales-order-table th:nth-child(4) {
width: 200px;
}
.sales-order-table th:nth-child(5) {
width: 80px;
}
/* 销售订单表单优化 */
.sales-order-item {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.sales-order-item-header {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.sales-order-item-header button {
flex: 1;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--font-medium);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
}
.sales-order-item-header button.active {
background: var(--primary-500);
color: white;
border-color: var(--primary-500);
}
.sales-order-item-header button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.sales-order-item-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sales-order-item-content input.form-input,
.sales-order-item-content select.form-input {
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
transition: all var(--duration-fast) var(--ease-default);
}
.sales-order-item-content input.form-input:focus,
.sales-order-item-content select.form-input:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px var(--primary-100);
}
.sales-order-item-row {
display: flex;
align-items: flex-start;
gap: var(--space-4);
}
.sales-order-item-column {
flex: 1;
min-width: 0;
}
.sales-order-item-column input.form-input,
.sales-order-item-column select.form-input {
width: 100%;
}
.sales-order-item-actions {
display: flex;
align-items: flex-start;
padding-top: var(--space-3);
}
/* 采购订单表单优化 */
.purchase-order-items {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.purchase-order-item {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.purchase-order-item-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.purchase-order-item-row {
display: flex;
align-items: flex-start;
gap: var(--space-4);
}
.purchase-order-item-column {
flex: 1;
min-width: 0;
}
.purchase-order-item-actions {
display: flex;
align-items: flex-start;
padding-top: var(--space-3);
}
.viewer-frame {
width: 100%;
height: 500px;
+851 -176
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
import os
import sys
import tempfile
vendor_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "_vendor"))
if os.path.isdir(vendor_dir) and vendor_dir not in sys.path:
sys.path.insert(0, vendor_dir)
src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))
if os.path.isdir(src_dir) and src_dir not in sys.path:
sys.path.insert(0, src_dir)
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import select
from fastapi import FastAPI, APIRouter
from api.inventory import inventory_router
from models.database import Base, User, Customer, Warehouse, Supplier, Product, ProductMaterial, Inventory
from database.database import get_db_session
from services.auth_service import get_current_active_user
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
@pytest.fixture(scope="session")
def sqlite_db_path():
fd, path = tempfile.mkstemp(prefix="gemold_test_", suffix=".db")
os.close(fd)
yield path
try:
os.remove(path)
except OSError:
pass
@pytest.fixture(scope="session")
async def async_engine(sqlite_db_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{sqlite_db_path}", future=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture(scope="function")
async def db_session(async_engine):
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
yield session
await session.rollback()
@pytest.fixture(scope="function")
async def seeded_db(db_session: AsyncSession):
user = User(
id=1,
username="tester",
email="tester@example.com",
hashed_password="x",
full_name="Tester",
is_active=True,
)
customer = Customer(id=1, code="C001", name="客户A", is_active=True)
supplier = Supplier(id=1, code="S001", name="供应商A", is_active=True)
warehouse = Warehouse(id=1, code="W001", name="默认仓库", is_active=True, is_default=True)
material = Product(
id=1,
sku="MAT-001",
name="钢材",
unit="kg",
item_type="material",
cost_price=10.0,
sale_price=0,
min_stock=0,
max_stock=100000,
is_active=True,
)
finished = Product(
id=2,
sku="MOLD-STD",
name="标准模具",
unit="套",
item_type="finished",
cost_price=0,
sale_price=1000.0,
min_stock=0,
max_stock=0,
is_active=True,
)
bom = ProductMaterial(
id=1,
finished_product_id=finished.id,
material_product_id=material.id,
quantity=2.0,
loss_rate=0.05,
)
inv = Inventory(
id=1,
product_id=material.id,
warehouse_id=warehouse.id,
quantity=1000,
locked_quantity=0,
)
db_session.add_all([user, customer, supplier, warehouse, material, finished, bom, inv])
await db_session.commit()
return {"user": user, "customer": customer, "supplier": supplier, "warehouse": warehouse, "material": material, "finished": finished}
@pytest.fixture(scope="function")
async def client(async_engine, seeded_db):
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
test_app = FastAPI()
test_app.include_router(inventory_router)
async def override_get_db_session():
async with session_factory() as session:
yield session
async def override_get_current_active_user():
async with session_factory() as session:
result = await session.execute(select(User).where(User.username == "tester"))
return result.scalar_one()
test_app.dependency_overrides[get_db_session] = override_get_db_session
test_app.dependency_overrides[get_current_active_user] = override_get_current_active_user
transport = ASGITransport(app=test_app, lifespan="off")
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
test_app.dependency_overrides.clear()
+259
View File
@@ -0,0 +1,259 @@
import pytest
@pytest.mark.anyio
async def test_inventory_list_returns_only_materials(client):
resp = await client.get("/api/inventory")
assert resp.status_code == 200
rows = resp.json()
assert any(r["product_sku"] == "MAT-001" for r in rows)
assert all(r["item_type"] == "material" for r in rows)
@pytest.mark.anyio
async def test_purchase_order_create_and_receive_flow(client):
create_payload = {
"supplier_id": 1,
"expected_date": None,
"remark": "po",
"items": [{"product_id": 1, "quantity": 5, "unit_price": 9999.0, "remark": None}],
}
resp = await client.post("/api/purchase-orders", json=create_payload)
assert resp.status_code == 201
order = resp.json()
assert order["status"] == "draft"
assert order["total_amount"] == 50.0
order_id = order["id"]
detail = await client.get(f"/api/purchase-orders/{order_id}")
assert detail.status_code == 200
item_id = detail.json()["items"][0]["id"]
recv1 = await client.post(
f"/api/purchase-orders/{order_id}/receive",
json={"warehouse_id": 1, "items": [{"item_id": item_id, "receive_quantity": 3}], "remark": "r1"},
)
assert recv1.status_code == 200
assert recv1.json()["status"] == "partial_received"
recv2 = await client.post(
f"/api/purchase-orders/{order_id}/receive",
json={"warehouse_id": 1, "items": [{"item_id": item_id, "receive_quantity": 2}], "remark": "r2"},
)
assert recv2.status_code == 200
assert recv2.json()["status"] == "received"
@pytest.mark.anyio
async def test_purchase_order_receive_overflow_rejected(client):
create_payload = {
"supplier_id": 1,
"items": [{"product_id": 1, "quantity": 2}],
}
resp = await client.post("/api/purchase-orders", json=create_payload)
assert resp.status_code == 201
order_id = resp.json()["id"]
detail = await client.get(f"/api/purchase-orders/{order_id}")
item_id = detail.json()["items"][0]["id"]
recv = await client.post(
f"/api/purchase-orders/{order_id}/receive",
json={"warehouse_id": 1, "items": [{"item_id": item_id, "receive_quantity": 3}]},
)
assert recv.status_code == 400
@pytest.mark.anyio
async def test_purchase_order_update_blocked_after_receive(client):
create_payload = {
"supplier_id": 1,
"items": [{"product_id": 1, "quantity": 2}],
}
resp = await client.post("/api/purchase-orders", json=create_payload)
assert resp.status_code == 201
order_id = resp.json()["id"]
detail = await client.get(f"/api/purchase-orders/{order_id}")
item_id = detail.json()["items"][0]["id"]
recv = await client.post(
f"/api/purchase-orders/{order_id}/receive",
json={"warehouse_id": 1, "items": [{"item_id": item_id, "receive_quantity": 1}]},
)
assert recv.status_code == 200
update_payload = {
"supplier_id": 1,
"items": [{"product_id": 1, "quantity": 2}],
}
upd = await client.put(f"/api/purchase-orders/{order_id}", json=update_payload)
assert upd.status_code == 400
@pytest.mark.anyio
async def test_sales_order_bom_plan_and_auto_issue(client):
create_payload = {
"customer_id": 1,
"items": [{"product_id": 2, "quantity": 1, "unit_price": 1000.0}],
}
resp = await client.post("/api/sales-orders", json=create_payload)
assert resp.status_code == 201
order_id = resp.json()["id"]
plan = await client.get(f"/api/sales-orders/{order_id}/production-plan")
assert plan.status_code == 200
items = plan.json()["items"]
assert len(items) == 1
assert items[0]["material_sku"] == "MAT-001"
assert items[0]["required_quantity"] == 3
assert items[0]["shortage_quantity"] == 0
movements = await client.get("/api/stock-movements", params={"product_id": 1})
assert movements.status_code == 200
assert any(m["movement_type"] == "issue_to_production" for m in movements.json())
@pytest.mark.anyio
async def test_sales_order_create_rejected_when_material_short(client):
inv_list = await client.get("/api/inventory")
inv_id = next(r["inventory_id"] for r in inv_list.json() if r["product_sku"] == "MAT-001")
set_zero = await client.put(
f"/api/inventory/{inv_id}",
json={"quantity": 0, "locked_quantity": 0, "batch_number": None, "location": None},
)
assert set_zero.status_code == 200
create_payload = {
"customer_id": 1,
"items": [{"product_id": 2, "quantity": 1, "unit_price": 1000.0}],
}
resp = await client.post("/api/sales-orders", json=create_payload)
assert resp.status_code == 400
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"customer_id": 1, "items": []},
{"customer_id": 1, "items": [{"product_id": 2, "quantity": 0, "unit_price": 1.0}]},
{"customer_id": 1, "items": [{"product_id": 2, "quantity": 1, "unit_price": -0.01}]},
],
)
async def test_sales_order_schema_validation(payload, client):
resp = await client.post("/api/sales-orders", json=payload)
assert resp.status_code == 422
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"customer_id": 1, "items": [{"product_id": None, "product_sku": None, "product_name": None, "quantity": 1, "unit_price": 1.0}]},
{"customer_id": 1, "items": [{"product_id": 99999, "quantity": 1, "unit_price": 1.0}]},
{"customer_id": 1, "items": [{"product_id": 1, "quantity": 1, "unit_price": 1.0}]},
],
)
async def test_sales_order_item_semantic_validation(payload, client):
resp = await client.post("/api/sales-orders", json=payload)
assert resp.status_code == 400
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"supplier_id": 1, "items": []},
{"supplier_id": 1, "items": [{"product_id": 1, "quantity": 0}]},
{"supplier_id": 99999, "items": [{"product_id": 1, "quantity": 1}]},
{"supplier_id": 1, "items": [{"product_id": 2, "quantity": 1}]},
],
)
async def test_purchase_order_validation(payload, client):
resp = await client.post("/api/purchase-orders", json=payload)
assert resp.status_code in {400, 422}
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"status": "draft"},
{"status": "pending"},
{"status": "received"},
{"status": ""},
],
)
async def test_sales_order_status_enum_rejected(payload, client):
create_payload = {
"customer_id": 1,
"items": [{"product_id": 2, "quantity": 1, "unit_price": 1000.0}],
}
resp = await client.post("/api/sales-orders", json=create_payload)
assert resp.status_code == 201
order_id = resp.json()["id"]
patch = await client.patch(f"/api/sales-orders/{order_id}/status", json=payload)
assert patch.status_code == 400
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"warehouse_id": 1, "items": []},
{"warehouse_id": 1, "items": [{"item_id": 99999, "receive_quantity": 1}]},
{"warehouse_id": 99999, "items": [{"item_id": 1, "receive_quantity": 1}]},
{"warehouse_id": 1, "items": [{"item_id": 1, "receive_quantity": 0}]},
],
)
async def test_purchase_receive_validation(payload, client):
create_payload = {"supplier_id": 1, "items": [{"product_id": 1, "quantity": 1}]}
resp = await client.post("/api/purchase-orders", json=create_payload)
assert resp.status_code == 201
order_id = resp.json()["id"]
detail = await client.get(f"/api/purchase-orders/{order_id}")
assert detail.status_code == 200
real_item_id = detail.json()["items"][0]["id"]
normalized = payload.copy()
if normalized.get("items"):
normalized["items"] = [dict(normalized["items"][0])]
if normalized["items"][0]["item_id"] == 1:
normalized["items"][0]["item_id"] = real_item_id
recv = await client.post(f"/api/purchase-orders/{order_id}/receive", json=normalized)
assert recv.status_code in {400, 404, 422}
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload,expected",
[
({"product_id": 2, "warehouse_id": 1, "quantity": 1, "locked_quantity": 0}, 400),
({"product_id": 1, "warehouse_id": 1, "quantity": -1, "locked_quantity": 0}, 400),
({"product_id": 1, "warehouse_id": 1, "quantity": 1, "locked_quantity": 2}, 400),
({"product_id": 1, "warehouse_id": 99999, "quantity": 1, "locked_quantity": 0}, 404),
],
)
async def test_inventory_create_validation(payload, expected, client):
resp = await client.post("/api/inventory", json=payload)
assert resp.status_code == expected
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload,expected",
[
({"product_id": 1, "warehouse_id": 1, "movement_type": "purchase_in", "quantity": 0}, 400),
({"product_id": 1, "warehouse_id": 1, "movement_type": "issue_to_production", "quantity": 999999}, 400),
({"product_id": 99999, "warehouse_id": 1, "movement_type": "purchase_in", "quantity": 1}, 404),
({"product_id": 1, "warehouse_id": 99999, "movement_type": "purchase_in", "quantity": 1}, 404),
],
)
async def test_stock_movement_create_validation(payload, expected, client):
resp = await client.post("/api/stock-movements", json=payload)
assert resp.status_code == expected
@@ -0,0 +1,78 @@
import pytest
@pytest.mark.anyio
async def test_delivered_sales_order_cannot_be_updated_or_deleted(client):
payload = {
"customer_id": 1,
"delivery_date": None,
"remark": "test",
"items": [
{
"product_id": None,
"product_sku": "MOLD-001",
"product_name": "模具001",
"product_category": "模具",
"product_unit": "套",
"quantity": 1,
"unit_price": 1000.0,
"remark": None,
}
],
}
create_resp = await client.post("/api/sales-orders", json=payload)
assert create_resp.status_code == 201
order = create_resp.json()
order_id = order["id"]
status_resp = await client.patch(f"/api/sales-orders/{order_id}/status", json={"status": "delivered"})
assert status_resp.status_code == 200
update_resp = await client.put(f"/api/sales-orders/{order_id}", json=payload)
assert update_resp.status_code == 400
delete_resp = await client.delete(f"/api/sales-orders/{order_id}")
assert delete_resp.status_code == 400
patch_resp = await client.patch(f"/api/sales-orders/{order_id}/status", json={"status": "paid"})
assert patch_resp.status_code == 400
@pytest.mark.anyio
async def test_non_delivered_sales_order_can_be_updated(client):
payload = {
"customer_id": 1,
"delivery_date": None,
"remark": "test",
"items": [
{
"product_id": None,
"product_sku": "MOLD-002",
"product_name": "模具002",
"product_category": "模具",
"product_unit": "套",
"quantity": 1,
"unit_price": 2000.0,
"remark": None,
}
],
}
create_resp = await client.post("/api/sales-orders", json=payload)
assert create_resp.status_code == 201
order_id = create_resp.json()["id"]
payload2 = {
**payload,
"remark": "changed",
"items": [
{
**payload["items"][0],
"quantity": 2,
}
],
}
update_resp = await client.put(f"/api/sales-orders/{order_id}", json=payload2)
assert update_resp.status_code == 200
assert update_resp.json()["total_amount"] == 4000.0