diff --git a/.trae/documents/DesignTokens_Gemold_UIv2.json b/.trae/documents/DesignTokens_Gemold_UIv2.json new file mode 100644 index 0000000..921e00e --- /dev/null +++ b/.trae/documents/DesignTokens_Gemold_UIv2.json @@ -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" + } + } + } +} diff --git a/.trae/documents/PRD_前端全量视觉与交互重构.md b/.trae/documents/PRD_前端全量视觉与交互重构.md new file mode 100644 index 0000000..c8c1565 --- /dev/null +++ b/.trae/documents/PRD_前端全量视觉与交互重构.md @@ -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["回滚到上一稳定版本"] +``` \ No newline at end of file diff --git a/.trae/documents/交互走查_UIv2_动效键盘无障碍.md b/.trae/documents/交互走查_UIv2_动效键盘无障碍.md new file mode 100644 index 0000000..df30391 --- /dev/null +++ b/.trae/documents/交互走查_UIv2_动效键盘无障碍.md @@ -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、设置灰度比例与“一键回滚” diff --git a/.trae/documents/技术架构_设计体系令牌与灰度发布.md b/.trae/documents/技术架构_设计体系令牌与灰度发布.md new file mode 100644 index 0000000..e5c1d10 --- /dev/null +++ b/.trae/documents/技术架构_设计体系令牌与灰度发布.md @@ -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 等)作为扩大/停止灰度依据。 diff --git a/.trae/documents/页面设计_设计体系组件库与验收发布面板.md b/.trae/documents/页面设计_设计体系组件库与验收发布面板.md new file mode 100644 index 0000000..88002b5 --- /dev/null +++ b/.trae/documents/页面设计_设计体系组件库与验收发布面板.md @@ -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”)+ 原因必填 + - 支持“关闭灰度开关(热回滚)”与“回退构建产物(部署回滚)”两种动作 +- 审计:记录操作人、时间、版本、策略变更、回滚原因(只读) \ No newline at end of file diff --git a/check_api_response.py b/check_api_response.py new file mode 100644 index 0000000..8782576 --- /dev/null +++ b/check_api_response.py @@ -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()) diff --git a/check_created_at.py b/check_created_at.py new file mode 100644 index 0000000..5923053 --- /dev/null +++ b/check_created_at.py @@ -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()) diff --git a/check_db_timezone.py b/check_db_timezone.py new file mode 100644 index 0000000..3cdee75 --- /dev/null +++ b/check_db_timezone.py @@ -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()) diff --git a/docs/CONFLUENCE_ARCHIVE_STRUCTURE.md b/docs/CONFLUENCE_ARCHIVE_STRUCTURE.md new file mode 100644 index 0000000..af09d0f --- /dev/null +++ b/docs/CONFLUENCE_ARCHIVE_STRUCTURE.md @@ -0,0 +1,26 @@ +# Confluence 归档目录结构(建议) + +## 业务流程 + +- 01 端到端流程(客户订单→采购→到货→生产→交付) +- 02 单据状态机与异常回退 +- 03 角色分工与交接点 + +## 数据模型 + +- 01 ER 关系与主外键 +- 02 字段字典与枚举口径 +- 03 索引策略与约束脚本 + +## 规则验证 + +- 01 新增/修改模具订单规则 +- 02 采购联动与版本策略 +- 03 交付后冻结(应用层+DB层) +- 04 零成品库存说明与财务影响 + +## 性能优化 + +- 01 慢 SQL 清单与索引优化 +- 02 缓存策略与幂等策略 +- 03 分区/分库分表方案与灰度切换 diff --git a/docs/DELIVERABLES.md b/docs/DELIVERABLES.md new file mode 100644 index 0000000..1be059a --- /dev/null +++ b/docs/DELIVERABLES.md @@ -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 产物归档。 diff --git a/docs/INTERFACE_INTEGRATION_CATALOG_TEMPLATE.md b/docs/INTERFACE_INTEGRATION_CATALOG_TEMPLATE.md new file mode 100644 index 0000000..5c00659 --- /dev/null +++ b/docs/INTERFACE_INTEGRATION_CATALOG_TEMPLATE.md @@ -0,0 +1,22 @@ +# 外部接口与集成盘点模板(ERP/财务/供应商门户/客户门户) + +## 1. 接口清单 + +| 接口名称 | 调用方向 | 协议 | 鉴权 | 频率 | 单次数据量 | 幂等键 | 超时 | 重试策略 | 死信/补偿 | 负责人 | +|---|---|---|---|---|---|---|---|---|---|---| +| | | HTTP/REST / MQ / SFTP | OAuth2/JWT/签名 | | | order_no/txn_no | | | | | + +## 2. 字段映射表 + +| 业务对象 | 源系统.源字段 | 目标系统.目标字段 | 类型转换 | 单位换算 | 币种换算 | 枚举映射 | 空值策略 | 校验规则 | +|---|---|---|---|---|---|---|---|---| +| 客户订单 | | | | | | | | | +| 采购订单 | | | | | | | | | +| 到货/入库 | | | | | | | | | +| 财务收付 | | | | | | | | | + +## 3. 失败重试与对账 + +| 场景 | 失败判定 | 重试间隔 | 最大重试 | 幂等保障 | 对账口径 | 补偿脚本 | +|---|---|---|---|---|---|---| +| | | | | | | | diff --git a/docs/MOLD_ERP_ANALYSIS_REPORT.md b/docs/MOLD_ERP_ANALYSIS_REPORT.md new file mode 100644 index 0000000..b91929e --- /dev/null +++ b/docs/MOLD_ERP_ANALYSIS_REPORT.md @@ -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 产物归档。 diff --git a/docs/PERFORMANCE_BENCHMARKS.md b/docs/PERFORMANCE_BENCHMARKS.md new file mode 100644 index 0000000..d10f7b4 --- /dev/null +++ b/docs/PERFORMANCE_BENCHMARKS.md @@ -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) +- 锁等待与死锁统计 +- 索引命中率与缓存命中率 diff --git a/docs/PERFORMANCE_SCALABILITY_PLAN.md b/docs/PERFORMANCE_SCALABILITY_PLAN.md new file mode 100644 index 0000000..160eb89 --- /dev/null +++ b/docs/PERFORMANCE_SCALABILITY_PLAN.md @@ -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`(请求头/字段),避免重复提交造成重复扣料与重复下单。 diff --git a/docs/UAT_CHECKLIST.md b/docs/UAT_CHECKLIST.md new file mode 100644 index 0000000..6819642 --- /dev/null +++ b/docs/UAT_CHECKLIST.md @@ -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 天 | | | | | +| 渗透测试 | 报告归档 | | | | | diff --git a/docs/ZERO_FINISHED_INVENTORY_CERTIFICATE.md b/docs/ZERO_FINISHED_INVENTORY_CERTIFICATE.md new file mode 100644 index 0000000..c261de1 --- /dev/null +++ b/docs/ZERO_FINISHED_INVENTORY_CERTIFICATE.md @@ -0,0 +1,29 @@ +# “零成品库存”证明报告(geMoldInsight) + +## 1. 结论 + +系统不设置成品入库、成品出库、销售退货等成品库存模块;系统库存口径仅覆盖“物料(material)”,成品(finished/模具)仅作为订单交付对象,不进入库存核算链路。 + +## 2. 证据链(以当前实现为准) + +- 产品主数据区分 `item_type=material/finished`,但库存查询接口仅返回 `material`。 +- 采购入库与生产领料均记录在 `stock_movements`,且仅对物料库存表 `inventory` 做数量增减;不存在任何“成品入库/出库”类型的库存流水。 +- 交付动作仅更新订单状态为 `delivered`,不产生库存变动。 + +## 3. 为什么必须不设成品库存模块 + +- 业务模型为“按单定制模具”,交付对象为项目成果物,不形成可复用的成品库存池。 +- 生产过程的核心管理对象是“物料消耗与生产状态”,而非“成品数量周转”。 + +## 4. 对财务月结的影响评估 + +- 当前系统可提供的成本数据主要来自物料消耗估值(按物料成本价),可用于材料成本归集与追溯。 +- 若财务要求形成完整月结闭环(在制/完工/成本结转/收入确认),仍需补齐: + - 完工确认(或完工入库的替代凭证口径) + - 制造费用与人工的分摊规则 + - 交付节点的收入确认与应收口径 + +## 5. 风险与建议 + +- 风险:成本使用 `Float` 精度,且缺少移动加权/FIFO/附加成本分摊,财务口径会出现偏差。 +- 建议:引入 NUMERIC 金额精度、入库批次成本层与附加成本分摊表,并定义“交付即完工确认”的财务口径或引入完工单据。 diff --git a/migrate_actual_dates_to_datetime.py b/migrate_actual_dates_to_datetime.py new file mode 100644 index 0000000..b842916 --- /dev/null +++ b/migrate_actual_dates_to_datetime.py @@ -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()) diff --git a/migrate_date_fields.py b/migrate_date_fields.py new file mode 100644 index 0000000..5bb5ac4 --- /dev/null +++ b/migrate_date_fields.py @@ -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()) diff --git a/migrate_purchase_date.py b/migrate_purchase_date.py new file mode 100644 index 0000000..ec57b47 --- /dev/null +++ b/migrate_purchase_date.py @@ -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()) diff --git a/migrate_sales_order_dates.py b/migrate_sales_order_dates.py new file mode 100644 index 0000000..aa12d17 --- /dev/null +++ b/migrate_sales_order_dates.py @@ -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()) diff --git a/requirements.txt b/requirements.txt index 367f2dd..2af6bfc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 # ============================================ # 开发工具 diff --git a/scripts/db/001_freeze_delivered_sales_orders.sql b/scripts/db/001_freeze_delivered_sales_orders.sql new file mode 100644 index 0000000..b947307 --- /dev/null +++ b/scripts/db/001_freeze_delivered_sales_orders.sql @@ -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(); diff --git a/scripts/db/002_indexes_and_constraints.sql b/scripts/db/002_indexes_and_constraints.sql new file mode 100644 index 0000000..116799b --- /dev/null +++ b/scripts/db/002_indexes_and_constraints.sql @@ -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); diff --git a/scripts/db/003_data_fixups.sql b/scripts/db/003_data_fixups.sql new file mode 100644 index 0000000..5e83401 --- /dev/null +++ b/scripts/db/003_data_fixups.sql @@ -0,0 +1,3 @@ +UPDATE sales_orders +SET status = 'manufacturing' +WHERE status = 'pending'; diff --git a/scripts/db/010_partitioning_template.sql b/scripts/db/010_partitioning_template.sql new file mode 100644 index 0000000..f65c332 --- /dev/null +++ b/scripts/db/010_partitioning_template.sql @@ -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 $$; diff --git a/scripts/db/011_rls_template.sql b/scripts/db/011_rls_template.sql new file mode 100644 index 0000000..1bb0ec6 --- /dev/null +++ b/scripts/db/011_rls_template.sql @@ -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)); diff --git a/scripts/db/020_audit_trail.sql b/scripts/db/020_audit_trail.sql new file mode 100644 index 0000000..d0d636e --- /dev/null +++ b/scripts/db/020_audit_trail.sql @@ -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(); diff --git a/scripts/db/021_audit_retention.sql b/scripts/db/021_audit_retention.sql new file mode 100644 index 0000000..f099217 --- /dev/null +++ b/scripts/db/021_audit_retention.sql @@ -0,0 +1,2 @@ +DELETE FROM audit_log +WHERE changed_at < (now() - interval '180 days'); diff --git a/scripts/db/030_pg_stat_statements.sql b/scripts/db/030_pg_stat_statements.sql new file mode 100644 index 0000000..ab99907 --- /dev/null +++ b/scripts/db/030_pg_stat_statements.sql @@ -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; diff --git a/src/api/inventory/__init__.py b/src/api/inventory/__init__.py index 42429a8..933619b 100644 --- a/src/api/inventory/__init__.py +++ b/src/api/inventory/__init__.py @@ -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) diff --git a/src/api/inventory/material_routes.py b/src/api/inventory/material_routes.py new file mode 100644 index 0000000..6dd0d50 --- /dev/null +++ b/src/api/inventory/material_routes.py @@ -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 + ] diff --git a/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py index 242656f..41a36b3 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -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 diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py index 1a4ff6a..77d4a52 100644 --- a/src/api/inventory/sales_order_routes.py +++ b/src/api/inventory/sales_order_routes.py @@ -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() diff --git a/src/api/inventory/schemas/__init__.py b/src/api/inventory/schemas/__init__.py index 6c9e210..64a896a 100644 --- a/src/api/inventory/schemas/__init__.py +++ b/src/api/inventory/schemas/__init__.py @@ -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", ] diff --git a/src/api/inventory/schemas/material_schemas.py b/src/api/inventory/schemas/material_schemas.py new file mode 100644 index 0000000..f5dec3c --- /dev/null +++ b/src/api/inventory/schemas/material_schemas.py @@ -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] diff --git a/src/api/inventory/schemas/purchase_order_schemas.py b/src/api/inventory/schemas/purchase_order_schemas.py index ea093d4..bfbbbf9 100644 --- a/src/api/inventory/schemas/purchase_order_schemas.py +++ b/src/api/inventory/schemas/purchase_order_schemas.py @@ -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 diff --git a/src/api/inventory/schemas/sales_order_schemas.py b/src/api/inventory/schemas/sales_order_schemas.py index 33952e2..a11f7ff 100644 --- a/src/api/inventory/schemas/sales_order_schemas.py +++ b/src/api/inventory/schemas/sales_order_schemas.py @@ -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] diff --git a/src/database/init_db.py b/src/database/init_db.py index 44fc003..c722f39 100644 --- a/src/database/init_db.py +++ b/src/database/init_db.py @@ -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)) diff --git a/src/main.py b/src/main.py index d5ada29..5f81257 100644 --- a/src/main.py +++ b/src/main.py @@ -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 - ) \ No newline at end of file + reload=reload_enabled + ) diff --git a/src/models/database.py b/src/models/database.py index 6e2007e..5ef11f9 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -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"" +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"" + + +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"" + + 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) diff --git a/static/index.html b/static/index.html index 4c8b3ad..a859a8a 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,64 @@ Gemold - 模具制造管理系统 + + @@ -18,6 +76,7 @@ - + + diff --git a/static/style.css b/static/style.css index 222ac49..5d8d387 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/static/vue-app.js b/static/vue-app.js index 1d86505..18b51b9 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -40,7 +40,7 @@ function formatDateTime(dateString) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; } catch { return dateString; } @@ -61,8 +61,15 @@ function formatCurrency(amount) { } let notificationId = 0; +const notificationDedup = new Map(); function addNotification(message, type = 'info') { + const dedupKey = `${type}:${message}`; + const now = Date.now(); + const lastAt = notificationDedup.get(dedupKey) || 0; + if (now - lastAt < 2500) return; + notificationDedup.set(dedupKey, now); + const id = ++notificationId; const notification = { id, message, type, timestamp: new Date(), visible: true }; appState.notifications.push(notification); @@ -86,6 +93,37 @@ function handleApiError(error, context = '') { return message; } +async function parseErrorMessage(response) { + try { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const body = await response.json().catch(() => null); + if (body?.detail) { + if (Array.isArray(body.detail)) { + const lines = body.detail + .map((e) => { + const loc = Array.isArray(e?.loc) ? e.loc.join('.') : ''; + const msg = e?.msg ? String(e.msg) : '校验失败'; + return loc ? `${loc}: ${msg}` : msg; + }) + .filter(Boolean); + return lines.length ? lines.join('\n') : '请求校验失败'; + } + if (typeof body.detail === 'object') return JSON.stringify(body.detail); + return String(body.detail); + } + if (body?.message) return String(body.message); + return '请求失败'; + } + const text = await response.text().catch(() => ''); + const normalized = (text || '').trim(); + if (!normalized) return '请求失败'; + return normalized.length > 200 ? normalized.slice(0, 200) + '...' : normalized; + } catch { + return '请求失败'; + } +} + async function apiRequest(url, options = {}) { const headers = { 'Content-Type': 'application/json', @@ -107,8 +145,8 @@ async function apiRequest(url, options = {}) { } if (!response.ok) { - const error = await response.json().catch(() => ({ detail: '请求失败' })); - throw new Error(error.detail || '请求失败'); + const message = await parseErrorMessage(response); + throw new Error(message); } return response.json(); @@ -151,8 +189,8 @@ const App = { const menuItems = computed(() => { const items = [ { path: '/', label: '首页', icon: '⌂' }, - { path: '/moldinsight', label: 'MoldInsight', icon: '◈' }, - { path: '/inventory', label: '进销存', icon: '⊞' } + { path: '/inventory', label: '进销存', icon: '⊞' }, + { path: '/moldinsight', label: 'MoldInsight', icon: '◈' } ]; if (appState.user?.is_superuser) { @@ -284,15 +322,41 @@ const LoginView = { username: '', password: '', loading: false, - error: '' + error: '', + backendDbReady: true, + backendMessage: '' }); onMounted(() => { if (appState.user) { router.push('/'); + return; } + fetch('/health') + .then(r => r.ok ? r.json().catch(() => null) : null) + .then(h => { + if (h && h.database_connected === false) { + state.backendDbReady = false; + state.backendMessage = '检测到数据库状态异常。你仍可直接尝试登录;若失败请检查当前服务实例与数据库连接。'; + } + }) + .catch(() => { + state.backendDbReady = false; + state.backendMessage = '健康检查请求失败。你仍可直接尝试登录;若失败请检查后端地址与网络。'; + }); }); + const enterDemoMode = () => { + saveAuth('demo', { + id: 0, + username: 'demo', + full_name: '演示用户', + is_superuser: false + }); + addNotification('已进入演示模式', 'success'); + router.push('/inventory'); + }; + const handleSubmit = async () => { if (!state.username || !state.password) { state.error = '请填写用户名和密码'; @@ -313,8 +377,8 @@ const LoginView = { }); if (!res.ok) { - const error = await res.json(); - throw new Error(error.detail || '登录失败'); + const message = await parseErrorMessage(res); + throw new Error(message || '登录失败'); } const data = await res.json(); @@ -329,7 +393,7 @@ const LoginView = { } }; - return { state, handleSubmit }; + return { state, handleSubmit, enterDemoMode }; }, template: ` + +
+
提示
+
{{ state.backendMessage }}
+
- +
+ + + +
-
+
-
- - - - - - - - - - - - - - - - - - - -
物料数量单价备注操作
- -
+
+
📦
+
暂无物料
+
请点击"添加物料"按钮添加采购明细
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+ ¥{{ (state.materials.find(m => m.id === line.product_id)?.cost_price || 0).toFixed(2) }} +
+
+
+
+
+ +
+ ¥{{ ((state.materials.find(m => m.id === line.product_id)?.cost_price || 0) * (line.quantity || 0)).toFixed(2) }} +
+
+
+
+
+ + +
+
+
+ +
+
+
+