init
This commit is contained in:
@@ -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”)+ 原因必填
|
||||
- 支持“关闭灰度开关(热回滚)”与“回退构建产物(部署回滚)”两种动作
|
||||
- 审计:记录操作人、时间、版本、策略变更、回滚原因(只读)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,26 @@
|
||||
# Confluence 归档目录结构(建议)
|
||||
|
||||
## 业务流程
|
||||
|
||||
- 01 端到端流程(客户订单→采购→到货→生产→交付)
|
||||
- 02 单据状态机与异常回退
|
||||
- 03 角色分工与交接点
|
||||
|
||||
## 数据模型
|
||||
|
||||
- 01 ER 关系与主外键
|
||||
- 02 字段字典与枚举口径
|
||||
- 03 索引策略与约束脚本
|
||||
|
||||
## 规则验证
|
||||
|
||||
- 01 新增/修改模具订单规则
|
||||
- 02 采购联动与版本策略
|
||||
- 03 交付后冻结(应用层+DB层)
|
||||
- 04 零成品库存说明与财务影响
|
||||
|
||||
## 性能优化
|
||||
|
||||
- 01 慢 SQL 清单与索引优化
|
||||
- 02 缓存策略与幂等策略
|
||||
- 03 分区/分库分表方案与灰度切换
|
||||
@@ -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. 失败重试与对账
|
||||
|
||||
| 场景 | 失败判定 | 重试间隔 | 最大重试 | 幂等保障 | 对账口径 | 补偿脚本 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| | | | | | | |
|
||||
@@ -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 产物归档。
|
||||
@@ -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)
|
||||
- 锁等待与死锁统计
|
||||
- 索引命中率与缓存命中率
|
||||
@@ -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`(请求头/字段),避免重复提交造成重复扣料与重复下单。
|
||||
@@ -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 金额精度、入库批次成本层与附加成本分摊表,并定义“交付即完工确认”的财务口径或引入完工单据。
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE sales_orders
|
||||
SET status = 'manufacturing'
|
||||
WHERE status = 'pending';
|
||||
@@ -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 $$;
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
@@ -0,0 +1,2 @@
|
||||
DELETE FROM audit_log
|
||||
WHERE changed_at < (now() - interval '180 days');
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user