Compare commits
16 Commits
optimizati
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dbb5fa70 | ||
|
|
705ecf1e5a | ||
|
|
b024b061e9 | ||
|
|
867fc3beee | ||
|
|
9b60aa3e0d | ||
|
|
30fb7af159 | ||
|
|
6d87a2e1cd | ||
|
|
707db3a482 | ||
|
|
c424633030 | ||
|
|
eb0f2eff95 | ||
|
|
5ec8a98125 | ||
|
|
df9a4b485d | ||
|
|
453b19399b | ||
|
|
fc39d81564 | ||
|
|
a00e817065 | ||
|
|
06c7b800fc |
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,7 +7,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.impeccable/
|
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.log
|
*.log
|
||||||
outputs/
|
outputs/
|
||||||
|
|||||||
162
.impeccable/design.json
Normal file
162
.impeccable/design.json
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"generatedAt": "2026-06-12T00:00:00+08:00",
|
||||||
|
"title": "Design System: ForgeFlow ERP",
|
||||||
|
"extensions": {
|
||||||
|
"colorMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "ERP Canvas",
|
||||||
|
"canonical": "#f0f2f5",
|
||||||
|
"tonalRamp": ["#ffffff", "#f5f7fb", "#f0f2f5", "#e9edf3"]
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Work Surface",
|
||||||
|
"canonical": "#ffffff",
|
||||||
|
"tonalRamp": ["#ffffff", "#fbfdff", "#f8fafc"]
|
||||||
|
},
|
||||||
|
"primary": {
|
||||||
|
"role": "primary",
|
||||||
|
"displayName": "Operational Blue",
|
||||||
|
"canonical": "#409eff",
|
||||||
|
"tonalRamp": ["#ecf5ff", "#d9ecff", "#409eff", "#2563eb"]
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"role": "semantic",
|
||||||
|
"displayName": "Pass Green",
|
||||||
|
"canonical": "#67c23a",
|
||||||
|
"tonalRamp": ["#f0f9eb", "#e1f3d8", "#67c23a"]
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"role": "semantic",
|
||||||
|
"displayName": "Review Amber",
|
||||||
|
"canonical": "#e6a23c",
|
||||||
|
"tonalRamp": ["#fdf6ec", "#faecd8", "#e6a23c"]
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"role": "semantic",
|
||||||
|
"displayName": "Stop Red",
|
||||||
|
"canonical": "#f56c6c",
|
||||||
|
"tonalRamp": ["#fef0f0", "#fde2e2", "#f56c6c"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typographyMeta": {
|
||||||
|
"title": {
|
||||||
|
"displayName": "Title",
|
||||||
|
"purpose": "Panel and drawer titles inside authenticated product screens."
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"displayName": "Body",
|
||||||
|
"purpose": "Chinese-first operational UI text, descriptions, and table cells."
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"displayName": "Label",
|
||||||
|
"purpose": "Field labels, chips, table headers, and compact metadata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shadows": [
|
||||||
|
{
|
||||||
|
"name": "low-surface",
|
||||||
|
"value": "0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06)",
|
||||||
|
"purpose": "Tables, compact repeated cards, and low emphasis containers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "panel-surface",
|
||||||
|
"value": "0 12px 28px rgba(31, 45, 61, 0.12), 0 3px 8px rgba(31, 45, 61, 0.08)",
|
||||||
|
"purpose": "Main panels and large workflow containers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "floating-surface",
|
||||||
|
"value": "0 20px 46px rgba(31, 45, 61, 0.16), 0 8px 18px rgba(31, 45, 61, 0.1)",
|
||||||
|
"purpose": "Drawers, dropdowns, popovers, and overlays."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"motion": [
|
||||||
|
{
|
||||||
|
"name": "fast-state",
|
||||||
|
"value": "140ms ease",
|
||||||
|
"purpose": "Small button, link, and row hover feedback."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "normal-state",
|
||||||
|
"value": "220ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
"purpose": "Drawer and panel state transitions."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"breakpoints": [
|
||||||
|
{
|
||||||
|
"name": "tablet",
|
||||||
|
"value": "860px"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop-collapse",
|
||||||
|
"value": "1180px"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"name": "Primary Button",
|
||||||
|
"kind": "button",
|
||||||
|
"refersTo": "button-primary",
|
||||||
|
"description": "Main action button for saving, creating, and confirming high-value ERP actions.",
|
||||||
|
"html": "<button class=\"ds-btn-primary\">Save document</button>",
|
||||||
|
"css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 14px; border: 1px solid #409eff; border-radius: 12px; background: #409eff; color: #ffffff; font: 700 13px/1 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; cursor: pointer; transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } .ds-btn-primary:hover { background: #2563eb; border-color: #2563eb; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Status Badge",
|
||||||
|
"kind": "chip",
|
||||||
|
"refersTo": "status-primary",
|
||||||
|
"description": "Compact semantic status label used across tables and detail drawers.",
|
||||||
|
"html": "<span class=\"ds-status\"><span></span>Approved</span>",
|
||||||
|
"css": ".ds-status { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 2px 8px; border: 1px solid #d9ecff; border-radius: 4px; background: #ecf5ff; color: #409eff; font: 600 12px/1.45 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; white-space: nowrap; } .ds-status span { width: 6px; height: 6px; border-radius: 999px; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Data Table Container",
|
||||||
|
"kind": "custom",
|
||||||
|
"refersTo": "table-container",
|
||||||
|
"description": "Scrollable table frame with sticky headers for dense ERP records.",
|
||||||
|
"html": "<div class=\"ds-table\"><table><thead><tr><th>Document</th><th>Status</th></tr></thead><tbody><tr><td>PO-001</td><td>Pending</td></tr></tbody></table></div>",
|
||||||
|
"css": ".ds-table { overflow: auto; border: 1px solid rgba(209, 218, 232, 0.96); border-radius: 16px; background: #ffffff; box-shadow: 0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06); } .ds-table table { width: 100%; border-collapse: separate; border-spacing: 0; font: 14px/1.45 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; } .ds-table th { position: sticky; top: 0; background: #f8fbff; color: #334155; font-size: 12px; font-weight: 750; text-align: left; } .ds-table th, .ds-table td { padding: 12px; border-bottom: 1px solid #ebeef5; } .ds-table td { color: #42526b; }"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"narrative": {
|
||||||
|
"northStar": "The Clear Production Desk",
|
||||||
|
"overview": "ForgeFlow ERP is a clear, light, high-density operational interface for a hardware manufacturing company. It should help users trace orders, materials, batches, operations, documents, and costs without visual noise.",
|
||||||
|
"keyCharacteristics": [
|
||||||
|
"Light routine ERP surfaces",
|
||||||
|
"Restrained blue action and selection language",
|
||||||
|
"Chinese-first practical product typography",
|
||||||
|
"Tables, drawers, forms, and status badges as core vocabulary",
|
||||||
|
"Formal document visuals only for business documents"
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "The Blue Rarity Rule",
|
||||||
|
"body": "Blue identifies current position and next action; it is not broad decoration.",
|
||||||
|
"section": "colors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Fixed Scale Rule",
|
||||||
|
"body": "Product screens use fixed UI type sizes, not fluid hero headings.",
|
||||||
|
"section": "typography"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The One Depth Signal Rule",
|
||||||
|
"body": "Do not stack thick borders, large radius, glossy gradients, and heavy shadow on one element.",
|
||||||
|
"section": "elevation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dos": [
|
||||||
|
"Do keep routine ERP screens light, restrained, and table-first.",
|
||||||
|
"Do standardize table, drawer, form, status badge, pagination, and action button behavior before styling individual pages.",
|
||||||
|
"Do provide loading, empty, error, no-permission, disabled, saving, and archive-failed states for critical flows."
|
||||||
|
],
|
||||||
|
"donts": [
|
||||||
|
"Don't make screens look obviously AI-generated through generic card grids, decorative gradients, glass effects, or ornamental animation.",
|
||||||
|
"Don't use side accent borders greater than 1px on cards or callouts.",
|
||||||
|
"Don't let old dark industrial styling bleed into everyday ERP work screens."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.impeccable/live/config.json
Normal file
6
.impeccable/live/config.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": ["frontend/index.html"],
|
||||||
|
"insertBefore": "</body>",
|
||||||
|
"commentSyntax": "html",
|
||||||
|
"cspChecked": true
|
||||||
|
}
|
||||||
BIN
.impeccable/live/forgeflow-smoke-login.png
Normal file
BIN
.impeccable/live/forgeflow-smoke-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
229
DESIGN.md
229
DESIGN.md
@ -1,229 +0,0 @@
|
|||||||
---
|
|
||||||
name: ForgeFlow ERP
|
|
||||||
description: A clear operational ERP interface for hardware manufacturing workflows.
|
|
||||||
colors:
|
|
||||||
canvas: "#f0f2f5"
|
|
||||||
canvas-soft: "#f5f7fb"
|
|
||||||
surface: "#ffffff"
|
|
||||||
surface-subtle: "#fbfdff"
|
|
||||||
text: "#303133"
|
|
||||||
text-strong: "#15202b"
|
|
||||||
text-soft: "#606266"
|
|
||||||
text-muted: "#909399"
|
|
||||||
line: "#ebeef5"
|
|
||||||
line-strong: "#d9e1ec"
|
|
||||||
primary: "#409eff"
|
|
||||||
primary-deep: "#2563eb"
|
|
||||||
primary-soft: "#ecf5ff"
|
|
||||||
info: "#0ea5e9"
|
|
||||||
success: "#67c23a"
|
|
||||||
warning: "#e6a23c"
|
|
||||||
orange: "#d97706"
|
|
||||||
danger: "#f56c6c"
|
|
||||||
typography:
|
|
||||||
title:
|
|
||||||
fontFamily: "Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif"
|
|
||||||
fontSize: "18px"
|
|
||||||
fontWeight: 700
|
|
||||||
lineHeight: 1.35
|
|
||||||
letterSpacing: "0"
|
|
||||||
body:
|
|
||||||
fontFamily: "Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif"
|
|
||||||
fontSize: "14px"
|
|
||||||
fontWeight: 400
|
|
||||||
lineHeight: 1.55
|
|
||||||
letterSpacing: "0"
|
|
||||||
label:
|
|
||||||
fontFamily: "Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif"
|
|
||||||
fontSize: "12px"
|
|
||||||
fontWeight: 600
|
|
||||||
lineHeight: 1.3
|
|
||||||
letterSpacing: "0"
|
|
||||||
rounded:
|
|
||||||
xs: "4px"
|
|
||||||
sm: "8px"
|
|
||||||
md: "10px"
|
|
||||||
lg: "12px"
|
|
||||||
panel: "14px"
|
|
||||||
spacing:
|
|
||||||
xs: "6px"
|
|
||||||
sm: "8px"
|
|
||||||
md: "12px"
|
|
||||||
lg: "16px"
|
|
||||||
xl: "24px"
|
|
||||||
components:
|
|
||||||
button-primary:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.surface}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "0 14px"
|
|
||||||
height: "38px"
|
|
||||||
button-secondary:
|
|
||||||
backgroundColor: "{colors.surface}"
|
|
||||||
textColor: "{colors.text-soft}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: "0 14px"
|
|
||||||
height: "38px"
|
|
||||||
status-success:
|
|
||||||
backgroundColor: "#f0f9eb"
|
|
||||||
textColor: "{colors.success}"
|
|
||||||
rounded: "{rounded.xs}"
|
|
||||||
padding: "2px 8px"
|
|
||||||
status-primary:
|
|
||||||
backgroundColor: "{colors.primary-soft}"
|
|
||||||
textColor: "{colors.primary}"
|
|
||||||
rounded: "{rounded.xs}"
|
|
||||||
padding: "2px 8px"
|
|
||||||
table-container:
|
|
||||||
backgroundColor: "{colors.surface}"
|
|
||||||
textColor: "{colors.text-soft}"
|
|
||||||
rounded: "16px"
|
|
||||||
padding: "0"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Design System: ForgeFlow ERP
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
**Creative North Star: "The Clear Production Desk"**
|
|
||||||
|
|
||||||
ForgeFlow ERP should feel like a clean production desk in a busy hardware factory: the work is dense, the stakes are real, and the interface keeps records, batches, orders, and responsibilities easy to read. The system is product-first, so familiarity is a feature. It should look mature enough for daily operational use, not like a marketing page or a visual experiment.
|
|
||||||
|
|
||||||
The current codebase contains two visual histories: an older dark industrial theme and a newer light admin theme. The light admin theme is the direction to preserve for routine ERP screens. Dark, animated, highly decorative industrial styling may remain on the login screen or special showcase surfaces, but it should not define ordinary data-entry, table, and workflow pages.
|
|
||||||
|
|
||||||
**Key Characteristics:**
|
|
||||||
|
|
||||||
- Light, calm, high-density operational surfaces.
|
|
||||||
- Blue primary actions used sparingly for current selection and key commands.
|
|
||||||
- Chinese-first typography using a practical system sans stack.
|
|
||||||
- Table, drawer, form, pagination, and status components as the core vocabulary.
|
|
||||||
- Formal paper-like styling only for archived business documents and document entry flows.
|
|
||||||
|
|
||||||
## 2. Colors
|
|
||||||
|
|
||||||
The palette is a restrained light ERP system: neutral surfaces carry most of the product, with blue reserved for action, selection, and traceability.
|
|
||||||
|
|
||||||
### Primary
|
|
||||||
- **Operational Blue** (#409eff): current admin accent for active pagination, selected states, focus, and primary actions.
|
|
||||||
- **Trace Blue** (#2563eb): stronger blue already present in `--ui-steel`; use for focus rings, high-emphasis primary actions, and cross-record traceability.
|
|
||||||
|
|
||||||
### Secondary
|
|
||||||
- **Process Cyan** (#0ea5e9): supporting process and information accents; do not use as broad decoration.
|
|
||||||
- **Pass Green** (#67c23a): success and completed states.
|
|
||||||
- **Review Amber** (#e6a23c): warning, pending review, and attention states.
|
|
||||||
- **Stop Red** (#f56c6c): destructive, failed, or blocked states.
|
|
||||||
|
|
||||||
### Neutral
|
|
||||||
- **ERP Canvas** (#f0f2f5): app shell background.
|
|
||||||
- **Work Surface** (#ffffff): panels, forms, tables, and drawer bodies.
|
|
||||||
- **Soft Surface** (#fbfdff): subtle table and card gradients.
|
|
||||||
- **Main Ink** (#303133): default text.
|
|
||||||
- **Soft Ink** (#606266): secondary text and metadata.
|
|
||||||
- **Muted Ink** (#909399): placeholders and disabled text; verify contrast before using on tinted backgrounds.
|
|
||||||
- **Hairline** (#ebeef5): table and form dividers.
|
|
||||||
|
|
||||||
### Named Rules
|
|
||||||
|
|
||||||
**The Blue Rarity Rule.** Blue should identify where the user is and what they can do next. It should not be used as a decorative wash across every panel.
|
|
||||||
|
|
||||||
**The Light Admin Rule.** Routine ERP screens use light surfaces. Dark industrial styling is reserved for login or explicitly staged visual moments.
|
|
||||||
|
|
||||||
## 3. Typography
|
|
||||||
|
|
||||||
**Display Font:** The product should avoid a separate display face inside authenticated screens.
|
|
||||||
**Body Font:** `Helvetica Neue`, Helvetica, `PingFang SC`, `Hiragino Sans GB`, `Microsoft YaHei`, Arial, sans-serif.
|
|
||||||
**Label/Mono Font:** No separate mono font is established; codes and batch numbers use the body stack unless a future token is introduced.
|
|
||||||
|
|
||||||
**Character:** Practical Chinese UI typography with a tight product scale. The interface should feel easy to scan in tables and drawers, not editorial or poster-like.
|
|
||||||
|
|
||||||
### Hierarchy
|
|
||||||
- **Title** (700, 18px, 1.35): panel titles, drawer titles, key page headers.
|
|
||||||
- **Section Title** (700, 15-16px, 1.35): form sections, table group headings, workflow blocks.
|
|
||||||
- **Body** (400-500, 14px, 1.55): operational copy, descriptions, table cells.
|
|
||||||
- **Table Header** (750, 12px, 1.25): sticky table headers and sortable labels.
|
|
||||||
- **Label** (600, 12-13px, 1.3): field labels, tags, chips, small controls.
|
|
||||||
|
|
||||||
### Named Rules
|
|
||||||
|
|
||||||
**The Fixed Scale Rule.** Product screens use fixed rem or px sizes, not fluid hero typography. Large `clamp()` headings are only acceptable on intentionally staged login or overview pages.
|
|
||||||
|
|
||||||
**The No Display Labels Rule.** Buttons, labels, chips, table headers, and drawer metadata use the product sans stack only.
|
|
||||||
|
|
||||||
## 4. Elevation
|
|
||||||
|
|
||||||
The current system uses a hybrid of tonal layering, borders, and shadows. The desired direction is flatter by default: panels and tables should rely on background contrast, dividers, and light shadows. High shadows are reserved for drawers, popovers, dropdowns, and active overlays.
|
|
||||||
|
|
||||||
### Shadow Vocabulary
|
|
||||||
- **Low Surface** (`0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06)`): tables, small cards, summary blocks.
|
|
||||||
- **Panel Surface** (`0 12px 28px rgba(31, 45, 61, 0.12), 0 3px 8px rgba(31, 45, 61, 0.08)`): main panels and large cards.
|
|
||||||
- **Floating Surface** (`0 20px 46px rgba(31, 45, 61, 0.16), 0 8px 18px rgba(31, 45, 61, 0.1)`): drawers, popovers, menus, and temporary overlays.
|
|
||||||
- **Focus Ring** (`0 0 0 3px rgba(37, 99, 235, 0.18)`): keyboard focus and active form controls.
|
|
||||||
|
|
||||||
### Named Rules
|
|
||||||
|
|
||||||
**The One Depth Signal Rule.** Do not pair thick borders, large radius, glossy gradients, and heavy shadow on the same element. Pick the depth signal that best explains the state.
|
|
||||||
|
|
||||||
## 5. Components
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
- **Shape:** 10-12px for normal buttons; full pill only for tags and compact chips.
|
|
||||||
- **Primary:** solid blue or restrained blue treatment, 38px default height, 8px icon/text gap.
|
|
||||||
- **Hover / Focus:** small state feedback only. Avoid decorative gradient sweeps and heavy lift.
|
|
||||||
- **Secondary / Ghost:** white or subtle neutral surface with border, no large shadow.
|
|
||||||
- **Danger:** red text or soft red background; reserve filled red for destructive confirmation.
|
|
||||||
|
|
||||||
### Chips
|
|
||||||
- **Style:** compact pill tags for metadata and statuses, with neutral or semantic tint.
|
|
||||||
- **State:** selected chips may use blue text and soft blue background. Inactive chips should stay neutral.
|
|
||||||
|
|
||||||
### Cards / Containers
|
|
||||||
- **Corner Style:** 12-14px for panels and repeated cards.
|
|
||||||
- **Background:** white to soft white gradients are acceptable when subtle.
|
|
||||||
- **Shadow Strategy:** low or panel surface only; avoid decorative ghost-card combinations.
|
|
||||||
- **Border:** 1px neutral border. Avoid colored side stripes.
|
|
||||||
- **Internal Padding:** 16-24px depending on density.
|
|
||||||
|
|
||||||
### Inputs / Fields
|
|
||||||
- **Style:** white or very light background, 1px neutral border, 10-12px radius.
|
|
||||||
- **Focus:** blue focus ring and border shift.
|
|
||||||
- **Error / Disabled:** semantic color plus text; do not rely on color alone.
|
|
||||||
- **Density:** dense but readable. Long business forms should use sections, sticky actions, and progressive disclosure.
|
|
||||||
|
|
||||||
### Tables
|
|
||||||
- **Style:** sticky headers, clear row dividers, horizontal overflow containment, and explicit wide/overview modes.
|
|
||||||
- **Header:** 12px semibold/demibold, neutral ink, sticky top.
|
|
||||||
- **Cells:** 14px body text, ellipsis only when the full value is available through tooltip, drawer detail, or export.
|
|
||||||
- **Actions:** fixed-width action column with consistent icon/button vocabulary.
|
|
||||||
|
|
||||||
### Drawers
|
|
||||||
- **Style:** use one drawer vocabulary for business editing and details. Headers include title, optional context tag, and a single close action.
|
|
||||||
- **Sizes:** normal, wide, xl, fullscreen. Use fullscreen only when the workflow genuinely needs table-scale comparison.
|
|
||||||
- **State:** every drawer form needs saving, success, error, disabled, and unsaved-change handling.
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
- **Style:** left workflow navigation plus top breadcrumb and visited tags.
|
|
||||||
- **Active State:** active route should be obvious through background, text weight, and an icon or indicator, not a thick decorative stripe.
|
|
||||||
- **Collapsed State:** icon-only navigation must preserve tooltips and avoid layout width animations.
|
|
||||||
|
|
||||||
### Signature Component: Formal Documents
|
|
||||||
|
|
||||||
Formal document styling is allowed for sales orders, purchase orders, warehouse operations, quality inspection, and archived PDFs. It should look like a real operational document: grid lines, document number, signatures, archive status, and readable paper tone. Do not apply this paper treatment to ordinary dashboards or master-data maintenance.
|
|
||||||
|
|
||||||
## 6. Do's and Don'ts
|
|
||||||
|
|
||||||
### Do:
|
|
||||||
- **Do** keep routine ERP screens light, restrained, and table-first.
|
|
||||||
- **Do** use blue for primary actions, selected route/page state, focus, and traceability.
|
|
||||||
- **Do** standardize table, drawer, form, status badge, pagination, and action button behavior before styling individual pages.
|
|
||||||
- **Do** preserve dense information where users compare orders, batches, quantities, costs, and statuses.
|
|
||||||
- **Do** provide loading, empty, error, no-permission, disabled, saving, and archive-failed states for critical flows.
|
|
||||||
- **Do** keep formal paper styling scoped to business documents and PDF archive flows.
|
|
||||||
|
|
||||||
### Don't:
|
|
||||||
- **Don't** make screens look obviously AI-generated through generic card grids, decorative gradients, glass effects, or ornamental animation.
|
|
||||||
- **Don't** use `border-left` or `border-right` greater than 1px as colored accents on cards or callouts.
|
|
||||||
- **Don't** combine a 1px border with a large soft shadow on every card or button.
|
|
||||||
- **Don't** use 24px+ card radii on routine product UI.
|
|
||||||
- **Don't** let old dark industrial styling bleed into everyday ERP work screens.
|
|
||||||
- **Don't** show raw English enum codes in business UI.
|
|
||||||
- **Don't** hide long table values with ellipsis unless the complete value remains accessible.
|
|
||||||
20
PRODUCT.md
20
PRODUCT.md
@ -6,28 +6,28 @@ product
|
|||||||
|
|
||||||
## Users
|
## Users
|
||||||
|
|
||||||
ForgeFlow ERP serves a roughly 50-person hardware manufacturing company. The primary users are managers, sales staff, planners, buyers, warehouse operators, workshop supervisors, production staff, finance users, equipment administrators, and system administrators. They use the system in office and shop-floor contexts to keep orders, materials, production, inventory, delivery, returns, and finance aligned.
|
嘉恒五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
|
||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
The product exists to make the full manufacturing loop visible and controllable: sales orders drive material requirements, procurement, warehouse batches, work orders, operation reporting, finished goods, delivery, returns, and cost reconciliation. Success means users can trace every important business fact back to orders, batches, operations, documents, and responsible roles without hunting through isolated screens.
|
系统用于把五金制造企业的采购、销售、仓储、生产、报工、发货、售后、财务和系统管理流程串联起来。成功的界面应该让业务人员感觉是在填写正式单据、查看可信台账、执行明确动作,而不是在操作一个临时 demo。
|
||||||
|
|
||||||
## Brand Personality
|
## Brand Personality
|
||||||
|
|
||||||
Modern, clear, and easy to adopt. The interface should feel calm, precise, and work-focused: a mature ERP that helps people finish high-stakes daily tasks without visual noise. It can carry a light industrial character where it supports manufacturing context, but the default posture is clean operational clarity rather than heavy factory theming.
|
可靠、利落、工业质感。界面可以有五金制造业的材料感和秩序感,但不能为了装饰牺牲效率、阅读清晰度和业务可信度。
|
||||||
|
|
||||||
## Anti-references
|
## Anti-references
|
||||||
|
|
||||||
Do not make the product look obviously AI-generated. Avoid decorative SaaS landing-page tropes, oversized hero sections inside work screens, generic card grids, glassmorphism, gratuitous gradients, fake depth, excessive roundness, and ornamental motion. Avoid a default Element Plus-style admin surface that feels assembled from common templates. Avoid dark big-screen dashboard aesthetics for routine operational pages. Avoid paper-like document styling outside formal business documents and archived forms.
|
不要 demo 感强的卡片拼贴、解释大框、老式 Web 表单混入正式单据、按钮文案词不达意、状态无颜色区分、表格列名错位、值把表格撑到难以操作、抽屉内部滚动条阻断整页滚动。
|
||||||
|
|
||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. Make the next operational action obvious.
|
1. 业务动作要像正式单据,不像随手填卡片。
|
||||||
2. Treat traceability as a first-class interface pattern.
|
2. 表格和台账优先服务核对效率,字段名清楚,长值可查看全量但不破坏布局。
|
||||||
3. Preserve density where users compare records, but keep hierarchy calm.
|
3. 状态、风险和动作要有明确视觉区分,避免所有按钮和状态一个颜色。
|
||||||
4. Use one consistent component vocabulary across modules.
|
4. 系统解释应收敛到需要时出现,不能常驻占用主要工作空间。
|
||||||
5. Let document-like visuals appear only when the user is handling a formal business document.
|
5. 视觉升级必须保留原有业务字段和流程语义,装修不改业务骨架。
|
||||||
|
|
||||||
## Accessibility & Inclusion
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
Target WCAG AA for text contrast and interaction states. Support keyboard navigation for core workflows, clear focus states, readable Chinese text on ordinary office and workshop displays, reduced-motion preferences, and status communication that does not rely on color alone.
|
默认按生产型后台工具处理:文字对比度要足够,交互目标不能过小,键盘焦点可见,动画只用于状态反馈并尊重 reduced-motion。中文业务字段、系统状态和备注优先使用中文。
|
||||||
|
|||||||
@ -49,3 +49,5 @@ npm run dev
|
|||||||
- 数据库 DDL:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/jiaheng_erp_schema.sql`
|
- 数据库 DDL:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/jiaheng_erp_schema.sql`
|
||||||
- 后端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/app/main.py`
|
- 后端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/app/main.py`
|
||||||
- 前端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend/src/main.js`
|
- 前端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend/src/main.js`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,5 +10,3 @@ MYSQL_PASSWORD=replace_me
|
|||||||
ALLOWED_ORIGINS=http://localhost:5173
|
ALLOWED_ORIGINS=http://localhost:5173
|
||||||
AUTH_SECRET_KEY=please-change-this-in-production
|
AUTH_SECRET_KEY=please-change-this-in-production
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=480
|
AUTH_TOKEN_EXPIRE_MINUTES=480
|
||||||
# 可选:多进程/多实例部署时配置固定登录 RSA 私钥,避免不同进程登录密钥不一致。
|
|
||||||
# AUTH_LOGIN_PRIVATE_KEY_PATH=/opt/ForgeFlow-ERP/backend/storage/auth_login_private_key.pem
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from app.db.session import get_db
|
|||||||
from app.models.org import User
|
from app.models.org import User
|
||||||
from app.schemas.database import (
|
from app.schemas.database import (
|
||||||
AuthChangePasswordRequest,
|
AuthChangePasswordRequest,
|
||||||
AuthLoginKeyResponse,
|
|
||||||
AuthLoginRequest,
|
AuthLoginRequest,
|
||||||
AuthResetPasswordRequest,
|
AuthResetPasswordRequest,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
@ -20,20 +19,13 @@ from app.services.auth import (
|
|||||||
require_system_admin,
|
require_system_admin,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from app.services.login_crypto import decrypt_login_password, get_login_public_key
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login-key", response_model=AuthLoginKeyResponse)
|
|
||||||
def login_key() -> AuthLoginKeyResponse:
|
|
||||||
return AuthLoginKeyResponse(**get_login_public_key().__dict__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthSession)
|
@router.post("/login", response_model=AuthSession)
|
||||||
def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession:
|
def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession:
|
||||||
password = decrypt_login_password(payload.password_ciphertext, payload.login_key_id)
|
return login_with_password(db, payload.username, payload.password)
|
||||||
return login_with_password(db, payload.username, password)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=LoginUser)
|
@router.get("/me", response_model=LoginUser)
|
||||||
|
|||||||
@ -43,6 +43,7 @@ from app.schemas.dashboard import (
|
|||||||
SalesOrderLifecycleRead,
|
SalesOrderLifecycleRead,
|
||||||
)
|
)
|
||||||
from app.services.auth import require_authenticated_user
|
from app.services.auth import require_authenticated_user
|
||||||
|
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph
|
||||||
from app.services.mock_data import get_dashboard_payload
|
from app.services.mock_data import get_dashboard_payload
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
||||||
@ -358,7 +359,7 @@ def _material_lot_rows(limit: int = 100):
|
|||||||
.outerjoin(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id)
|
.outerjoin(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id)
|
||||||
.outerjoin(finished_subquery, finished_subquery.c.source_material_lot_id == StockLot.id)
|
.outerjoin(finished_subquery, finished_subquery.c.source_material_lot_id == StockLot.id)
|
||||||
.outerjoin(delivery_subquery, delivery_subquery.c.source_material_lot_id == StockLot.id)
|
.outerjoin(delivery_subquery, delivery_subquery.c.source_material_lot_id == StockLot.id)
|
||||||
.where(Item.item_type == "RAW_MATERIAL")
|
.where(Item.item_type == "RAW_MATERIAL", Warehouse.warehouse_type == "RAW")
|
||||||
.order_by(StockLot.created_at.desc(), StockLot.id.desc())
|
.order_by(StockLot.created_at.desc(), StockLot.id.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
@ -387,13 +388,10 @@ def list_dashboard_material_lots(
|
|||||||
return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows]
|
return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead)
|
def _build_legacy_material_lot_lifecycle_graph(
|
||||||
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead:
|
db: Session,
|
||||||
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first()
|
lot: DashboardMaterialLotRead,
|
||||||
if not lot_row:
|
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
|
||||||
raise HTTPException(status_code=404, detail="原材料批次不存在")
|
|
||||||
|
|
||||||
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
|
|
||||||
nodes: list[LifecycleNode] = []
|
nodes: list[LifecycleNode] = []
|
||||||
edges: list[LifecycleEdge] = []
|
edges: list[LifecycleEdge] = []
|
||||||
node_ids: set[str] = set()
|
node_ids: set[str] = set()
|
||||||
@ -465,7 +463,7 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
|
|||||||
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
|
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
|
||||||
.join(Item, Item.id == WorkOrder.product_item_id)
|
.join(Item, Item.id == WorkOrder.product_item_id)
|
||||||
.outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
|
.outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
|
||||||
.where(WorkOrderMaterialIssue.source_lot_id == lot_id)
|
.where(WorkOrderMaterialIssue.source_lot_id == lot.lot_id)
|
||||||
.order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id)
|
.order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id)
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
work_order_ids = sorted({row["work_order_id"] for row in issues})
|
work_order_ids = sorted({row["work_order_id"] for row in issues})
|
||||||
@ -741,6 +739,19 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
|
|||||||
)
|
)
|
||||||
add_edge(_edge(f"fg-{row['completion_item_id']}", node_id, "库存发货"))
|
add_edge(_edge(f"fg-{row['completion_item_id']}", node_id, "库存发货"))
|
||||||
|
|
||||||
|
return nodes, edges
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead)
|
||||||
|
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead:
|
||||||
|
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first()
|
||||||
|
if not lot_row:
|
||||||
|
raise HTTPException(status_code=404, detail="原材料批次不存在")
|
||||||
|
|
||||||
|
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
|
||||||
|
nodes, edges = build_material_lot_lifecycle_graph(db, lot)
|
||||||
|
if not any(node.type == "production_ledger" for node in nodes):
|
||||||
|
nodes, edges = _build_legacy_material_lot_lifecycle_graph(db, lot)
|
||||||
return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)
|
return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -88,6 +88,87 @@ from app.services.system_permissions import list_employee_options
|
|||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
||||||
|
|
||||||
|
DEFAULT_PROCESS_CODE = "PROC-DEFAULT"
|
||||||
|
DEFAULT_PROCESS_NAME = "系统默认工序"
|
||||||
|
DEFAULT_WORK_CENTER_CODE = "WC-DEFAULT"
|
||||||
|
DEFAULT_WORK_CENTER_NAME = "系统默认工作中心"
|
||||||
|
DEFAULT_ORG_ROOT_CODE = "ORG_ROOT"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_operation_department(db: Session) -> Department:
|
||||||
|
department = db.scalar(select(Department).where(Department.dept_code == DEFAULT_ORG_ROOT_CODE).limit(1))
|
||||||
|
if department:
|
||||||
|
return department
|
||||||
|
|
||||||
|
department = db.scalar(select(Department).order_by(Department.id.asc()).limit(1))
|
||||||
|
if department:
|
||||||
|
return department
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
department = Department(
|
||||||
|
dept_code=DEFAULT_ORG_ROOT_CODE,
|
||||||
|
dept_name="总公司",
|
||||||
|
parent_id=None,
|
||||||
|
org_node_type="COMPANY",
|
||||||
|
dept_type="ADMIN",
|
||||||
|
manager_name=None,
|
||||||
|
manager_employee_id=None,
|
||||||
|
status="ACTIVE",
|
||||||
|
sort_no=0,
|
||||||
|
remark="系统自动创建的默认组织根节点",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(department)
|
||||||
|
db.flush()
|
||||||
|
return department
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_operation_foundation(db: Session) -> None:
|
||||||
|
changed = False
|
||||||
|
now = datetime.now()
|
||||||
|
process_exists = db.scalar(select(Process.id).limit(1))
|
||||||
|
if not process_exists:
|
||||||
|
db.add(
|
||||||
|
Process(
|
||||||
|
process_code=DEFAULT_PROCESS_CODE,
|
||||||
|
process_name=DEFAULT_PROCESS_NAME,
|
||||||
|
process_type="INTERNAL",
|
||||||
|
is_report_required=1,
|
||||||
|
is_quality_gate=0,
|
||||||
|
status="ACTIVE",
|
||||||
|
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
work_center_exists = db.scalar(select(WorkCenter.id).limit(1))
|
||||||
|
if not work_center_exists:
|
||||||
|
department = _default_operation_department(db)
|
||||||
|
db.add(
|
||||||
|
WorkCenter(
|
||||||
|
center_code=DEFAULT_WORK_CENTER_CODE,
|
||||||
|
center_name=DEFAULT_WORK_CENTER_NAME,
|
||||||
|
dept_id=department.id,
|
||||||
|
center_type="INTERNAL",
|
||||||
|
shift_pattern="默认班次",
|
||||||
|
capacity_hours_per_day=Decimal("8.00"),
|
||||||
|
status="ACTIVE",
|
||||||
|
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"]
|
MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"]
|
||||||
MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"]
|
MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"]
|
||||||
MATERIAL_STATUS_LABELS = {
|
MATERIAL_STATUS_LABELS = {
|
||||||
@ -597,6 +678,7 @@ def list_work_centers(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> list[WorkCenterRead]:
|
) -> list[WorkCenterRead]:
|
||||||
|
_ensure_default_operation_foundation(db)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
WorkCenter.id.label("id"),
|
WorkCenter.id.label("id"),
|
||||||
@ -766,6 +848,7 @@ def list_processes(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> list[ProcessRead]:
|
) -> list[ProcessRead]:
|
||||||
|
_ensure_default_operation_foundation(db)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(
|
select(
|
||||||
Process.id.label("process_id"),
|
Process.id.label("process_id"),
|
||||||
|
|||||||
@ -90,14 +90,7 @@ class LoginUser(BaseModel):
|
|||||||
|
|
||||||
class AuthLoginRequest(BaseModel):
|
class AuthLoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password_ciphertext: str = Field(min_length=1)
|
password: str
|
||||||
login_key_id: str = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthLoginKeyResponse(BaseModel):
|
|
||||||
key_id: str
|
|
||||||
public_key_pem: str
|
|
||||||
algorithm: str
|
|
||||||
|
|
||||||
|
|
||||||
class AuthChangePasswordRequest(BaseModel):
|
class AuthChangePasswordRequest(BaseModel):
|
||||||
|
|||||||
@ -59,6 +59,16 @@ ARCHIVE_MIN_ROW_HEIGHT = 24
|
|||||||
ARCHIVE_CELL_HORIZONTAL_PADDING = 8
|
ARCHIVE_CELL_HORIZONTAL_PADDING = 8
|
||||||
ARCHIVE_CELL_VERTICAL_PADDING = 8
|
ARCHIVE_CELL_VERTICAL_PADDING = 8
|
||||||
ARCHIVE_CELL_FONT_SIZE = 8
|
ARCHIVE_CELL_FONT_SIZE = 8
|
||||||
|
ARCHIVE_METADATA_FONT_SIZE = 10
|
||||||
|
ARCHIVE_METADATA_LEFT_X = 42
|
||||||
|
ARCHIVE_METADATA_RIGHT_X = 340
|
||||||
|
ARCHIVE_METADATA_COLUMN_GAP = 10
|
||||||
|
ARCHIVE_METADATA_LEFT_WIDTH = ARCHIVE_METADATA_RIGHT_X - ARCHIVE_METADATA_LEFT_X - ARCHIVE_METADATA_COLUMN_GAP
|
||||||
|
ARCHIVE_METADATA_RIGHT_WIDTH = A4[0] - ARCHIVE_TABLE_RIGHT_MARGIN - ARCHIVE_METADATA_RIGHT_X
|
||||||
|
ARCHIVE_METADATA_TOP_Y_OFFSET = 92
|
||||||
|
ARCHIVE_METADATA_MIN_ROW_HEIGHT = 20
|
||||||
|
ARCHIVE_METADATA_ROW_PADDING = 7
|
||||||
|
ARCHIVE_METADATA_TABLE_GAP = 18
|
||||||
|
|
||||||
ARCHIVE_STATUS_LABELS = {
|
ARCHIVE_STATUS_LABELS = {
|
||||||
"ACTIVE": "启用",
|
"ACTIVE": "启用",
|
||||||
@ -1024,6 +1034,59 @@ def _draw_cell(
|
|||||||
text_y -= line_height
|
text_y -= line_height
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_metadata_rows(context: ArchiveContext) -> list[tuple[tuple[str, str], tuple[str, str]]]:
|
||||||
|
contact_text = f"{context.contact_name or ''} 电话: {context.contact_phone or ''}"
|
||||||
|
tax_text = f"{decimal_text((context.tax_rate or 0) * 100)}%"
|
||||||
|
return [
|
||||||
|
(("单据编号", context.document_no), ("单据日期", date_text(context.document_date))),
|
||||||
|
((context.partner_label, context.partner_name), (context.due_date_label, date_text(context.due_date))),
|
||||||
|
(("联系人", contact_text), ("状态", context.status)),
|
||||||
|
((context.address_label or "地址", context.address or ""), ("税率", tax_text)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_metadata_line_groups(
|
||||||
|
context: ArchiveContext,
|
||||||
|
font_name: str,
|
||||||
|
size: int = ARCHIVE_METADATA_FONT_SIZE,
|
||||||
|
) -> list[tuple[list[str], list[str]]]:
|
||||||
|
groups: list[tuple[list[str], list[str]]] = []
|
||||||
|
for (left_label, left_value), (right_label, right_value) in _archive_metadata_rows(context):
|
||||||
|
left_text = f"{left_label}: {left_value or ''}"
|
||||||
|
right_text = f"{right_label}: {right_value or ''}"
|
||||||
|
groups.append(
|
||||||
|
(
|
||||||
|
_wrap_text_to_lines(left_text, font_name, size, ARCHIVE_METADATA_LEFT_WIDTH),
|
||||||
|
_wrap_text_to_lines(right_text, font_name, size, ARCHIVE_METADATA_RIGHT_WIDTH),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_row_height(line_count: int, size: int = ARCHIVE_METADATA_FONT_SIZE) -> float:
|
||||||
|
return max(ARCHIVE_METADATA_MIN_ROW_HEIGHT, line_count * _cell_line_height(size) + ARCHIVE_METADATA_ROW_PADDING)
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_table_y_for_context(context: ArchiveContext, font_name: str) -> float:
|
||||||
|
_, height = A4
|
||||||
|
y = height - ARCHIVE_METADATA_TOP_Y_OFFSET
|
||||||
|
for left_lines, right_lines in _archive_metadata_line_groups(context, font_name):
|
||||||
|
y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1))
|
||||||
|
return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, y - ARCHIVE_METADATA_TABLE_GAP)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_metadata_lines(
|
||||||
|
pdf: canvas.Canvas,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
lines: list[str],
|
||||||
|
line_height: float,
|
||||||
|
) -> None:
|
||||||
|
for line in lines:
|
||||||
|
pdf.drawString(x, y, line)
|
||||||
|
y -= line_height
|
||||||
|
|
||||||
|
|
||||||
def _archive_table_columns() -> list[tuple[str, int, str]]:
|
def _archive_table_columns() -> list[tuple[str, int, str]]:
|
||||||
return [
|
return [
|
||||||
("行", 26, "center"),
|
("行", 26, "center"),
|
||||||
@ -1045,7 +1108,7 @@ def _draw_archive_page_frame(
|
|||||||
bold_font_name: str,
|
bold_font_name: str,
|
||||||
page_no: int,
|
page_no: int,
|
||||||
total_pages: int,
|
total_pages: int,
|
||||||
) -> None:
|
) -> float:
|
||||||
width, height = A4
|
width, height = A4
|
||||||
pdf.setFillColorRGB(0.99, 0.975, 0.92)
|
pdf.setFillColorRGB(0.99, 0.975, 0.92)
|
||||||
pdf.rect(0, 0, width, height, stroke=0, fill=1)
|
pdf.rect(0, 0, width, height, stroke=0, fill=1)
|
||||||
@ -1061,15 +1124,13 @@ def _draw_archive_page_frame(
|
|||||||
pdf.drawRightString(width - 42, height - 62, "PDF归档")
|
pdf.drawRightString(width - 42, height - 62, "PDF归档")
|
||||||
pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页")
|
pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页")
|
||||||
|
|
||||||
pdf.setFont(font_name, 10)
|
pdf.setFont(font_name, ARCHIVE_METADATA_FONT_SIZE)
|
||||||
pdf.drawString(42, height - 92, f"单据编号: {context.document_no}")
|
line_height = _cell_line_height(ARCHIVE_METADATA_FONT_SIZE)
|
||||||
pdf.drawString(42, height - 112, f"{context.partner_label}: {context.partner_name}")
|
metadata_y = height - ARCHIVE_METADATA_TOP_Y_OFFSET
|
||||||
pdf.drawString(42, height - 132, f"联系人: {context.contact_name or ''} 电话: {context.contact_phone or ''}")
|
for left_lines, right_lines in _archive_metadata_line_groups(context, font_name):
|
||||||
pdf.drawString(42, height - 152, f"{context.address_label or '地址'}: {context.address or ''}")
|
_draw_metadata_lines(pdf, ARCHIVE_METADATA_LEFT_X, metadata_y, left_lines, line_height)
|
||||||
pdf.drawString(340, height - 92, f"单据日期: {date_text(context.document_date)}")
|
_draw_metadata_lines(pdf, ARCHIVE_METADATA_RIGHT_X, metadata_y, right_lines, line_height)
|
||||||
pdf.drawString(340, height - 112, f"{context.due_date_label}: {date_text(context.due_date)}")
|
metadata_y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1))
|
||||||
pdf.drawString(340, height - 132, f"状态: {context.status}")
|
|
||||||
pdf.drawString(340, height - 152, f"税率: {decimal_text((context.tax_rate or 0) * 100)}%")
|
|
||||||
|
|
||||||
pdf.setFont(bold_font_name, 26)
|
pdf.setFont(bold_font_name, 26)
|
||||||
pdf.setStrokeColorRGB(0.55, 0.05, 0.05)
|
pdf.setStrokeColorRGB(0.55, 0.05, 0.05)
|
||||||
@ -1079,6 +1140,7 @@ def _draw_archive_page_frame(
|
|||||||
pdf.rotate(-12)
|
pdf.rotate(-12)
|
||||||
pdf.setStrokeColor(colors.black)
|
pdf.setStrokeColor(colors.black)
|
||||||
pdf.setFillColor(colors.black)
|
pdf.setFillColor(colors.black)
|
||||||
|
return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, metadata_y - ARCHIVE_METADATA_TABLE_GAP)
|
||||||
|
|
||||||
|
|
||||||
def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None:
|
def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None:
|
||||||
@ -1180,14 +1242,13 @@ def render_pdf_archive(context: ArchiveContext, output_path: str | Path) -> None
|
|||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
font_name, bold_font_name = _register_archive_font()
|
font_name, bold_font_name = _register_archive_font()
|
||||||
pdf = canvas.Canvas(str(output), pagesize=A4)
|
pdf = canvas.Canvas(str(output), pagesize=A4)
|
||||||
height = A4[1]
|
table_y = _archive_table_y_for_context(context, font_name)
|
||||||
table_y = height - ARCHIVE_TABLE_TOP_Y_OFFSET
|
|
||||||
row_h = ARCHIVE_MIN_ROW_HEIGHT
|
row_h = ARCHIVE_MIN_ROW_HEIGHT
|
||||||
max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y
|
max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y
|
||||||
pages = _line_pages(context.lines, max_body_height, font_name)
|
pages = _line_pages(context.lines, max_body_height, font_name)
|
||||||
|
|
||||||
for page_index, page_lines in enumerate(pages, start=1):
|
for page_index, page_lines in enumerate(pages, start=1):
|
||||||
_draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages))
|
table_y = _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages))
|
||||||
_draw_archive_table_header(pdf, table_y, bold_font_name)
|
_draw_archive_table_header(pdf, table_y, bold_font_name)
|
||||||
y = _draw_archive_table_rows(
|
y = _draw_archive_table_rows(
|
||||||
pdf,
|
pdf,
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LoginPublicKey:
|
|
||||||
key_id: str
|
|
||||||
public_key_pem: str
|
|
||||||
algorithm: str = "RSA-OAEP-SHA256"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_env_pem(value: str) -> bytes:
|
|
||||||
return value.replace("\\n", "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _load_private_key():
|
|
||||||
private_key_path = os.getenv("AUTH_LOGIN_PRIVATE_KEY_PATH")
|
|
||||||
private_key_pem = os.getenv("AUTH_LOGIN_PRIVATE_KEY")
|
|
||||||
|
|
||||||
if private_key_path:
|
|
||||||
with open(private_key_path, "rb") as key_file:
|
|
||||||
key_data = key_file.read()
|
|
||||||
return serialization.load_pem_private_key(key_data, password=None)
|
|
||||||
|
|
||||||
if private_key_pem:
|
|
||||||
return serialization.load_pem_private_key(_normalize_env_pem(private_key_pem), password=None)
|
|
||||||
|
|
||||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_login_public_key() -> LoginPublicKey:
|
|
||||||
private_key = _load_private_key()
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
public_der = public_key.public_bytes(
|
|
||||||
encoding=serialization.Encoding.DER,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
)
|
|
||||||
public_pem = public_key.public_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
||||||
).decode("utf-8")
|
|
||||||
key_id = hashlib.sha256(public_der).hexdigest()[:16]
|
|
||||||
return LoginPublicKey(key_id=key_id, public_key_pem=public_pem)
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_login_password(password_ciphertext: str, login_key_id: str) -> str:
|
|
||||||
key_info = get_login_public_key()
|
|
||||||
if login_key_id != key_info.key_id:
|
|
||||||
raise HTTPException(status_code=400, detail="登录密钥已过期,请刷新后重试")
|
|
||||||
|
|
||||||
try:
|
|
||||||
ciphertext = base64.b64decode(password_ciphertext.encode("utf-8"), validate=True)
|
|
||||||
plaintext = _load_private_key().decrypt(
|
|
||||||
ciphertext,
|
|
||||||
padding.OAEP(
|
|
||||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
label=None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return plaintext.decode("utf-8")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=400, detail="登录密码密文无效,请刷新后重试") from exc
|
|
||||||
456
backend/app/services/material_lifecycle_dag.py
Normal file
456
backend/app/services/material_lifecycle_dag.py
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import false, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models.master_data import Item
|
||||||
|
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
|
||||||
|
from app.models.org import Employee
|
||||||
|
from app.models.sales import Customer, SalesOrder
|
||||||
|
from app.schemas.dashboard import DashboardMaterialLotRead, LifecycleDetailItem, LifecycleEdge, LifecycleNode
|
||||||
|
from app.services.document_archives import production_document_batch_no_from_text
|
||||||
|
from app.services.system_config import get_smart_operation_report_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value: Any) -> float:
|
||||||
|
if value is None:
|
||||||
|
return 0.0
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_qty(value: Any, digits: int = 2) -> str:
|
||||||
|
numeric = _num(value)
|
||||||
|
if abs(numeric - round(numeric)) < 0.000001:
|
||||||
|
return f"{int(round(numeric))}"
|
||||||
|
return f"{numeric:.{digits}f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_weight(value: Any) -> str:
|
||||||
|
return f"{_fmt_qty(value, 3)} kg"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_datetime(value: Any) -> str:
|
||||||
|
if not value:
|
||||||
|
return "-"
|
||||||
|
return str(value).replace("T", " ")[:19]
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(label: str, value: Any) -> LifecycleDetailItem:
|
||||||
|
text = "-" if value is None or value == "" else str(value)
|
||||||
|
return LifecycleDetailItem(label=label, value=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _node(
|
||||||
|
*,
|
||||||
|
node_id: str,
|
||||||
|
node_type: str,
|
||||||
|
title: str,
|
||||||
|
stage: str,
|
||||||
|
level: int,
|
||||||
|
subtitle: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
tone: str = "normal",
|
||||||
|
summary: str | None = None,
|
||||||
|
metrics: list[tuple[str, Any]] | None = None,
|
||||||
|
details: list[tuple[str, Any]] | None = None,
|
||||||
|
raw: dict[str, Any] | None = None,
|
||||||
|
) -> LifecycleNode:
|
||||||
|
return LifecycleNode(
|
||||||
|
id=node_id,
|
||||||
|
type=node_type,
|
||||||
|
title=title,
|
||||||
|
subtitle=subtitle,
|
||||||
|
status=status,
|
||||||
|
stage=stage,
|
||||||
|
level=level,
|
||||||
|
tone=tone,
|
||||||
|
summary=summary,
|
||||||
|
metrics=[_detail(label, value) for label, value in (metrics or [])],
|
||||||
|
details=[_detail(label, value) for label, value in (details or [])],
|
||||||
|
raw=raw or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge(source: str, target: str, label: str | None = None) -> LifecycleEdge:
|
||||||
|
return LifecycleEdge(id=f"{source}->{target}", source=source, target=target, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_node(nodes: list[LifecycleNode], node_ids: set[str], node: LifecycleNode) -> None:
|
||||||
|
if node.id not in node_ids:
|
||||||
|
node_ids.add(node.id)
|
||||||
|
nodes.append(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_edge(edges: list[LifecycleEdge], edge_ids: set[str], node_ids: set[str], edge: LifecycleEdge) -> None:
|
||||||
|
if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids:
|
||||||
|
edge_ids.add(edge.id)
|
||||||
|
edges.append(edge)
|
||||||
|
|
||||||
|
|
||||||
|
def _settlement_group_key(txn: ProductionBatchLedgerTxn) -> tuple[str, str]:
|
||||||
|
batch_no = production_document_batch_no_from_text(txn.remark)
|
||||||
|
if batch_no:
|
||||||
|
return f"batch:{batch_no}", batch_no
|
||||||
|
return f"txn:{txn.id}", f"{txn.txn_type}-{txn.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _txn_node_type(txn_type: str) -> str:
|
||||||
|
return {
|
||||||
|
"成品入库": "finished_inbound",
|
||||||
|
"生产余料入库": "surplus_inbound",
|
||||||
|
"生产废料入库": "scrap_inbound",
|
||||||
|
"重新开工": "ledger_reopen",
|
||||||
|
}.get(txn_type, "ledger_writeoff")
|
||||||
|
|
||||||
|
|
||||||
|
def build_material_lot_lifecycle_graph(
|
||||||
|
db: Session,
|
||||||
|
lot: DashboardMaterialLotRead,
|
||||||
|
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
|
||||||
|
nodes: list[LifecycleNode] = []
|
||||||
|
edges: list[LifecycleEdge] = []
|
||||||
|
node_ids: set[str] = set()
|
||||||
|
edge_ids: set[str] = set()
|
||||||
|
|
||||||
|
raw_node_id = f"raw-lot-{lot.lot_id}"
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=raw_node_id,
|
||||||
|
node_type="raw_material_lot",
|
||||||
|
title=lot.lot_no,
|
||||||
|
subtitle=f"{lot.material_code} · {lot.material_name}",
|
||||||
|
status=lot.status,
|
||||||
|
stage="原料入库",
|
||||||
|
level=0,
|
||||||
|
summary="原材料批次入库后,按生产台账追踪生产出库、库外余额、入库结算和销售去向。",
|
||||||
|
metrics=[
|
||||||
|
("入库重量", _fmt_weight(lot.inbound_weight_kg)),
|
||||||
|
("剩余重量", _fmt_weight(lot.remaining_weight_kg)),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("原材料编码", lot.material_code),
|
||||||
|
("原材料名称", lot.material_name),
|
||||||
|
("仓库", lot.warehouse_name),
|
||||||
|
],
|
||||||
|
raw=lot.model_dump(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ledger_rows = db.execute(
|
||||||
|
select(
|
||||||
|
ProductionBatchLedger,
|
||||||
|
Item.item_code.label("product_code"),
|
||||||
|
Item.item_name.label("product_name"),
|
||||||
|
)
|
||||||
|
.join(Item, Item.id == ProductionBatchLedger.product_item_id)
|
||||||
|
.where(ProductionBatchLedger.material_lot_id == lot.lot_id)
|
||||||
|
.order_by(ProductionBatchLedger.id)
|
||||||
|
).all()
|
||||||
|
ledgers = [row.ProductionBatchLedger for row in ledger_rows]
|
||||||
|
ledger_ids = [int(ledger.id) for ledger in ledgers]
|
||||||
|
|
||||||
|
txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids}
|
||||||
|
if ledger_ids:
|
||||||
|
txns = db.scalars(
|
||||||
|
select(ProductionBatchLedgerTxn)
|
||||||
|
.where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
|
||||||
|
.order_by(ProductionBatchLedgerTxn.biz_time, ProductionBatchLedgerTxn.id)
|
||||||
|
).all()
|
||||||
|
for txn in txns:
|
||||||
|
txns_by_ledger_id.setdefault(int(txn.production_ledger_id), []).append(txn)
|
||||||
|
|
||||||
|
finished_node_id_by_lot_id: dict[int, str] = {}
|
||||||
|
settlement_txn_types = {"成品入库", "生产余料入库", "生产废料入库", "结单核销", "该批材料结单", "重新开工"}
|
||||||
|
for row in ledger_rows:
|
||||||
|
ledger = row.ProductionBatchLedger
|
||||||
|
ledger_id = int(ledger.id)
|
||||||
|
ledger_node_id = f"ledger-{ledger_id}"
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=ledger_node_id,
|
||||||
|
node_type="production_ledger",
|
||||||
|
title=f"{ledger.material_lot_no} · {row.product_name}",
|
||||||
|
subtitle=row.product_code,
|
||||||
|
status=ledger.status,
|
||||||
|
stage="生产台账",
|
||||||
|
level=1,
|
||||||
|
summary="一行生产台账代表同一材料库存批次号与产品组合,是材料批次闭环的主链路。",
|
||||||
|
metrics=[
|
||||||
|
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
|
||||||
|
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
||||||
|
("成品入库", _fmt_qty(ledger.finished_inbound_qty)),
|
||||||
|
("余料入库", _fmt_weight(ledger.surplus_inbound_weight_kg)),
|
||||||
|
("废料入库", _fmt_weight(ledger.scrap_inbound_weight_kg)),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("产品编码", row.product_code),
|
||||||
|
("产品名称", row.product_name),
|
||||||
|
("首次领料", _fmt_datetime(ledger.first_issue_time)),
|
||||||
|
("最近领料", _fmt_datetime(ledger.last_issue_time)),
|
||||||
|
("结单次数", ledger.lock_count),
|
||||||
|
("备注", ledger.remark),
|
||||||
|
],
|
||||||
|
raw={
|
||||||
|
"production_ledger_id": ledger.id,
|
||||||
|
"material_lot_id": ledger.material_lot_id,
|
||||||
|
"product_item_id": ledger.product_item_id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(raw_node_id, ledger_node_id, "进入生产台账"))
|
||||||
|
|
||||||
|
issue_txns = [txn for txn in txns_by_ledger_id.get(ledger_id, []) if txn.txn_type == "生产出库"]
|
||||||
|
issue_node_ids: list[str] = []
|
||||||
|
for txn in issue_txns:
|
||||||
|
issue_node_id = f"issue-txn-{txn.id}"
|
||||||
|
issue_node_ids.append(issue_node_id)
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=issue_node_id,
|
||||||
|
node_type="production_issue_txn",
|
||||||
|
title=f"生产出库 {txn.id}",
|
||||||
|
subtitle=ledger.material_lot_no,
|
||||||
|
status="POSTED",
|
||||||
|
tone="success",
|
||||||
|
stage="生产出库",
|
||||||
|
level=2,
|
||||||
|
summary="生产出库事务累加到生产台账,并增加该批材料的库外余额。",
|
||||||
|
metrics=[
|
||||||
|
("出库重量", _fmt_weight(txn.weight_delta_kg)),
|
||||||
|
("出库后库外余额", _fmt_weight(txn.outside_weight_after_kg)),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("业务时间", _fmt_datetime(txn.biz_time)),
|
||||||
|
("来源单据", txn.source_doc_type),
|
||||||
|
("来源ID", txn.source_doc_id),
|
||||||
|
("备注", txn.remark),
|
||||||
|
],
|
||||||
|
raw={"txn_id": txn.id, "production_ledger_id": txn.production_ledger_id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, issue_node_id, "生产出库"))
|
||||||
|
|
||||||
|
outside_node_id = f"outside-balance-{ledger_id}"
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=outside_node_id,
|
||||||
|
node_type="ledger_outside_balance",
|
||||||
|
title="库外余额",
|
||||||
|
subtitle=ledger.material_lot_no,
|
||||||
|
status=ledger.status,
|
||||||
|
stage="库外闭环",
|
||||||
|
level=3,
|
||||||
|
summary="库外余额用于衡量该材料批次尚未通过成品、余料、废料入库或结单核销闭环的重量。",
|
||||||
|
metrics=[
|
||||||
|
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
||||||
|
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
|
||||||
|
("结单核销", _fmt_weight(ledger.writeoff_weight_kg)),
|
||||||
|
],
|
||||||
|
raw={"production_ledger_id": ledger.id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if issue_node_ids:
|
||||||
|
for issue_node_id in issue_node_ids:
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(issue_node_id, outside_node_id, "形成库外余额"))
|
||||||
|
else:
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, outside_node_id, "库外余额"))
|
||||||
|
|
||||||
|
settlement_groups: dict[tuple[str, str], list[ProductionBatchLedgerTxn]] = {}
|
||||||
|
for txn in txns_by_ledger_id.get(ledger_id, []):
|
||||||
|
if txn.txn_type in settlement_txn_types:
|
||||||
|
settlement_groups.setdefault(_settlement_group_key(txn), []).append(txn)
|
||||||
|
|
||||||
|
for group_index, ((group_key, group_title), group_txns) in enumerate(settlement_groups.items(), start=1):
|
||||||
|
settlement_node_id = f"settlement-{ledger_id}-{group_key}"
|
||||||
|
total_weight_delta = sum(_num(txn.weight_delta_kg) for txn in group_txns)
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=settlement_node_id,
|
||||||
|
node_type="production_ledger_settlement",
|
||||||
|
title=group_title,
|
||||||
|
subtitle=f"{len(group_txns)} 笔台账事务",
|
||||||
|
status="POSTED",
|
||||||
|
tone="success",
|
||||||
|
stage="入库结算",
|
||||||
|
level=4,
|
||||||
|
summary="按生产单据批次号聚合成品、余料、废料入库及结单核销事务;没有批次号的事务独立成组。",
|
||||||
|
metrics=[
|
||||||
|
("事务数", len(group_txns)),
|
||||||
|
("重量变化", _fmt_weight(total_weight_delta)),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("分组", group_title),
|
||||||
|
("序号", group_index),
|
||||||
|
],
|
||||||
|
raw={"production_ledger_id": ledger.id, "group_key": group_key},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(outside_node_id, settlement_node_id, "入库结算"))
|
||||||
|
|
||||||
|
txns_by_type: dict[str, list[ProductionBatchLedgerTxn]] = {}
|
||||||
|
for txn in group_txns:
|
||||||
|
txns_by_type.setdefault(str(txn.txn_type), []).append(txn)
|
||||||
|
for txn_type, type_txns in txns_by_type.items():
|
||||||
|
child_node_id = f"{_txn_node_type(txn_type)}-{ledger_id}-{group_key}"
|
||||||
|
child_weight_delta = sum(_num(txn.weight_delta_kg) for txn in type_txns)
|
||||||
|
child_qty_delta = sum(_num(txn.qty_delta) for txn in type_txns)
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=child_node_id,
|
||||||
|
node_type=_txn_node_type(txn_type),
|
||||||
|
title=txn_type,
|
||||||
|
subtitle=group_title,
|
||||||
|
status="POSTED",
|
||||||
|
tone="success",
|
||||||
|
stage=txn_type,
|
||||||
|
level=5,
|
||||||
|
summary="该节点来自生产台账事务,并参与材料批次库外闭环。",
|
||||||
|
metrics=[
|
||||||
|
("数量变化", _fmt_qty(child_qty_delta)),
|
||||||
|
("重量变化", _fmt_weight(child_weight_delta)),
|
||||||
|
("事务数", len(type_txns)),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("最近库外余额", _fmt_weight(type_txns[-1].outside_weight_after_kg)),
|
||||||
|
("业务时间", _fmt_datetime(type_txns[-1].biz_time)),
|
||||||
|
],
|
||||||
|
raw={
|
||||||
|
"production_ledger_id": ledger.id,
|
||||||
|
"txn_ids": [txn.id for txn in type_txns],
|
||||||
|
"group_key": group_key,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(settlement_node_id, child_node_id, txn_type))
|
||||||
|
if _txn_node_type(txn_type) == "finished_inbound":
|
||||||
|
for txn in type_txns:
|
||||||
|
if txn.source_line_id:
|
||||||
|
finished_node_id_by_lot_id[int(txn.source_line_id)] = child_node_id
|
||||||
|
|
||||||
|
if ledger_ids and get_smart_operation_report_enabled(db):
|
||||||
|
report_rows = db.execute(
|
||||||
|
select(
|
||||||
|
OperationReport,
|
||||||
|
Employee.employee_name.label("employee_name"),
|
||||||
|
)
|
||||||
|
.join(Employee, Employee.id == OperationReport.employee_id)
|
||||||
|
.where(OperationReport.production_ledger_id.in_(ledger_ids))
|
||||||
|
.order_by(OperationReport.start_time, OperationReport.id)
|
||||||
|
).all()
|
||||||
|
for row in report_rows:
|
||||||
|
report = row.OperationReport
|
||||||
|
node_id = f"report-{report.id}"
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=node_id,
|
||||||
|
node_type="operation_report",
|
||||||
|
title=report.report_no,
|
||||||
|
subtitle=row.employee_name,
|
||||||
|
status="ABNORMAL" if report.is_abnormal else "NORMAL",
|
||||||
|
tone="danger" if report.is_abnormal else "success",
|
||||||
|
stage="现场报工证据",
|
||||||
|
level=2,
|
||||||
|
summary="只作为现场报工证据展示,不参与材料批次闭环判定。",
|
||||||
|
metrics=[
|
||||||
|
("报工数", _fmt_qty(report.report_qty)),
|
||||||
|
("合格数", _fmt_qty(report.good_qty)),
|
||||||
|
("报废数", _fmt_qty(report.scrap_qty)),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("员工", row.employee_name),
|
||||||
|
("开始", _fmt_datetime(report.start_time)),
|
||||||
|
("结束", _fmt_datetime(report.end_time)),
|
||||||
|
("来源", report.report_source),
|
||||||
|
],
|
||||||
|
raw={"report_id": report.id, "production_ledger_id": report.production_ledger_id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(f"ledger-{report.production_ledger_id}", node_id, "报工证据"))
|
||||||
|
|
||||||
|
finished_lot = aliased(StockLot)
|
||||||
|
order_customer = aliased(Customer)
|
||||||
|
direct_customer = aliased(Customer)
|
||||||
|
delivery_rows = db.execute(
|
||||||
|
select(
|
||||||
|
DeliveryItem.id.label("delivery_item_id"),
|
||||||
|
DeliveryItem.lot_id.label("delivery_lot_id"),
|
||||||
|
finished_lot.id.label("finished_lot_id"),
|
||||||
|
finished_lot.source_doc_id.label("production_ledger_id"),
|
||||||
|
finished_lot.lot_no.label("lot_no"),
|
||||||
|
Delivery.delivery_no.label("delivery_no"),
|
||||||
|
SalesOrder.order_no.label("order_no"),
|
||||||
|
direct_customer.customer_name.label("direct_customer_name"),
|
||||||
|
order_customer.customer_name.label("order_customer_name"),
|
||||||
|
Delivery.delivery_date.label("delivery_date"),
|
||||||
|
DeliveryItem.delivery_qty.label("delivery_qty"),
|
||||||
|
DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"),
|
||||||
|
DeliveryItem.line_amount.label("line_amount"),
|
||||||
|
DeliveryItem.status.label("status"),
|
||||||
|
)
|
||||||
|
.join(finished_lot, finished_lot.id == DeliveryItem.lot_id)
|
||||||
|
.join(Item, Item.id == finished_lot.item_id)
|
||||||
|
.join(Delivery, Delivery.id == DeliveryItem.delivery_id)
|
||||||
|
.outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id)
|
||||||
|
.outerjoin(order_customer, order_customer.id == SalesOrder.customer_id)
|
||||||
|
.outerjoin(direct_customer, direct_customer.id == Delivery.customer_id)
|
||||||
|
.where(
|
||||||
|
finished_lot.source_doc_id.in_(ledger_ids) if ledger_ids else false(),
|
||||||
|
finished_lot.source_doc_type.in_(["PRODUCTION_LEDGER_IN", "生产台账"]),
|
||||||
|
Item.item_type == "FINISHED_GOOD",
|
||||||
|
)
|
||||||
|
.order_by(Delivery.delivery_date, DeliveryItem.id)
|
||||||
|
).mappings().all()
|
||||||
|
for row in delivery_rows:
|
||||||
|
delivery_node_id = f"delivery-{row['delivery_item_id']}"
|
||||||
|
_append_node(
|
||||||
|
nodes,
|
||||||
|
node_ids,
|
||||||
|
_node(
|
||||||
|
node_id=delivery_node_id,
|
||||||
|
node_type="delivery",
|
||||||
|
title=row["delivery_no"],
|
||||||
|
subtitle=row["order_no"],
|
||||||
|
status=row["status"],
|
||||||
|
stage="销售去向",
|
||||||
|
level=6,
|
||||||
|
summary="销售出库从生产台账生成的成品库存批次追溯到材料批次。",
|
||||||
|
metrics=[
|
||||||
|
("发货数量", _fmt_qty(row["delivery_qty"])),
|
||||||
|
("发货重量", _fmt_weight(row["delivery_weight_kg"])),
|
||||||
|
],
|
||||||
|
details=[
|
||||||
|
("客户", row["direct_customer_name"] or row["order_customer_name"]),
|
||||||
|
("库存批次号", row["lot_no"]),
|
||||||
|
("发货时间", _fmt_datetime(row["delivery_date"])),
|
||||||
|
],
|
||||||
|
raw=dict(row),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source_node_id = finished_node_id_by_lot_id.get(int(row["delivery_lot_id"] or 0))
|
||||||
|
if not source_node_id:
|
||||||
|
source_node_id = f"ledger-{row['production_ledger_id']}"
|
||||||
|
_append_edge(edges, edge_ids, node_ids, _edge(source_node_id, delivery_node_id, "销售出库"))
|
||||||
|
|
||||||
|
return nodes, edges
|
||||||
@ -85,7 +85,10 @@ MENU_PERMISSION_TREE = [
|
|||||||
ORG_ROOT_CODE = "ORG_ROOT"
|
ORG_ROOT_CODE = "ORG_ROOT"
|
||||||
ORG_ROOT_NAME = "总公司"
|
ORG_ROOT_NAME = "总公司"
|
||||||
VALID_CHILD_NODE_TYPES = {
|
VALID_CHILD_NODE_TYPES = {
|
||||||
"COMPANY": {"BRANCH": "总公司下只能新增分公司"},
|
"COMPANY": {
|
||||||
|
"BRANCH": "总公司下只能新增分公司或直属部门",
|
||||||
|
"DEPARTMENT": "总公司下只能新增分公司或直属部门",
|
||||||
|
},
|
||||||
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
||||||
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
||||||
"GROUP": {},
|
"GROUP": {},
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import padding
|
|
||||||
|
|
||||||
from app.services.login_crypto import decrypt_login_password, get_login_public_key
|
|
||||||
|
|
||||||
|
|
||||||
class AuthLoginEncryptionTest(unittest.TestCase):
|
|
||||||
def test_decrypts_password_encrypted_with_public_key(self) -> None:
|
|
||||||
key_info = get_login_public_key()
|
|
||||||
public_key = serialization.load_pem_public_key(key_info.public_key_pem.encode("utf-8"))
|
|
||||||
ciphertext = public_key.encrypt(
|
|
||||||
"safe-password-123".encode("utf-8"),
|
|
||||||
padding.OAEP(
|
|
||||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
label=None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
decrypted = decrypt_login_password(base64.b64encode(ciphertext).decode("utf-8"), key_info.key_id)
|
|
||||||
|
|
||||||
self.assertEqual(decrypted, "safe-password-123")
|
|
||||||
|
|
||||||
def test_rejects_unknown_login_key_id(self) -> None:
|
|
||||||
key_info = get_login_public_key()
|
|
||||||
|
|
||||||
with self.assertRaises(HTTPException) as raised:
|
|
||||||
decrypt_login_password("not-a-real-ciphertext", f"{key_info.key_id}-old")
|
|
||||||
|
|
||||||
self.assertEqual(raised.exception.status_code, 400)
|
|
||||||
self.assertEqual(raised.exception.detail, "登录密钥已过期,请刷新后重试")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
510
backend/tests/test_dashboard_material_lot_lifecycle.py
Normal file
510
backend/tests/test_dashboard_material_lot_lifecycle.py
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, create_engine
|
||||||
|
from sqlalchemy.ext.compiler import compiles
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(BigInteger, "sqlite")
|
||||||
|
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
|
||||||
|
_ = type_, compiler, kw
|
||||||
|
return "INTEGER"
|
||||||
|
|
||||||
|
|
||||||
|
import app.models.master_data # noqa: E402,F401
|
||||||
|
import app.models.miniapp # noqa: E402,F401
|
||||||
|
import app.models.operations # noqa: E402,F401
|
||||||
|
import app.models.org # noqa: E402,F401
|
||||||
|
import app.models.planning # noqa: E402,F401
|
||||||
|
import app.models.sales # noqa: E402,F401
|
||||||
|
from app.api.routes.dashboard import get_material_lot_lifecycle, list_dashboard_material_lots # noqa: E402
|
||||||
|
from app.models.base import Base # noqa: E402
|
||||||
|
from app.models.master_data import Item, Warehouse # noqa: E402
|
||||||
|
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402
|
||||||
|
from app.models.org import Department, Employee, SystemConfig # noqa: E402
|
||||||
|
from app.schemas.dashboard import DashboardMaterialLotRead # noqa: E402
|
||||||
|
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardMaterialLotLifecycleTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
self.db: Session = self.SessionLocal()
|
||||||
|
self.now = datetime(2026, 6, 14, 9, 0)
|
||||||
|
self._seed_lot_and_ledger()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def _seed_lot_and_ledger(self) -> None:
|
||||||
|
raw_item = Item(
|
||||||
|
id=1,
|
||||||
|
item_code="RM-001",
|
||||||
|
item_name="冷轧钢板",
|
||||||
|
item_type="RAW_MATERIAL",
|
||||||
|
unit_weight_kg=Decimal("1"),
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
product = Item(
|
||||||
|
id=2,
|
||||||
|
item_code="FG-001",
|
||||||
|
item_name="钢制碗",
|
||||||
|
item_type="FINISHED_GOOD",
|
||||||
|
unit_weight_kg=Decimal("0.5"),
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
warehouse = Warehouse(
|
||||||
|
id=1,
|
||||||
|
warehouse_code="WH-RAW",
|
||||||
|
warehouse_name="原材料库",
|
||||||
|
warehouse_type="RAW",
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
lot = StockLot(
|
||||||
|
id=1,
|
||||||
|
lot_no="YL0001",
|
||||||
|
lot_role="RAW_MATERIAL",
|
||||||
|
item_id=1,
|
||||||
|
warehouse_id=1,
|
||||||
|
source_doc_type="期初入库",
|
||||||
|
source_doc_id=1,
|
||||||
|
inbound_qty=Decimal("0"),
|
||||||
|
inbound_weight_kg=Decimal("500"),
|
||||||
|
remaining_qty=Decimal("0"),
|
||||||
|
remaining_weight_kg=Decimal("300"),
|
||||||
|
locked_qty=Decimal("0"),
|
||||||
|
locked_weight_kg=Decimal("0"),
|
||||||
|
unit_cost=Decimal("4"),
|
||||||
|
quality_status="PASS",
|
||||||
|
status="AVAILABLE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
ledger = ProductionBatchLedger(
|
||||||
|
id=1,
|
||||||
|
material_lot_id=1,
|
||||||
|
material_lot_no="YL0001",
|
||||||
|
material_item_id=1,
|
||||||
|
product_item_id=2,
|
||||||
|
status="在生产",
|
||||||
|
miniapp_selectable=1,
|
||||||
|
total_issued_weight_kg=Decimal("120"),
|
||||||
|
outside_weight_kg=Decimal("42"),
|
||||||
|
finished_inbound_qty=Decimal("100"),
|
||||||
|
surplus_inbound_weight_kg=Decimal("18"),
|
||||||
|
scrap_inbound_weight_kg=Decimal("6"),
|
||||||
|
writeoff_weight_kg=Decimal("54"),
|
||||||
|
first_issue_time=self.now,
|
||||||
|
last_issue_time=self.now,
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
dept = Department(
|
||||||
|
id=1,
|
||||||
|
dept_code="D-001",
|
||||||
|
dept_name="生产部",
|
||||||
|
dept_type="PRODUCTION",
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
id=1,
|
||||||
|
employee_code="EMP-001",
|
||||||
|
employee_name="张三",
|
||||||
|
dept_id=1,
|
||||||
|
is_operator=1,
|
||||||
|
is_workshop_staff=1,
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
txns = [
|
||||||
|
ProductionBatchLedgerTxn(
|
||||||
|
id=1,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="生产出库",
|
||||||
|
qty_delta=Decimal("0"),
|
||||||
|
weight_delta_kg=Decimal("120"),
|
||||||
|
outside_weight_after_kg=Decimal("120"),
|
||||||
|
source_doc_type="库存流水",
|
||||||
|
source_doc_id=101,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="生产出库累计到生产台账",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
),
|
||||||
|
ProductionBatchLedgerTxn(
|
||||||
|
id=2,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="成品入库",
|
||||||
|
qty_delta=Decimal("100"),
|
||||||
|
weight_delta_kg=Decimal("-50"),
|
||||||
|
outside_weight_after_kg=Decimal("70"),
|
||||||
|
source_doc_type="库存流水",
|
||||||
|
source_doc_id=102,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="单据批次号:PB20260614001",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
),
|
||||||
|
ProductionBatchLedgerTxn(
|
||||||
|
id=3,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="生产余料入库",
|
||||||
|
qty_delta=Decimal("0"),
|
||||||
|
weight_delta_kg=Decimal("-18"),
|
||||||
|
outside_weight_after_kg=Decimal("52"),
|
||||||
|
source_doc_type="库存流水",
|
||||||
|
source_doc_id=103,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="单据批次号:PB20260614001",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
),
|
||||||
|
ProductionBatchLedgerTxn(
|
||||||
|
id=4,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="生产废料入库",
|
||||||
|
qty_delta=Decimal("0"),
|
||||||
|
weight_delta_kg=Decimal("-6"),
|
||||||
|
outside_weight_after_kg=Decimal("46"),
|
||||||
|
source_doc_type="库存流水",
|
||||||
|
source_doc_id=104,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="单据批次号:PB20260614001",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
),
|
||||||
|
ProductionBatchLedgerTxn(
|
||||||
|
id=5,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="结单核销",
|
||||||
|
qty_delta=Decimal("0"),
|
||||||
|
weight_delta_kg=Decimal("-4"),
|
||||||
|
outside_weight_after_kg=Decimal("42"),
|
||||||
|
source_doc_type="结单",
|
||||||
|
source_doc_id=105,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="单据批次号:PB20260614001",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
report = OperationReport(
|
||||||
|
id=1,
|
||||||
|
report_no="BG20260614001",
|
||||||
|
production_ledger_id=1,
|
||||||
|
employee_id=1,
|
||||||
|
report_source="MINIAPP",
|
||||||
|
start_time=self.now,
|
||||||
|
end_time=self.now,
|
||||||
|
report_qty=Decimal("100"),
|
||||||
|
good_qty=Decimal("96"),
|
||||||
|
scrap_qty=Decimal("4"),
|
||||||
|
rework_qty=Decimal("0"),
|
||||||
|
downtime_minutes=Decimal("0"),
|
||||||
|
extra_cost_amount=Decimal("0"),
|
||||||
|
is_abnormal=0,
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
self.db.add_all([raw_item, product, warehouse, lot, ledger, dept, employee, *txns, report])
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def _lot_read(self) -> DashboardMaterialLotRead:
|
||||||
|
return DashboardMaterialLotRead(
|
||||||
|
lot_id=1,
|
||||||
|
lot_no="YL0001",
|
||||||
|
material_item_id=1,
|
||||||
|
material_code="RM-001",
|
||||||
|
material_name="冷轧钢板",
|
||||||
|
warehouse_name="原材料库",
|
||||||
|
inbound_weight_kg=500,
|
||||||
|
remaining_weight_kg=300,
|
||||||
|
unit_cost=4,
|
||||||
|
inbound_amount=2000,
|
||||||
|
issued_batch_count=0,
|
||||||
|
issued_weight_kg=0,
|
||||||
|
finished_batch_count=0,
|
||||||
|
finished_qty=0,
|
||||||
|
delivery_count=0,
|
||||||
|
status="AVAILABLE",
|
||||||
|
quality_status="PASS",
|
||||||
|
created_at=self.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_production_ledger_graph_replaces_work_order_main_chain(self) -> None:
|
||||||
|
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||||
|
|
||||||
|
node_types = {node.type for node in nodes}
|
||||||
|
self.assertIn("production_ledger", node_types)
|
||||||
|
self.assertIn("production_issue_txn", node_types)
|
||||||
|
self.assertIn("ledger_outside_balance", node_types)
|
||||||
|
self.assertIn("production_ledger_settlement", node_types)
|
||||||
|
self.assertIn("finished_inbound", node_types)
|
||||||
|
self.assertIn("surplus_inbound", node_types)
|
||||||
|
self.assertIn("scrap_inbound", node_types)
|
||||||
|
self.assertNotIn("work_order", node_types)
|
||||||
|
|
||||||
|
def test_production_ledger_title_stage_and_outside_balance_metric(self) -> None:
|
||||||
|
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||||
|
|
||||||
|
ledger_node = next(node for node in nodes if node.type == "production_ledger")
|
||||||
|
metrics = {item.label: item.value for item in ledger_node.metrics}
|
||||||
|
self.assertEqual(ledger_node.title, "YL0001 · 钢制碗")
|
||||||
|
self.assertEqual(ledger_node.stage, "生产台账")
|
||||||
|
self.assertEqual(metrics["库外余额"], "42 kg")
|
||||||
|
|
||||||
|
def test_material_lot_dashboard_list_excludes_scrap_warehouse_lots(self) -> None:
|
||||||
|
scrap_warehouse = Warehouse(
|
||||||
|
id=3,
|
||||||
|
warehouse_code="WH-SCRAP",
|
||||||
|
warehouse_name="废料库",
|
||||||
|
warehouse_type="SCRAP",
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
scrap_lot = StockLot(
|
||||||
|
id=30,
|
||||||
|
lot_no="SCRAPI-YL0001-001",
|
||||||
|
lot_role="PRODUCTION_SCRAP",
|
||||||
|
item_id=1,
|
||||||
|
warehouse_id=3,
|
||||||
|
source_doc_type="PRODUCTION_SCRAP_IN",
|
||||||
|
source_doc_id=1,
|
||||||
|
inbound_qty=Decimal("0"),
|
||||||
|
inbound_weight_kg=Decimal("5"),
|
||||||
|
remaining_qty=Decimal("0"),
|
||||||
|
remaining_weight_kg=Decimal("5"),
|
||||||
|
locked_qty=Decimal("0"),
|
||||||
|
locked_weight_kg=Decimal("0"),
|
||||||
|
unit_cost=Decimal("4"),
|
||||||
|
quality_status="SCRAP",
|
||||||
|
status="AVAILABLE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
self.db.add_all([scrap_warehouse, scrap_lot])
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
rows = list_dashboard_material_lots(limit=100, db=self.db)
|
||||||
|
|
||||||
|
lot_nos = {row.lot_no for row in rows}
|
||||||
|
self.assertIn("YL0001", lot_nos)
|
||||||
|
self.assertNotIn("SCRAPI-YL0001-001", lot_nos)
|
||||||
|
self.assertTrue(all(row.warehouse_name == "原材料库" for row in rows))
|
||||||
|
|
||||||
|
def test_operation_report_respects_smart_operation_report_switch(self) -> None:
|
||||||
|
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||||
|
self.assertIn("operation_report", {node.type for node in nodes})
|
||||||
|
|
||||||
|
self.db.add(
|
||||||
|
SystemConfig(
|
||||||
|
config_code="SMART_OPERATION_REPORT_ENABLED",
|
||||||
|
config_name="对接智能报工小程序",
|
||||||
|
config_value="关闭",
|
||||||
|
status="ACTIVE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||||
|
self.assertNotIn("operation_report", {node.type for node in nodes})
|
||||||
|
|
||||||
|
def test_route_returns_new_graph_when_production_ledger_exists(self) -> None:
|
||||||
|
response = get_material_lot_lifecycle(1, self.db)
|
||||||
|
|
||||||
|
node_types = {node.type for node in response.nodes}
|
||||||
|
self.assertIn("production_ledger", node_types)
|
||||||
|
self.assertNotIn("work_order", node_types)
|
||||||
|
|
||||||
|
def test_route_legacy_fallback_does_not_crash_without_production_ledger(self) -> None:
|
||||||
|
legacy_lot = StockLot(
|
||||||
|
id=2,
|
||||||
|
lot_no="YL9999",
|
||||||
|
lot_role="RAW_MATERIAL",
|
||||||
|
item_id=1,
|
||||||
|
warehouse_id=1,
|
||||||
|
source_doc_type="期初入库",
|
||||||
|
source_doc_id=2,
|
||||||
|
inbound_qty=Decimal("0"),
|
||||||
|
inbound_weight_kg=Decimal("80"),
|
||||||
|
remaining_qty=Decimal("0"),
|
||||||
|
remaining_weight_kg=Decimal("80"),
|
||||||
|
locked_qty=Decimal("0"),
|
||||||
|
locked_weight_kg=Decimal("0"),
|
||||||
|
unit_cost=Decimal("4"),
|
||||||
|
quality_status="PASS",
|
||||||
|
status="AVAILABLE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
self.db.add(legacy_lot)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
response = get_material_lot_lifecycle(2, self.db)
|
||||||
|
|
||||||
|
self.assertEqual(response.lot.lot_no, "YL9999")
|
||||||
|
node_types = {node.type for node in response.nodes}
|
||||||
|
self.assertIn("raw_material_lot", node_types)
|
||||||
|
self.assertNotIn("production_ledger", node_types)
|
||||||
|
|
||||||
|
def test_delivery_edges_target_finished_inbound_for_exact_finished_lot(self) -> None:
|
||||||
|
finished_lot_a = StockLot(
|
||||||
|
id=10,
|
||||||
|
lot_no="FGL-A",
|
||||||
|
lot_role="INVENTORY",
|
||||||
|
item_id=2,
|
||||||
|
warehouse_id=1,
|
||||||
|
source_doc_type="PRODUCTION_LEDGER_IN",
|
||||||
|
source_doc_id=1,
|
||||||
|
inbound_qty=Decimal("40"),
|
||||||
|
inbound_weight_kg=Decimal("20"),
|
||||||
|
remaining_qty=Decimal("40"),
|
||||||
|
remaining_weight_kg=Decimal("20"),
|
||||||
|
locked_qty=Decimal("0"),
|
||||||
|
locked_weight_kg=Decimal("0"),
|
||||||
|
unit_cost=Decimal("8"),
|
||||||
|
quality_status="PASS",
|
||||||
|
status="AVAILABLE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
finished_lot_b = StockLot(
|
||||||
|
id=11,
|
||||||
|
lot_no="FGL-B",
|
||||||
|
lot_role="INVENTORY",
|
||||||
|
item_id=2,
|
||||||
|
warehouse_id=1,
|
||||||
|
source_doc_type="PRODUCTION_LEDGER_IN",
|
||||||
|
source_doc_id=1,
|
||||||
|
inbound_qty=Decimal("60"),
|
||||||
|
inbound_weight_kg=Decimal("30"),
|
||||||
|
remaining_qty=Decimal("0"),
|
||||||
|
remaining_weight_kg=Decimal("0"),
|
||||||
|
locked_qty=Decimal("0"),
|
||||||
|
locked_weight_kg=Decimal("0"),
|
||||||
|
unit_cost=Decimal("8"),
|
||||||
|
quality_status="PASS",
|
||||||
|
status="AVAILABLE",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
finished_txn_a = ProductionBatchLedgerTxn(
|
||||||
|
id=20,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="成品入库",
|
||||||
|
qty_delta=Decimal("40"),
|
||||||
|
weight_delta_kg=Decimal("-20"),
|
||||||
|
outside_weight_after_kg=Decimal("70"),
|
||||||
|
source_doc_type="库存流水",
|
||||||
|
source_doc_id=201,
|
||||||
|
source_line_id=10,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="单据批次号:PB-FGL-A",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
finished_txn_b = ProductionBatchLedgerTxn(
|
||||||
|
id=21,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="成品入库",
|
||||||
|
qty_delta=Decimal("60"),
|
||||||
|
weight_delta_kg=Decimal("-30"),
|
||||||
|
outside_weight_after_kg=Decimal("42"),
|
||||||
|
source_doc_type="库存流水",
|
||||||
|
source_doc_id=202,
|
||||||
|
source_line_id=11,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="单据批次号:PB-FGL-B",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
delivery = Delivery(
|
||||||
|
id=1,
|
||||||
|
delivery_no="FH20260614001",
|
||||||
|
warehouse_id=1,
|
||||||
|
delivery_date=self.now,
|
||||||
|
status="POSTED",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
delivery_item = DeliveryItem(
|
||||||
|
id=1,
|
||||||
|
delivery_id=1,
|
||||||
|
line_no=1,
|
||||||
|
product_item_id=2,
|
||||||
|
lot_id=11,
|
||||||
|
delivery_qty=Decimal("10"),
|
||||||
|
delivery_weight_kg=Decimal("5"),
|
||||||
|
unit_price=Decimal("12"),
|
||||||
|
line_amount=Decimal("120"),
|
||||||
|
status="POSTED",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
self.db.add_all([finished_lot_a, finished_lot_b, finished_txn_a, finished_txn_b, delivery, delivery_item])
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
nodes, edges = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||||
|
|
||||||
|
delivery_node = next(node for node in nodes if node.type == "delivery")
|
||||||
|
finished_a = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-A")
|
||||||
|
finished_b = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-B")
|
||||||
|
delivery_source_ids = {edge.source for edge in edges if edge.target == delivery_node.id}
|
||||||
|
self.assertIn(finished_b.id, delivery_source_ids)
|
||||||
|
self.assertNotIn(finished_a.id, delivery_source_ids)
|
||||||
|
self.assertNotIn("ledger-1", delivery_source_ids)
|
||||||
|
|
||||||
|
def test_reopen_txn_uses_reopen_node_type_not_writeoff(self) -> None:
|
||||||
|
self.db.add(
|
||||||
|
ProductionBatchLedgerTxn(
|
||||||
|
id=30,
|
||||||
|
production_ledger_id=1,
|
||||||
|
txn_type="重新开工",
|
||||||
|
qty_delta=Decimal("0"),
|
||||||
|
weight_delta_kg=Decimal("0"),
|
||||||
|
outside_weight_after_kg=Decimal("42"),
|
||||||
|
source_doc_type="生产台账",
|
||||||
|
source_doc_id=1,
|
||||||
|
biz_time=self.now,
|
||||||
|
operator_user_id=1,
|
||||||
|
remark="重新开工",
|
||||||
|
created_at=self.now,
|
||||||
|
updated_at=self.now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||||||
|
|
||||||
|
reopen_node = next(node for node in nodes if node.title == "重新开工")
|
||||||
|
self.assertEqual(reopen_node.type, "ledger_reopen")
|
||||||
|
self.assertEqual(reopen_node.stage, "重新开工")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -43,6 +43,7 @@ from app.services.document_archives import ( # noqa: E402
|
|||||||
get_latest_archive,
|
get_latest_archive,
|
||||||
render_pdf_archive,
|
render_pdf_archive,
|
||||||
_archive_table_columns,
|
_archive_table_columns,
|
||||||
|
_draw_archive_page_frame,
|
||||||
_draw_cell,
|
_draw_cell,
|
||||||
_draw_archive_footer,
|
_draw_archive_footer,
|
||||||
)
|
)
|
||||||
@ -732,6 +733,77 @@ class DocumentArchiveServiceTest(unittest.TestCase):
|
|||||||
rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size)
|
rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size)
|
||||||
self.assertLessEqual(rendered_width, 54)
|
self.assertLessEqual(rendered_width, 54)
|
||||||
|
|
||||||
|
def test_draw_archive_page_frame_wraps_long_metadata_inside_columns(self) -> None:
|
||||||
|
class FakePdf:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.font_name = "Helvetica"
|
||||||
|
self.size = 10
|
||||||
|
self.drawn_texts: list[tuple[float, float, str, str, int]] = []
|
||||||
|
|
||||||
|
def setFillColorRGB(self, *args, **kwargs) -> None:
|
||||||
|
_ = args, kwargs
|
||||||
|
|
||||||
|
def rect(self, *args, **kwargs) -> None:
|
||||||
|
_ = args, kwargs
|
||||||
|
|
||||||
|
def setStrokeColor(self, *args, **kwargs) -> None:
|
||||||
|
_ = args, kwargs
|
||||||
|
|
||||||
|
def setStrokeColorRGB(self, *args, **kwargs) -> None:
|
||||||
|
_ = args, kwargs
|
||||||
|
|
||||||
|
def setFillColor(self, *args, **kwargs) -> None:
|
||||||
|
_ = args, kwargs
|
||||||
|
|
||||||
|
def setFont(self, font_name, size) -> None:
|
||||||
|
self.font_name = font_name
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
def drawRightString(self, x, y, text) -> None:
|
||||||
|
_ = x, y, text
|
||||||
|
|
||||||
|
def drawCentredString(self, x, y, text) -> None:
|
||||||
|
_ = x, y, text
|
||||||
|
|
||||||
|
def drawString(self, x, y, text) -> None:
|
||||||
|
self.drawn_texts.append((x, y, text, self.font_name, self.size))
|
||||||
|
|
||||||
|
def rotate(self, *args, **kwargs) -> None:
|
||||||
|
_ = args, kwargs
|
||||||
|
|
||||||
|
fake_pdf = FakePdf()
|
||||||
|
context = ArchiveContext(
|
||||||
|
document_type=DOCUMENT_TYPE_SALES_ORDER,
|
||||||
|
business_id=100,
|
||||||
|
document_no="生产领料-JH_不锈钢板-用于测试00101_0001-苹果电脑后盖-超长批次-客户特殊版本-返工追溯链路",
|
||||||
|
title="生产领料出库单归档",
|
||||||
|
partner_label="生产产品",
|
||||||
|
partner_name="苹果电脑后盖-测试",
|
||||||
|
document_date=date(2026, 6, 11),
|
||||||
|
due_date_label="生产台账",
|
||||||
|
due_date=2,
|
||||||
|
status="在生产",
|
||||||
|
tax_rate=0,
|
||||||
|
total_amount=636,
|
||||||
|
contact_name="",
|
||||||
|
contact_phone="",
|
||||||
|
address="原材料库",
|
||||||
|
)
|
||||||
|
|
||||||
|
table_y = _draw_archive_page_frame(fake_pdf, context, "Helvetica", "Helvetica-Bold", 1, 1)
|
||||||
|
|
||||||
|
metadata_lines = [
|
||||||
|
(text, font_name, size)
|
||||||
|
for x, y, text, font_name, size in fake_pdf.drawn_texts
|
||||||
|
if x == 42 and y > A4[1] - 190
|
||||||
|
]
|
||||||
|
document_no_lines = [text for text, _, _ in metadata_lines if "单据编号" in text or "返工追溯" in text]
|
||||||
|
self.assertGreaterEqual(len(document_no_lines), 2)
|
||||||
|
self.assertLess(table_y, A4[1] - 190)
|
||||||
|
for text, font_name, size in metadata_lines:
|
||||||
|
rendered_width = pdfmetrics.stringWidth(text, font_name, size)
|
||||||
|
self.assertLessEqual(rendered_width, 288)
|
||||||
|
|
||||||
def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None:
|
def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None:
|
||||||
order = self._seed_sales_order()
|
order = self._seed_sales_order()
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, create_engine, func, select
|
||||||
|
from sqlalchemy.ext.compiler import compiles
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(BigInteger, "sqlite")
|
||||||
|
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
|
||||||
|
_ = type_, compiler, kw
|
||||||
|
return "INTEGER"
|
||||||
|
|
||||||
|
|
||||||
|
import app.models.master_data # noqa: E402,F401
|
||||||
|
import app.models.miniapp # noqa: E402,F401
|
||||||
|
import app.models.operations # noqa: E402,F401
|
||||||
|
import app.models.org # noqa: E402,F401
|
||||||
|
import app.models.planning # noqa: E402,F401
|
||||||
|
import app.models.sales # noqa: E402,F401
|
||||||
|
from app.api.routes import master_data # noqa: E402
|
||||||
|
from app.models.base import Base # noqa: E402
|
||||||
|
from app.models.operations import Process, WorkCenter # noqa: E402
|
||||||
|
from app.models.org import Department # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class MasterDataDefaultOperationFoundationTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
self.db: Session = self.SessionLocal()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_list_processes_seeds_default_process_for_empty_system(self) -> None:
|
||||||
|
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 0)
|
||||||
|
|
||||||
|
rows = master_data.list_processes(limit=100, db=self.db)
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0].process_code, "PROC-DEFAULT")
|
||||||
|
self.assertEqual(rows[0].process_name, "系统默认工序")
|
||||||
|
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 1)
|
||||||
|
|
||||||
|
def test_list_work_centers_seeds_default_work_center_for_empty_system(self) -> None:
|
||||||
|
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 0)
|
||||||
|
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 0)
|
||||||
|
|
||||||
|
rows = master_data.list_work_centers(limit=100, db=self.db)
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0].center_code, "WC-DEFAULT")
|
||||||
|
self.assertEqual(rows[0].center_name, "系统默认工作中心")
|
||||||
|
self.assertEqual(rows[0].dept_name, "总公司")
|
||||||
|
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 1)
|
||||||
|
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 1)
|
||||||
@ -398,6 +398,32 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
|||||||
self.assertEqual(tree[0].children[0].parent_id, self.company.id)
|
self.assertEqual(tree[0].children[0].parent_id, self.company.id)
|
||||||
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
|
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
|
||||||
|
|
||||||
|
def test_create_org_node_allows_company_direct_department(self) -> None:
|
||||||
|
from app.schemas.system_permissions import OrgNodeCreate
|
||||||
|
from app.services.system_permissions import create_org_node, build_org_tree
|
||||||
|
|
||||||
|
direct_department = create_org_node(
|
||||||
|
self.db,
|
||||||
|
OrgNodeCreate(
|
||||||
|
parent_id=self.company.id,
|
||||||
|
node_type="DEPARTMENT",
|
||||||
|
node_label="总公司直属部门",
|
||||||
|
dept_code="DIRECT-DEPT",
|
||||||
|
dept_type="ADMIN",
|
||||||
|
manager_employee_id=None,
|
||||||
|
sort_no=1,
|
||||||
|
remark=None,
|
||||||
|
status="ACTIVE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
tree = build_org_tree(self.db)
|
||||||
|
self.assertEqual(direct_department.parent_id, self.company.id)
|
||||||
|
self.assertEqual(direct_department.node_type, "DEPARTMENT")
|
||||||
|
self.assertTrue(
|
||||||
|
any(child.node_label == "总公司直属部门" and child.node_type == "DEPARTMENT" for child in tree[0].children)
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_org_node_rejects_invalid_child_level(self) -> None:
|
def test_create_org_node_rejects_invalid_child_level(self) -> None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.schemas.system_permissions import OrgNodeCreate
|
from app.schemas.system_permissions import OrgNodeCreate
|
||||||
@ -408,9 +434,9 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
|||||||
self.db,
|
self.db,
|
||||||
OrgNodeCreate(
|
OrgNodeCreate(
|
||||||
parent_id=self.company.id,
|
parent_id=self.company.id,
|
||||||
node_type="DEPARTMENT",
|
node_type="GROUP",
|
||||||
node_label="错误部门",
|
node_label="错误小组",
|
||||||
dept_code="BAD-DEPT",
|
dept_code="BAD-GROUP",
|
||||||
dept_type="ADMIN",
|
dept_type="ADMIN",
|
||||||
manager_employee_id=None,
|
manager_employee_id=None,
|
||||||
sort_no=1,
|
sort_no=1,
|
||||||
@ -419,7 +445,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(ctx.exception.status_code, 400)
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
self.assertIn("总公司下只能新增分公司", str(ctx.exception.detail))
|
self.assertIn("总公司下只能新增分公司或直属部门", str(ctx.exception.detail))
|
||||||
|
|
||||||
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
|
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,212 @@
|
|||||||
|
# Warehouse Paper Form Unification Phase 1 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make warehouse operation documents use one cohesive paper-form visual language, starting with the production-ledger controlled inbound forms that currently mix modern web controls into the paper sheet.
|
||||||
|
|
||||||
|
**Architecture:** Keep all business state, computed values, validation, and submit payloads in `InventoryLedgerView.vue` unchanged. Introduce paper-specific wrappers/classes around the existing controls so selectors, selected-lot tables, live summaries, remarks, and settle controls visually belong to the same document grid as the approved “客料入库单”. Avoid global restyling of `.form-field`, `.double-field`, `.table-wrap`, or shared controls used outside paper documents.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 SFC, Vite, project CSS in `frontend/src/styles/main.css`, Node built-in test runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `frontend/src/views/InventoryLedgerView.vue`: convert the generic warehouse drawer’s production-ledger controlled inbound branch to paper-specific markup while preserving existing bindings and branch conditions.
|
||||||
|
- Modify `frontend/src/styles/main.css`: add reusable paper document classes for control grids, inline summary rows, source-lot selector/table panels, and settle confirmation rows.
|
||||||
|
- Modify `frontend/src/views/InventoryLedgerView.test.js`: add regression coverage that production surplus/scrap inbound forms use the paper-specific classes and that the old naked modern classes are not the only visual contract.
|
||||||
|
|
||||||
|
## Design Direction
|
||||||
|
|
||||||
|
- Aesthetic: “工业纸质单据”.
|
||||||
|
- Purpose: Support fast warehouse entry while making each operation feel like a formal business document, not a demo web form.
|
||||||
|
- Differentiation anchor: Thick ledger grid lines, label/value cells, stamped meta strips, and paper-native control panels.
|
||||||
|
- DFII: 13. Impact 4, fit 5, feasibility 5, performance 4, consistency risk 5. This is strong because it reuses the approved客料入库 visual system, but must be scoped to avoid touching unrelated app forms.
|
||||||
|
|
||||||
|
## Task 1: Lock The Regression Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
|
||||||
|
- Read: `frontend/src/views/InventoryLedgerView.vue`
|
||||||
|
- Read: `frontend/src/styles/main.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add paper-form assertions for production-ledger controlled inbound**
|
||||||
|
|
||||||
|
Add a test that checks the generic drawer contains production-ledger paper classes and keeps the old branch visually wrapped:
|
||||||
|
|
||||||
|
```js
|
||||||
|
it("wraps production-ledger controlled inbound forms in the shared paper control language", () => {
|
||||||
|
assert.match(source, /warehouse-paper-control-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(source, /warehouse-paper-inline-summary/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-toggle/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-remark/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-control-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-control-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-settle-toggle/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test and confirm it fails before implementation**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
node --test src/views/InventoryLedgerView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected before implementation: failure mentioning one or more missing `warehouse-paper-*` classes.
|
||||||
|
|
||||||
|
## Task 2: Convert Production Controlled Inbound Markup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the naked production work-order block**
|
||||||
|
|
||||||
|
Replace the `isProductionWorkOrderControlledInbound` `stack-form` block with paper-grid fields:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="isProductionWorkOrderControlledInbound" class="warehouse-paper-control-grid warehouse-paper-control-grid-compact">
|
||||||
|
<label class="warehouse-paper-field warehouse-paper-field-wide">
|
||||||
|
<span>{{ isReworkScrapInbound ? "返工工单" : "生产台账" }}</span>
|
||||||
|
<select v-model.number="genericForm.work_order_id" required @change="applyGenericWorkOrder">
|
||||||
|
<option disabled value="">{{ isReworkScrapInbound ? "请选择返工工单" : "请选择生产台账" }}</option>
|
||||||
|
<option v-for="workOrder in genericWorkOrderOptions" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
|
||||||
|
{{ formatWorkOrderLedgerOption(workOrder, activeOperation?.bizType) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-if="selectedGenericWorkOrder">
|
||||||
|
<article class="warehouse-paper-summary-card warehouse-paper-summary-card-wide">
|
||||||
|
<span>产品</span>
|
||||||
|
<strong>{{ selectedGenericWorkOrder.product_code }} · {{ selectedGenericWorkOrder.product_name }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="warehouse-paper-summary-card">
|
||||||
|
<span>材料库存批次号</span>
|
||||||
|
<strong>{{ selectedGenericWorkOrder.source_lot_summary || selectedGenericWorkOrder.material_lot_no || "-" }}</strong>
|
||||||
|
</article>
|
||||||
|
<article v-if="isProductionScrapInbound" class="warehouse-paper-summary-card">
|
||||||
|
<span>库外未闭环重量</span>
|
||||||
|
<strong>{{ formatWeight(selectedGenericWorkOrder.outside_weight_kg || selectedGenericWorkOrder.limits?.max_scrap_inbound_weight_kg) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article v-if="isProductionSurplusInbound" class="warehouse-paper-summary-card">
|
||||||
|
<span>库外未闭环重量</span>
|
||||||
|
<strong>{{ formatWeight(selectedGenericWorkOrder.outside_weight_kg || selectedGenericWorkOrder.limits?.max_surplus_inbound_weight_kg) }}</strong>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Convert the core fields to a paper grid**
|
||||||
|
|
||||||
|
Replace the main `double-field` and `triple-field` groups in the generic drawer with `warehouse-paper-control-grid`, using the existing exact conditions and v-models. Keep field order: item, warehouse, location, measure basis, weight, quantity, unit cost.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wrap source-lot selector and tables in paper panels**
|
||||||
|
|
||||||
|
Keep `StockLotTagSelect` unchanged, but place it inside:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="usesGenericSourceLotSelector" class="warehouse-paper-source-lot-panel">
|
||||||
|
<div class="warehouse-paper-source-lot-heading">
|
||||||
|
<span>来源库存批次号</span>
|
||||||
|
<strong>{{ genericSourceLotSelectPlaceholder }}</strong>
|
||||||
|
</div>
|
||||||
|
<StockLotTagSelect ... />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
For selected lot tables, keep the same columns and inputs but add `warehouse-paper-source-lot-table` to the wrapping table container so CSS can remove the ordinary web-card feel.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Convert summaries and settle controls**
|
||||||
|
|
||||||
|
Replace `inventory-live-summary` display with `warehouse-paper-inline-summary`, and replace `work-order-settle-panel` / `work-order-settle-toggle` with paper-specific classes. Keep `genericForm.settle_work_order`, deviation values, and `genericForm.settle_remark` unchanged.
|
||||||
|
|
||||||
|
## Task 3: Add Paper-Specific CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/styles/main.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add grid spanning rules**
|
||||||
|
|
||||||
|
Add the new paper containers beside existing `.warehouse-paper-grid` rules:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-control-grid,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-source-lot-panel,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-source-lot-table,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-inline-summary,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-settle-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reuse approved paper field and summary cells**
|
||||||
|
|
||||||
|
Make `.warehouse-paper-control-grid` visually match `.warehouse-paper-grid`, with four columns on desktop, two on tablet, and one on mobile. Make `.warehouse-paper-control-grid-compact` allow summary cards to live in the same grid.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Style selector/table/settle panels as document rows**
|
||||||
|
|
||||||
|
Style:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.warehouse-paper-source-lot-panel
|
||||||
|
.warehouse-paper-source-lot-heading
|
||||||
|
.warehouse-paper-source-lot-table
|
||||||
|
.warehouse-paper-inline-summary
|
||||||
|
.warehouse-paper-settle-panel
|
||||||
|
.warehouse-paper-settle-toggle
|
||||||
|
.warehouse-paper-settle-grid
|
||||||
|
.warehouse-paper-settle-remark
|
||||||
|
```
|
||||||
|
|
||||||
|
They should use document borders, paper backgrounds, Songti/Simsun value text where appropriate, and no floating modern-card shadows.
|
||||||
|
|
||||||
|
## Task 4: Verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `frontend/src/views/InventoryLedgerView.test.js`
|
||||||
|
- Build: `frontend/package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused view tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
node --test src/views/InventoryLedgerView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run production build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Vite build succeeds. Existing chunk-size warning is acceptable if it remains the same non-blocking warning.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Browser verification**
|
||||||
|
|
||||||
|
Open `http://localhost:5173/inventory-ledger`, then inspect:
|
||||||
|
|
||||||
|
- 原材料库 · 客料入库 remains unchanged and approved.
|
||||||
|
- 原材料库 · 生产余料入库 uses the same paper-grid visual language.
|
||||||
|
- The source-lot multi-select, selected-lot table, remark field, and “该批材料余料结单” no longer look like separate modern web cards pasted onto paper.
|
||||||
|
- Existing submit button and drawer behavior still work.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: covers the user’s requested recommended approach by converting the most visible mixed-UI warehouse document first and creating reusable paper classes for subsequent warehouse forms.
|
||||||
|
- Placeholder scan: no TBD/TODO/implement-later placeholders.
|
||||||
|
- Type consistency: class names in tests, markup, and CSS use the same `warehouse-paper-*` prefix.
|
||||||
1749
docs/superpowers/plans/2026-06-14-material-batch-lifecycle-dag.md
Normal file
1749
docs/superpowers/plans/2026-06-14-material-batch-lifecycle-dag.md
Normal file
File diff suppressed because it is too large
Load Diff
1410
docs/superpowers/plans/2026-06-14-system-extension-workbench.md
Normal file
1410
docs/superpowers/plans/2026-06-14-system-extension-workbench.md
Normal file
File diff suppressed because it is too large
Load Diff
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal file
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
# 组织树与脑图操作能力一致 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让“组织树模式”和“脑图模式”只是展示形式不同,组织节点和人员节点的操作能力完全一致。
|
||||||
|
|
||||||
|
**Architecture:** 保留 `SystemPermissionView.vue` 作为唯一组织动作编排层,继续复用现有 `nodeActions(node)`、`runNodeAction(actionKey, node)`、各类 drawer 打开函数和删除/授权函数。`OrgPermissionTree.vue` 只负责展示组织树、选中节点、发出节点菜单事件,不新增第二套业务动作逻辑。
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 `<script setup>`、现有静态 Node test、Vite、现有 `OrgPermissionTree.vue` / `SystemPermissionView.vue` 组件体系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify: `frontend/src/components/OrgPermissionTree.vue`
|
||||||
|
- 增加右侧“节点操作”入口。
|
||||||
|
- 继续支持组织树节点右键菜单。
|
||||||
|
- 通过 `node-contextmenu` emit 当前节点和菜单坐标,不直接执行业务动作。
|
||||||
|
- 调整右侧 header 按钮布局,让“节点操作”和“新增人员”共存。
|
||||||
|
|
||||||
|
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
|
||||||
|
- 锁定组织树存在“节点操作”按钮。
|
||||||
|
- 锁定组织树节点右键仍然 emit `open-menu`。
|
||||||
|
- 锁定组织树不新增第二套动作函数,只 emit `node-contextmenu`。
|
||||||
|
|
||||||
|
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||||||
|
- 复用现有 `openContextMenu(eventPayload)` 和 `runNodeAction(actionKey, node)`。
|
||||||
|
- 必要时给当前节点操作入口提供更稳定的菜单坐标处理。
|
||||||
|
- 不复制 `nodeActions` 到子组件。
|
||||||
|
|
||||||
|
- Modify: `frontend/src/views/SystemPermissionView.test.js`
|
||||||
|
- 锁定组织树仍绑定 `@node-contextmenu="openContextMenu"`。
|
||||||
|
- 锁定组织树的“节点操作”最终仍进入同一套 `contextMenuActions` / `runNodeAction`。
|
||||||
|
|
||||||
|
- Modify: `frontend/src/styles/main.css` if needed
|
||||||
|
- 如果组件 scoped 样式不够覆盖全局表格/抽屉样式,补少量全局样式。
|
||||||
|
- 不引入新的视觉体系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 锁定组织树动作入口契约
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试,要求组织树有显式节点操作入口**
|
||||||
|
|
||||||
|
在 `OrgPermissionTree.test.js` 末尾追加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
describe("OrgPermissionTree node action parity", () => {
|
||||||
|
it("exposes a visible current-node action entry that emits the shared node context menu event", () => {
|
||||||
|
assert.match(source, /节点操作/);
|
||||||
|
assert.match(source, /@click="openSelectedNodeMenu"/);
|
||||||
|
assert.match(source, /function openSelectedNodeMenu\(event\)/);
|
||||||
|
assert.match(source, /emit\("node-contextmenu"/);
|
||||||
|
assert.doesNotMatch(source, /function runNodeAction\(/);
|
||||||
|
assert.doesNotMatch(source, /function nodeActions\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
node src/components/OrgPermissionTree.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
not ok ... exposes a visible current-node action entry ...
|
||||||
|
```
|
||||||
|
|
||||||
|
失败原因应是当前源码没有 `节点操作`、`openSelectedNodeMenu`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 不修改生产代码,先提交测试是不需要的**
|
||||||
|
|
||||||
|
此任务只建立红灯,不单独提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 在组织树右侧 header 增加“节点操作”入口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/OrgPermissionTree.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改右侧 header 按钮区**
|
||||||
|
|
||||||
|
将当前右侧 header 中的单个“新增人员”按钮:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canAddEmployee"
|
||||||
|
@click="addEmployee"
|
||||||
|
>
|
||||||
|
新增人员
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
替换为:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="employee-head-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button node-action-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!selectedNode"
|
||||||
|
@click="openSelectedNodeMenu"
|
||||||
|
>
|
||||||
|
节点操作
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canAddEmployee"
|
||||||
|
@click="addEmployee"
|
||||||
|
>
|
||||||
|
新增人员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加 `openSelectedNodeMenu` 函数**
|
||||||
|
|
||||||
|
在 `openNodeMenu(payload)` 下方添加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function openSelectedNodeMenu(event) {
|
||||||
|
if (!selectedNode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = event.currentTarget?.getBoundingClientRect?.();
|
||||||
|
emit("node-contextmenu", {
|
||||||
|
node: selectedNode.value,
|
||||||
|
x: rect ? rect.left : event.clientX,
|
||||||
|
y: rect ? rect.bottom + 6 : event.clientY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:这里仍然只 emit `node-contextmenu`,由父组件现有 `openContextMenu` 接管菜单,不在子组件里实现动作。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 添加 scoped 样式**
|
||||||
|
|
||||||
|
在 `.employee-head` 附近增加:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.employee-head-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-button {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||||
|
background: #ffffff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-button:hover:not(:disabled) {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行组件测试确认 Task 1 变绿**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
node src/components/OrgPermissionTree.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# pass ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 锁定父页面复用同一套节点菜单
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/SystemPermissionView.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写测试确认组织树和脑图共用 `openContextMenu`**
|
||||||
|
|
||||||
|
在 `SystemPermissionView.test.js` 末尾追加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
describe("SystemPermissionView organization view action parity", () => {
|
||||||
|
it("routes both mind map and tree node actions through the same context menu action pipeline", () => {
|
||||||
|
assert.match(source, /<OrgMindMap[\s\S]*?@node-contextmenu="openContextMenu"/);
|
||||||
|
assert.match(source, /<OrgPermissionTree[\s\S]*?@node-contextmenu="openContextMenu"/);
|
||||||
|
assert.match(source, /const contextMenuActions = computed\(\(\) => \(contextMenu\.node \? nodeActions\(contextMenu\.node\) : \[\]\)\);/);
|
||||||
|
assert.match(source, /function runNodeAction\(actionKey, node\)/);
|
||||||
|
assert.match(source, /openOrgRenameDrawer\(node\)/);
|
||||||
|
assert.match(source, /openOrgManagerDrawer\(node\)/);
|
||||||
|
assert.match(source, /openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
|
||||||
|
assert.match(source, /openOrgEmployeeDrawer\(node\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
node src/views/SystemPermissionView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# pass ...
|
||||||
|
```
|
||||||
|
|
||||||
|
如果失败,说明当前源码实际函数名或字符串不同;不要新增第二套函数,先核对现有 `nodeActions` 和 `runNodeAction`,再调整测试为真实函数名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 优化上下文菜单打开位置与组织树使用体验
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 检查现有 `openContextMenu`**
|
||||||
|
|
||||||
|
确认当前函数类似:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function openContextMenu(eventPayload) {
|
||||||
|
selectedNode.value = eventPayload.node;
|
||||||
|
contextMenu.visible = true;
|
||||||
|
contextMenu.x = eventPayload.x;
|
||||||
|
contextMenu.y = eventPayload.y;
|
||||||
|
contextMenu.node = eventPayload.node;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改为带视口保护的位置计算**
|
||||||
|
|
||||||
|
将 `openContextMenu` 替换为:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function openContextMenu(eventPayload) {
|
||||||
|
selectedNode.value = eventPayload.node;
|
||||||
|
const menuWidth = 220;
|
||||||
|
const menuHeight = 280;
|
||||||
|
const viewportWidth = window.innerWidth || 1280;
|
||||||
|
const viewportHeight = window.innerHeight || 720;
|
||||||
|
contextMenu.visible = true;
|
||||||
|
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
|
||||||
|
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
|
||||||
|
contextMenu.node = eventPayload.node;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:组织树右侧“节点操作”按钮会把菜单开在按钮下方;如果靠右或靠下,菜单不应跑出屏幕。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 运行父页面测试**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
node src/views/SystemPermissionView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# pass ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 手动浏览器验收
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 打开系统权限管理页面**
|
||||||
|
|
||||||
|
Use in-app Browser:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173/system-permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验收组织树模式下组织节点操作**
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
1. 切换到“组织树模式”。
|
||||||
|
2. 点击左侧“总公司”节点。
|
||||||
|
3. 点击右侧“节点操作”。
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
菜单出现,包含:重命名、添加该节点的主管、新增分公司节点。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验收分公司节点操作**
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
1. 点击“旭升车间”或“嘉恒车间”。
|
||||||
|
2. 点击“节点操作”。
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
菜单出现,包含:重命名、添加该节点的主管、新增部门节点、删除节点。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验收部门节点操作**
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
1. 点击“旭升加工部”或“嘉恒生产部”。
|
||||||
|
2. 点击“节点操作”。
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
菜单出现,包含:重命名、添加该节点的主管、新增小组节点、新增人员、删除节点。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 验收右键入口**
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
1. 在组织树左侧任意组织节点上右键。
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
弹出的菜单内容和右侧“节点操作”按钮一致。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 验收人员操作仍正常**
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
1. 在右侧人员列表点击“人员授权”。
|
||||||
|
2. 确认弹窗仍为卡片式角色授权界面。
|
||||||
|
3. 关闭弹窗。
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
人员授权弹窗打开正常,不受节点操作入口影响。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 回归验证与提交
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- All modified files from earlier tasks.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 运行关键测试**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
node src/components/OrgPermissionTree.test.js
|
||||||
|
node src/views/SystemPermissionView.test.js
|
||||||
|
node src/utils/orgTreeEmployees.test.js
|
||||||
|
node src/App.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# fail 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行构建**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ built
|
||||||
|
```
|
||||||
|
|
||||||
|
允许出现当前项目既有的大 chunk warning,不作为阻断。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 检查 diff**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||||||
|
git diff --stat
|
||||||
|
git diff -- frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
只包含组织树动作入口、菜单位置保护、相关测试和少量样式。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 等用户确认后提交 main**
|
||||||
|
|
||||||
|
Run only after user confirms:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||||||
|
git add frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
|
||||||
|
git commit -m "补齐组织树节点操作能力"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
main -> main
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 同步 BH_DEV**
|
||||||
|
|
||||||
|
Run only after main push succeeds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||||||
|
git switch BH_DEV
|
||||||
|
git pull --ff-only origin BH_DEV
|
||||||
|
git cherry-pick <main_commit_hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[BH_DEV <hash>] 补齐组织树节点操作能力
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 在 BH_DEV 运行同样验证并推送**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||||
|
node src/components/OrgPermissionTree.test.js
|
||||||
|
node src/views/SystemPermissionView.test.js
|
||||||
|
node src/utils/orgTreeEmployees.test.js
|
||||||
|
node src/App.test.js
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
git push origin BH_DEV
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BH_DEV -> BH_DEV
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
已覆盖“组织树和脑图只是展示不同,功能一致”的核心要求。组织树右键入口和显式“节点操作”入口都会进入现有 `openContextMenu`,菜单项仍由 `nodeActions` 根据节点类型动态决定。
|
||||||
|
|
||||||
|
**Placeholder scan:**
|
||||||
|
未使用 TBD/TODO/后续补充。每个任务都有具体文件、代码片段、命令和预期结果。
|
||||||
|
|
||||||
|
**Type consistency:**
|
||||||
|
计划中使用的函数名与当前代码一致:`openContextMenu`、`nodeActions`、`runNodeAction`、`openOrgCreateDrawer`、`openOrgRenameDrawer`、`openOrgManagerDrawer`、`openOrgEmployeeDrawer`、`openAuthorizeDrawer`、`removeOrgEmployee`、`removeOrgNode`。
|
||||||
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
# ForgeFlow ERP UI Optimization Design
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
ForgeFlow ERP has moved beyond the initial skeleton stage. The frontend now contains 33 Vue views, 29 Vue components, a large global stylesheet, and many business-critical flows across sales, procurement, warehouse, production, quality, finance, returns, permissions, and system extension.
|
|
||||||
|
|
||||||
The current UI has useful product foundations: workflow navigation, table controls, status badges, drawers, pagination, searchable selects, formal document flows, and PDF archive interactions. The main problem is not a lack of UI, but drift: multiple visual eras coexist, page-level styles override shared components, and high-value ERP workflows are hard to polish consistently.
|
|
||||||
|
|
||||||
## Confirmed Product Direction
|
|
||||||
|
|
||||||
The product register is `product`.
|
|
||||||
|
|
||||||
The desired brand personality is modern, clear, and easy to adopt. Routine ERP pages should feel calm, precise, and work-focused. They should avoid obvious AI-generated visual patterns, decorative SaaS tropes, heavy dark dashboard aesthetics, and paper styling outside formal business documents.
|
|
||||||
|
|
||||||
Accessibility defaults to WCAG AA, keyboard-friendly interaction, readable Chinese text, reduced-motion support, and non-color-only status communication.
|
|
||||||
|
|
||||||
## Current Findings
|
|
||||||
|
|
||||||
### Design Governance
|
|
||||||
|
|
||||||
- `PRODUCT.md` and `DESIGN.md` were missing and are now created.
|
|
||||||
- The project now has `.impeccable/design.json` for tooling metadata.
|
|
||||||
- Live mode is configured through `.impeccable/live/config.json`.
|
|
||||||
|
|
||||||
### Code And Styling Scale
|
|
||||||
|
|
||||||
- `frontend/src/styles/main.css` has about 17,322 lines.
|
|
||||||
- `frontend/src/views/InventoryLedgerView.vue` has about 7,119 lines.
|
|
||||||
- Other large files include `PurchaseOrderView.vue`, `ProductWorkflowDrawer.vue`, `SalesPlanningView.vue`, and `SystemPermissionView.vue`.
|
|
||||||
- The production build passes, but Vite reports a large JS chunk at about 797 kB and CSS at about 353 kB.
|
|
||||||
|
|
||||||
### Visual Drift
|
|
||||||
|
|
||||||
- The codebase contains an older dark industrial theme and a newer light admin theme.
|
|
||||||
- The light admin theme should become the default for routine ERP screens.
|
|
||||||
- Dark industrial styling should be limited to login or intentionally staged surfaces.
|
|
||||||
- Several pages still contain Element-like colors such as `#409eff`, `#606266`, and `#909399`; these are acceptable only if they are intentionally tokenized.
|
|
||||||
|
|
||||||
### Impeccable Detector Warnings
|
|
||||||
|
|
||||||
The detector currently reports 9 warnings:
|
|
||||||
|
|
||||||
- Side accent border in `frontend/src/styles/main.css`.
|
|
||||||
- Layout property animation using `transition: width` in `frontend/src/styles/main.css`.
|
|
||||||
- Side accent border in `frontend/src/views/PurchaseOrderView.vue`.
|
|
||||||
- Side accent border in `frontend/src/views/SalesPlanningView.vue`.
|
|
||||||
- Width/height transition in `frontend/src/components/OrgMindMap.vue`.
|
|
||||||
|
|
||||||
These warnings match the product goal: remove obvious AI-generated tells and reduce janky layout animation.
|
|
||||||
|
|
||||||
## Optimization Goals
|
|
||||||
|
|
||||||
1. Establish a single routine ERP visual language.
|
|
||||||
2. Make high-frequency workflows faster to scan and safer to operate.
|
|
||||||
3. Standardize tables, drawers, forms, buttons, status badges, pagination, empty states, and error states.
|
|
||||||
4. Reduce global CSS drift and page-level one-off styling.
|
|
||||||
5. Improve bundle health with route-level code splitting.
|
|
||||||
6. Keep formal paper/document visuals scoped to business documents and archive flows.
|
|
||||||
|
|
||||||
## Phase 1: Design System Baseline
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- `frontend/src/styles/main.css`
|
|
||||||
- `frontend/src/components/ActionButton.vue`
|
|
||||||
- `frontend/src/components/StatusBadge.vue`
|
|
||||||
- `frontend/src/components/PaginationBar.vue`
|
|
||||||
- `frontend/src/components/TableControls.vue`
|
|
||||||
- `frontend/src/components/FormDrawer.vue`
|
|
||||||
- `frontend/src/components/SmartDrawer.vue`
|
|
||||||
- `frontend/src/components/DrawerDataTable.vue`
|
|
||||||
- `frontend/src/components/GlobalSearchableSelect.vue`
|
|
||||||
- `frontend/src/components/StockLotTagSelect.vue`
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
- Create a small set of canonical CSS tokens for color, radius, shadow, focus, spacing, and z-index.
|
|
||||||
- Keep the light admin theme as the default authenticated app theme.
|
|
||||||
- Remove decorative gradients from routine buttons and cards.
|
|
||||||
- Replace 24px+ routine card radii with 12-14px values.
|
|
||||||
- Remove side accent borders greater than 1px except inside formal document grids where they have document semantics.
|
|
||||||
- Add `prefers-reduced-motion` handling for remaining motion.
|
|
||||||
- Replace width/height transition patterns with non-layout animation or no animation.
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
- `impeccable detect` warnings for side-tab and layout-transition are resolved or documented as intentional exceptions.
|
|
||||||
- Buttons, form fields, cards, chips, and status badges share tokenized colors and radii.
|
|
||||||
- `npm run build` passes.
|
|
||||||
|
|
||||||
## Phase 2: Core Workflow Shell
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- `frontend/src/App.vue`
|
|
||||||
- route navigation and tags view
|
|
||||||
- topbar broadcast and assistant entry
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
- Simplify topbar hierarchy: breadcrumb/current page first, secondary utilities second.
|
|
||||||
- Make visited tags easier to scan, close, and recover from.
|
|
||||||
- Keep assistant entry as a helper, not a competing primary action.
|
|
||||||
- Improve collapsed sidebar behavior and tooltips.
|
|
||||||
- Remove width animation from shell collapse where it causes layout work.
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
- Navigation remains clear at expanded, collapsed, and narrow widths.
|
|
||||||
- Current route is visible without relying on a thick side stripe.
|
|
||||||
- Keyboard focus states remain visible.
|
|
||||||
|
|
||||||
## Phase 3: Table And Data Interaction System
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- `TableControls`
|
|
||||||
- `DrawerDataTable`
|
|
||||||
- all high-frequency table pages
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
- Move table enhancement behavior toward declared component APIs rather than DOM patching where possible.
|
|
||||||
- Standardize table density, sticky headers, sort behavior, tooltips, wide mode, and action columns.
|
|
||||||
- Define empty states by page intent: no data, filtered out, no permission, sync pending, load failed.
|
|
||||||
- Define loading skeletons for data regions instead of content-area spinners.
|
|
||||||
- Ensure exported/PDF data never inherits frontend ellipsis behavior.
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
- Tables on master data, warehouse, purchase, sales, production, and finance pages share the same search, sort, pagination, tooltip, and empty-state vocabulary.
|
|
||||||
- Long business identifiers remain accessible.
|
|
||||||
- Action columns no longer resize unexpectedly.
|
|
||||||
|
|
||||||
## Phase 4: Inventory Ledger Refactor
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- `frontend/src/views/InventoryLedgerView.vue`
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
Split the large view into smaller units:
|
|
||||||
|
|
||||||
- warehouse selector and summary
|
|
||||||
- inbound/outbound operation launcher
|
|
||||||
- stock lot list
|
|
||||||
- transaction ledger drawer
|
|
||||||
- stocktake dialog integration
|
|
||||||
- document archive actions
|
|
||||||
- operation-specific form sections
|
|
||||||
- composables for warehouse state, filtering, and API calls
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
- The main view becomes understandable without reading thousands of lines at once.
|
|
||||||
- Each warehouse operation form has consistent validation, loading, success, error, archive failed, and retry states.
|
|
||||||
- Existing warehouse business behavior remains unchanged.
|
|
||||||
|
|
||||||
## Phase 5: Formal Document System
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- sales order documents
|
|
||||||
- purchase order documents
|
|
||||||
- warehouse operation documents
|
|
||||||
- quality and production archive flows
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
- Keep formal document visuals scoped to real business documents.
|
|
||||||
- Extract document paper/grid/signature/archive action components.
|
|
||||||
- Avoid applying paper-like styling to dashboards and routine data tables.
|
|
||||||
- Align list archive state, preview, download, retry, and batch download interactions.
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
- Document entry and archive screens feel formal and traceable.
|
|
||||||
- Routine ERP pages remain light and product-like.
|
|
||||||
- Archive failure never silently disappears.
|
|
||||||
|
|
||||||
## Phase 6: Performance And Build Health
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- `frontend/src/router/index.js`
|
|
||||||
- large views and route imports
|
|
||||||
- global stylesheet ownership
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
- Convert route components to dynamic imports.
|
|
||||||
- Consider manual chunks only after route splitting.
|
|
||||||
- Move page-specific CSS out of `main.css` where practical.
|
|
||||||
- Keep global CSS for tokens, base layout, and shared components only.
|
|
||||||
|
|
||||||
### Acceptance
|
|
||||||
|
|
||||||
- Initial JS chunk warning is reduced or eliminated.
|
|
||||||
- CSS is easier to attribute to shared vs page-specific concerns.
|
|
||||||
- Build continues to pass.
|
|
||||||
|
|
||||||
## First Implementation Slice
|
|
||||||
|
|
||||||
Start with Phase 1 plus a narrow Phase 4 audit:
|
|
||||||
|
|
||||||
1. Fix side accent border and layout transition warnings.
|
|
||||||
2. Normalize buttons, status badges, and table container tokens.
|
|
||||||
3. Audit `InventoryLedgerView.vue` for component extraction boundaries without changing business behavior yet.
|
|
||||||
4. Run `npm run build` and `impeccable detect`.
|
|
||||||
|
|
||||||
This creates a safer base before touching the largest workflow page.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `cd frontend && npm run build`
|
|
||||||
- `node /Users/souplearn/.codex/skills/impeccable/scripts/detect.mjs --json frontend/src/App.vue frontend/src/styles/main.css frontend/src/views frontend/src/components`
|
|
||||||
- Manual browser checks at desktop and narrow widths when browser tooling is available.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
This workspace is not currently a git repository, so this plan does not include commit steps. If the work later moves into a git worktree, commit after each phase.
|
|
||||||
@ -0,0 +1,341 @@
|
|||||||
|
# 全系统纸质单据统一改造设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
系统已经开始从普通后台表单向正式业务单据升级:销售订单、采购订单已经接入 `DocumentPaper` 纸质单据和 PDF 归档;仓库中的 `原材料库 · 客料入库单` 已被用户认可为“模板级别”;`生产余料入库单 / 生产废料入库单` 第一阶段也已经开始改成同一套纸质单据语言。
|
||||||
|
|
||||||
|
用户明确补充:这不是仓库专属改造,而是全系统所有“单据型业务”都要采用统一纸质单据体系。为了避免仓库、采购、销售、生产各自长出不同样式,本设计把目标升级为“全系统业务单据纸质化 + PDF 归档统一体系”。
|
||||||
|
|
||||||
|
## 单据边界
|
||||||
|
|
||||||
|
### 纳入纸质化的对象
|
||||||
|
|
||||||
|
满足任一条件就纳入:
|
||||||
|
|
||||||
|
- 会创建正式业务凭证:销售订单、采购订单、到货入库单、质量校验单、发货单、退货/返工单。
|
||||||
|
- 会改变库存或形成库存流水:各库入库、出库、特殊调整、盘库。
|
||||||
|
- 会形成生产追溯闭环:生产出库、生产台账入库、生产入库结算、生产领料出库。
|
||||||
|
- 用户后续需要预览、下载、批量下载或审计留档。
|
||||||
|
|
||||||
|
### 不纳入纸质化的对象
|
||||||
|
|
||||||
|
这些仍按系统管理/主数据维护表单处理,不强行改成纸质单据:
|
||||||
|
|
||||||
|
- 供应商名录、客户名录、人员管理、设备管理。
|
||||||
|
- 原材料名录、产品管理、产品需规清单、BOM、工艺路线。
|
||||||
|
- 系统权限、系统拓展、导入工具、基础配置。
|
||||||
|
|
||||||
|
原因:这些是主数据或配置记录,不是业务凭证。强行纸质化会降低维护效率,也会把“单据”的正式感稀释掉。
|
||||||
|
|
||||||
|
## 现状清单
|
||||||
|
|
||||||
|
### 已基本纸质化且已有 PDF 归档
|
||||||
|
|
||||||
|
| 模块 | 页面 | 单据 | 前端状态 | 后端归档类型 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 订单与计划 | `SalesPlanningView.vue` | 销售订单 | 使用 `DocumentPaper`、`DocumentLineTable`、`DocumentArchiveActions` | `销售订单` | 已有预览、下载、重新生成、批量下载 |
|
||||||
|
| 采购与库存 | `PurchaseOrderView.vue` | 采购订单 | 使用 `DocumentPaper`、`DocumentArchiveActions` | `采购订单` | 已有预览、下载、重新生成、批量下载 |
|
||||||
|
| 采购与库存 | `PurchaseReceiptView.vue` | 到货入库单 | 使用 `DocumentPaper`、`DocumentArchiveActions` | `到货入库单` | 新增到货入库已纸质化 |
|
||||||
|
| 采购与库存 | `QualityInspectionView.vue` | 质量校验单 | 使用 `DocumentPaper`、`DocumentLineTable`、`DocumentArchiveActions` | `质量校验单` | 质检放行弹窗仍需继续统一交互 |
|
||||||
|
| 生产执行 | `ProductionLedgerView.vue` | 生产领料出库单 | 列表详情有 `DocumentArchiveActions` | `生产领料出库单` | 主要是归档入口,填单发生在仓库生产出库 |
|
||||||
|
| 生产执行 | `ProductionLedgerView.vue` | 生产入库结算单 | 列表详情有 `DocumentArchiveActions` | `生产入库结算单` | 对应生产台账入库 |
|
||||||
|
|
||||||
|
### 已套纸质外壳但仍有混用 UI
|
||||||
|
|
||||||
|
| 模块 | 页面 | 单据 | 当前问题 | 优先级 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 |
|
||||||
|
|
||||||
|
### 需要补充纸质化或确认是否作为单据
|
||||||
|
|
||||||
|
| 模块 | 页面 | 业务 | 判断 | 优先级 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 发货概览 | `DeliveryManagementView.vue` | 发货台账/发货明细 | 如果存在独立发货动作,应形成发货单;若发货统一走成品库销售出库,则只保留流水归档入口 | P1 |
|
||||||
|
| 发货概览 | `ReturnManagementView.vue` | 退货/返工/废料处置 | 属于业务凭证,需要与退货库单据闭环统一 | P1 |
|
||||||
|
| 发货概览 | `ScrapReworkView.vue` | 报废返工处理 | 若仍在使用,应按退货/返工处置单统一;若已被仓库/退货库替代,应清理或降级为台账 | P2 |
|
||||||
|
| 生产执行 | `CompletionReceiptView.vue` | 成品入库 | 旧入口如果仍可用,需要和仓库生产入库/生产台账入库统一;如果已被新流程替代,应标记为旧入口 | P2 |
|
||||||
|
| 嘉恒仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 |
|
||||||
|
|
||||||
|
## 推荐方案
|
||||||
|
|
||||||
|
### 方案 A:继续逐页局部改造
|
||||||
|
|
||||||
|
每个页面自己套 `DocumentPaper` 和单独 CSS。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 单次改动小。
|
||||||
|
- 速度快。
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 很容易再次出现“仓库一套、采购一套、销售一套”。
|
||||||
|
- 样式复制越来越多。
|
||||||
|
- 后续 PDF、预览、下载、异常说明、签核栏会重复实现。
|
||||||
|
|
||||||
|
结论:不推荐作为长期方案,只适合救急。
|
||||||
|
|
||||||
|
### 方案 B:沉淀全系统单据底座,分批迁移
|
||||||
|
|
||||||
|
把现在的 `DocumentPaper`、`DocumentGrid`、`DocumentLineTable`、`WarehouseDocumentFormShell` 抽象成全系统单据组件。仓库继续推进,但所有新样式都进入通用单据层。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 风格统一。
|
||||||
|
- 后续新增单据成本低。
|
||||||
|
- 能保留现有业务逻辑,不需要一次性大重构。
|
||||||
|
- 适合当前系统已经上线、仍在快速迭代的状态。
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 第一批需要先做组件抽象和兼容层。
|
||||||
|
- 每个单据仍要逐个检查字段和业务交互。
|
||||||
|
|
||||||
|
结论:推荐。
|
||||||
|
|
||||||
|
### 方案 C:一次性全系统重构
|
||||||
|
|
||||||
|
一次把所有单据型页面、归档、PDF 重新设计和重写。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 最终一致性最高。
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 风险大。
|
||||||
|
- 容易影响已经可用的采购、销售、仓库主流程。
|
||||||
|
- 验收压力大。
|
||||||
|
|
||||||
|
结论:当前不推荐。
|
||||||
|
|
||||||
|
## 目标架构
|
||||||
|
|
||||||
|
### 前端组件层
|
||||||
|
|
||||||
|
保留现有组件并向通用命名演进:
|
||||||
|
|
||||||
|
- `DocumentPaper`:纸张、标题、编号章、签核栏、留存联。
|
||||||
|
- `DocumentGrid`:只读/展示型字段格。
|
||||||
|
- `DocumentLineTable`:明细表。
|
||||||
|
- `DocumentArchiveActions`:预览、下载、重新生成。
|
||||||
|
|
||||||
|
新增或演进:
|
||||||
|
|
||||||
|
- `DocumentFormShell`:全系统通用填单外壳,替代仓库专属 `WarehouseDocumentFormShell`。
|
||||||
|
- `DocumentSection`:通用单据分区,替代仓库专属 `WarehouseDocumentSection`。
|
||||||
|
- `DocumentActionBar`:通用底部操作区,替代仓库专属 `WarehouseDocumentActionBar`。
|
||||||
|
- `DocumentField`:纸质输入格,承载 input、select、textarea、readonly。
|
||||||
|
- `DocumentSelectorPanel`:纸质风格的下拉、多选、来源批次选择、关联订单选择。
|
||||||
|
- `DocumentInlineSummary`:纸质风格的金额/数量/重量汇总条。
|
||||||
|
- `DocumentSettlePanel`:结单、异常说明、偏差确认等正式确认区。
|
||||||
|
|
||||||
|
兼容规则:
|
||||||
|
|
||||||
|
- 第一阶段不删除 `WarehouseDocument*` 组件。
|
||||||
|
- 新通用组件先复用当前纸质 CSS 变量和视觉语言。
|
||||||
|
- 仓库页面逐步从 `warehouse-paper-*` 类迁移到 `document-paper-*` 类。
|
||||||
|
- 老类名保留一段时间作为兼容,避免正在使用的仓库单据被打断。
|
||||||
|
|
||||||
|
### 前端页面层
|
||||||
|
|
||||||
|
每个单据页面遵循同一结构:
|
||||||
|
|
||||||
|
1. `DocumentFormShell`
|
||||||
|
2. 单据头:标题、单据编号、业务日期、公司/模块名。
|
||||||
|
3. 基础信息区:客户/供应商/仓库/经办人/业务日期/关联单据。
|
||||||
|
4. 明细区:物料/产品/批次/数量/重量/单价/金额。
|
||||||
|
5. 辅助信息区:照片、运单号、运费、包装备注、用途、说明。
|
||||||
|
6. 汇总区:数量、重量、金额、差异、异常说明。
|
||||||
|
7. 签核区:制单、经办、审核、仓库/财务。
|
||||||
|
8. 操作区:保存、保存并归档、归档状态动作。
|
||||||
|
|
||||||
|
### 后端归档层
|
||||||
|
|
||||||
|
继续使用现有 `document_archives` 表和统一路由:
|
||||||
|
|
||||||
|
- `POST /document-archives/{document_type_key}/{business_id}/generate`
|
||||||
|
- `GET /document-archives/{document_type_key}/{business_id}/latest/preview`
|
||||||
|
- `GET /document-archives/{document_type_key}/{business_id}/latest/download`
|
||||||
|
- `POST /document-archives/batch-download`
|
||||||
|
|
||||||
|
现有支持的归档类型:
|
||||||
|
|
||||||
|
- `销售订单`
|
||||||
|
- `采购订单`
|
||||||
|
- `到货入库单`
|
||||||
|
- `质量校验单`
|
||||||
|
- `生产领料出库单`
|
||||||
|
- `生产入库结算单`
|
||||||
|
- `仓库出入库单`
|
||||||
|
|
||||||
|
新增归档类型只在确实不是现有类型能表达时添加。优先复用:
|
||||||
|
|
||||||
|
- 普通仓库动作统一走 `仓库出入库单`。
|
||||||
|
- 生产出库走 `生产领料出库单`。
|
||||||
|
- 生产台账入库走 `生产入库结算单`。
|
||||||
|
- 采购流程走 `采购订单 / 到货入库单 / 质量校验单`。
|
||||||
|
- 销售下单走 `销售订单`。
|
||||||
|
|
||||||
|
### PDF 规则
|
||||||
|
|
||||||
|
PDF 是保存后的数据库事实快照,不从前端表单二次推导。
|
||||||
|
|
||||||
|
PDF 必须遵守:
|
||||||
|
|
||||||
|
- 不显示英文状态。
|
||||||
|
- 不显示前端省略号。
|
||||||
|
- 长文本完整换行,不超格。
|
||||||
|
- 制单、经办人默认使用当前登录账号或后端记录的操作人。
|
||||||
|
- 库存批次号使用业务认可的原材料库存批次号,不使用无业务意义的每库内部批次号。
|
||||||
|
- PDF 样式和前端纸质单据属于同一视觉体系,但不要求像素完全一致。
|
||||||
|
|
||||||
|
## 分期执行
|
||||||
|
|
||||||
|
### 第 0 期:全系统清单和统一底座
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 固化本文档的单据清单。
|
||||||
|
- 新增通用 `DocumentFormShell / DocumentSection / DocumentActionBar / DocumentField / DocumentSelectorPanel`。
|
||||||
|
- 把仓库当前纸质样式抽象为通用 `document-paper-*`,保留 `warehouse-paper-*` 兼容层。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 销售订单、采购订单、客料入库单视觉不退化。
|
||||||
|
- 新通用组件有基础测试或源码断言。
|
||||||
|
|
||||||
|
### 第 1 期:仓库 P0 单据
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 特殊入库 / 特殊出库。
|
||||||
|
- 生产出库。
|
||||||
|
- 生产台账入库结算单。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 这些单据业务风险最高,且当前混用 UI 最明显。
|
||||||
|
- 改完能沉淀“多行明细类单据”“生产闭环类单据”“异常说明类单据”三类模板。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 不再出现普通卡片/普通表格突兀嵌在纸里。
|
||||||
|
- 特殊出入库说明、明细行、调整前后数据完整显示。
|
||||||
|
- 生产台账入库三库联动显示为一张结算单,不误写成只属于成品库。
|
||||||
|
|
||||||
|
### 第 2 期:仓库 P1 单据
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 生产入库 / 返工入库。
|
||||||
|
- 销售出库。
|
||||||
|
- 原材料退货出库、退货入库、退货废料入库、返工出库。
|
||||||
|
- 期初、委外、报废、售卖等通用分支。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 6 大库所有出入库抽屉都统一纸质化。
|
||||||
|
- 流水里 PDF 预览、下载、重新生成入口完整。
|
||||||
|
|
||||||
|
### 第 3 期:采购、销售、质检细节统一
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 销售订单、采购订单、到货入库、质量校验页面继续消除残留普通控件。
|
||||||
|
- 关联销售订单、采购明细、质检备注弹窗等改成统一纸质选择/说明面板。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 已有 PDF 归档能力不回退。
|
||||||
|
- 纸质字段、明细表、按钮区和仓库单据保持同一套视觉语言。
|
||||||
|
|
||||||
|
### 第 4 期:发货、退货、返工闭环
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 发货台账按销售订单聚合后的明细留档入口统一。
|
||||||
|
- 退货、返工、废料处置相关页面和退货库单据闭环。
|
||||||
|
- 若旧页面被新仓库流程替代,则标记为只读台账或移除入口,避免双流程。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 用户能从销售订单、发货台账、退货库流水追溯到同一套 PDF 单据。
|
||||||
|
|
||||||
|
### 第 5 期:盘库与批量导出
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 盘库单纸质化和 PDF 归档。
|
||||||
|
- 各业务台账按时间/筛选条件批量导出单据或流水 Excel。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 盘库调整说明进入 PDF。
|
||||||
|
- Excel 导出不使用省略号,长字段完整输出。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### 前端测试
|
||||||
|
|
||||||
|
每批至少增加源码断言或组件测试:
|
||||||
|
|
||||||
|
- 单据页面使用通用 `DocumentFormShell` 或兼容的纸质单据组件。
|
||||||
|
- 不在纸质单据主体中裸露旧 `form-field / double-field / triple-field / workflow-tip` 作为主要布局。
|
||||||
|
- 归档按钮使用 `DocumentArchiveActions`。
|
||||||
|
- 保存按钮位于单据底部操作区,不悬在单据中间。
|
||||||
|
|
||||||
|
### 后端测试
|
||||||
|
|
||||||
|
继续沿用现有归档测试风格:
|
||||||
|
|
||||||
|
- 归档生成成功写入 `document_archives`。
|
||||||
|
- PDF 文件以 `%PDF` 开头。
|
||||||
|
- 归档失败不阻断业务保存。
|
||||||
|
- 列表返回最新 PDF 归档状态。
|
||||||
|
- 不同业务类型映射到正确 `document_type`。
|
||||||
|
|
||||||
|
### 浏览器验收
|
||||||
|
|
||||||
|
每批至少手动/浏览器验证:
|
||||||
|
|
||||||
|
- 典型桌面宽度。
|
||||||
|
- 当前内置浏览器窄视口。
|
||||||
|
- 单据可滚动、按钮位置合理。
|
||||||
|
- 长文本不挤坏单据。
|
||||||
|
- PDF 预览/下载入口可见。
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- 纸质化是表现层和归档层升级,不借机删字段。
|
||||||
|
- 不改变现有业务计算和提交语义。
|
||||||
|
- 不把主数据维护表单改成纸质单据。
|
||||||
|
- 不把 PDF 归档失败作为库存/订单保存失败。
|
||||||
|
- 不新增英文状态、英文备注或英文系统标志。
|
||||||
|
- 不在 PDF 中使用省略号。
|
||||||
|
- 不把仓库特有样式继续扩散到全系统,新增能力进入通用 `documentForms`。
|
||||||
|
|
||||||
|
## 下一步推荐
|
||||||
|
|
||||||
|
推荐进入第 0 期 + 第 1 期合并执行:
|
||||||
|
|
||||||
|
1. 先抽象通用单据底座,保留现有仓库兼容层。
|
||||||
|
2. 接着改 `特殊入库 / 特殊出库`。
|
||||||
|
3. 再改 `生产出库`。
|
||||||
|
4. 再改 `生产台账入库结算单`。
|
||||||
|
|
||||||
|
这样能最快解决用户当前肉眼最不舒服的仓库混 UI 问题,同时保证做出来的能力可以继续给采购、销售、质检、发货复用。
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
- 无占位项。
|
||||||
|
- 已明确哪些页面纳入,哪些页面不纳入。
|
||||||
|
- 已避免把仓库纸质化做成孤岛。
|
||||||
|
- 已给出分期和优先级。
|
||||||
|
- 已保持“先视觉/交互统一,不改变业务逻辑”的边界。
|
||||||
@ -5,6 +5,7 @@ const files = [
|
|||||||
"src/components/documentForms/DocumentPaper.vue",
|
"src/components/documentForms/DocumentPaper.vue",
|
||||||
"src/components/documentForms/DocumentGrid.vue",
|
"src/components/documentForms/DocumentGrid.vue",
|
||||||
"src/components/documentForms/DocumentLineTable.vue",
|
"src/components/documentForms/DocumentLineTable.vue",
|
||||||
|
"src/components/documentForms/WarehouseDocumentFormShell.vue",
|
||||||
"src/components/documentForms/DocumentArchiveActions.vue",
|
"src/components/documentForms/DocumentArchiveActions.vue",
|
||||||
"src/styles/main.css"
|
"src/styles/main.css"
|
||||||
];
|
];
|
||||||
@ -39,6 +40,17 @@ for (const snippet of ["预览PDF", "下载PDF", "重新生成"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warehouseDocumentFormShell = sources.get("src/components/documentForms/WarehouseDocumentFormShell.vue");
|
||||||
|
for (const snippet of ["formatChineseDate", "displayBusinessDate"]) {
|
||||||
|
if (!warehouseDocumentFormShell.includes(snippet)) {
|
||||||
|
throw new Error(`WarehouseDocumentFormShell missing business date formatting snippet: ${snippet}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warehouseDocumentFormShell.includes("{{ businessDate || \"-\" }}")) {
|
||||||
|
throw new Error("WarehouseDocumentFormShell must not render raw businessDate values");
|
||||||
|
}
|
||||||
|
|
||||||
for (const forbiddenSnippet of ["ghost-button", "action-button"]) {
|
for (const forbiddenSnippet of ["ghost-button", "action-button"]) {
|
||||||
if (archiveActions.includes(forbiddenSnippet)) {
|
if (archiveActions.includes(forbiddenSnippet)) {
|
||||||
throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`);
|
throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`);
|
||||||
@ -58,6 +70,25 @@ for (const selector of [".document-paper-shell", ".document-paper-sheet", ".docu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const selector of [
|
||||||
|
".document-grid-value input",
|
||||||
|
".document-grid-value select",
|
||||||
|
".document-grid-value textarea",
|
||||||
|
".document-line-table input",
|
||||||
|
".document-line-table select",
|
||||||
|
".document-line-table textarea"
|
||||||
|
]) {
|
||||||
|
if (!css.includes(selector)) {
|
||||||
|
throw new Error(`main.css missing document form overflow guard selector: ${selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snippet of ["box-sizing: border-box", "min-width: 0", "max-width: 100%", "text-overflow: ellipsis"]) {
|
||||||
|
if (!css.includes(snippet)) {
|
||||||
|
throw new Error(`document form controls missing overflow guard declaration: ${snippet}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const selector of [
|
for (const selector of [
|
||||||
".document-archive-button",
|
".document-archive-button",
|
||||||
".document-archive-button-preview",
|
".document-archive-button-preview",
|
||||||
|
|||||||
@ -481,11 +481,11 @@ assert.match(
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
inventoryLedgerSource,
|
inventoryLedgerSource,
|
||||||
/source_doc_type: isProductionSurplusInbound\.value \? "PRODUCTION_LEDGER" : null[\s\S]*source_doc_id: isProductionSurplusInbound\.value \? Number\(selectedGenericWorkOrder\.value\.production_ledger_id \|\| selectedGenericWorkOrder\.value\.work_order_id\) : null/
|
/source_doc_type: isProductionSurplusInbound\.value \? "WORK_ORDER" : null[\s\S]*source_doc_id: isProductionSurplusInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : null/
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
inventoryLedgerSource,
|
inventoryLedgerSource,
|
||||||
/source_doc_type: isProductionScrapInbound\.value \? "PRODUCTION_LEDGER" : isReworkScrapInbound\.value \? "WORK_ORDER" : activeWarehouseType\.value === "SEMI" \? "PRODUCT_ROUTE_OPERATION" : null[\s\S]*isProductionScrapInbound\.value[\s\S]*Number\(selectedGenericWorkOrder\.value\.production_ledger_id \|\| selectedGenericWorkOrder\.value\.work_order_id\)[\s\S]*isReworkScrapInbound\.value[\s\S]*Number\(selectedGenericWorkOrder\.value\.work_order_id\)/
|
/source_doc_type: isProductionScrapInbound\.value \|\| isReworkScrapInbound\.value \? "WORK_ORDER" : activeWarehouseType\.value === "SEMI" \? "PRODUCT_ROUTE_OPERATION" : null[\s\S]*source_doc_id: isProductionScrapInbound\.value \|\| isReworkScrapInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : activeWarehouseType\.value === "SEMI" \? Number\(selectedSourceDocId \|\| 0\) : null/
|
||||||
);
|
);
|
||||||
assert.match(inventoryLedgerSource, /:key="`inventory-main-table-\$\{activeInventoryTab\}`"/);
|
assert.match(inventoryLedgerSource, /:key="`inventory-main-table-\$\{activeInventoryTab\}`"/);
|
||||||
assert.match(inventoryLedgerSource, /:key="`inventory-controls-\$\{activeInventoryTab\}`"/);
|
assert.match(inventoryLedgerSource, /:key="`inventory-controls-\$\{activeInventoryTab\}`"/);
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const source = readFileSync(path.join(root, "src/services/api.js"), "utf8");
|
|
||||||
|
|
||||||
assert.match(source, /\/auth\/login-key/, "login should fetch the backend public key before submitting");
|
|
||||||
assert.match(source, /password_ciphertext/, "login payload should submit encrypted password ciphertext");
|
|
||||||
assert.match(source, /login_key_id/, "login payload should include the public key id used for encryption");
|
|
||||||
assert.doesNotMatch(
|
|
||||||
source,
|
|
||||||
/JSON\.stringify\(\{\s*username,\s*password\s*\}\)/,
|
|
||||||
"login must not serialize the plaintext password field"
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(source, /password:\s*password/, "login payload must not include plaintext password");
|
|
||||||
|
|
||||||
console.log("login password encryption checks passed");
|
|
||||||
@ -19,7 +19,7 @@ assert.match(
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/!isFinishedOutsourcingInbound && !isProductionScrapInbound && !isReworkScrapInbound && !isOutsourcingScrapInbound/,
|
/!isFinishedOutsourcingInbound && !isProductionScrapInbound && !isOutsourcingScrapInbound/,
|
||||||
"outsourcing scrap inbound should hide the manual lot number field"
|
"outsourcing scrap inbound should hide the manual lot number field"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const app = read("src/App.vue");
|
|||||||
const productionLedger = read("src/views/ProductionLedgerView.vue");
|
const productionLedger = read("src/views/ProductionLedgerView.vue");
|
||||||
const inventory = read("src/views/InventoryLedgerView.vue");
|
const inventory = read("src/views/InventoryLedgerView.vue");
|
||||||
|
|
||||||
assert.match(router, /component:\s*view\("ProductionLedgerView"\)/, "router should lazy load ProductionLedgerView");
|
assert.match(router, /ProductionLedgerView/, "router should import ProductionLedgerView");
|
||||||
assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route");
|
assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route");
|
||||||
assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger");
|
assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger");
|
||||||
assert.match(app, /生产台账/, "sidebar should show 生产台账");
|
assert.match(app, /生产台账/, "sidebar should show 生产台账");
|
||||||
|
|||||||
@ -15,12 +15,12 @@ const inventoryView = readSource("src/views/InventoryLedgerView.vue");
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
router,
|
router,
|
||||||
/component:\s*view\("ProductionLedgerView"\)/,
|
/import\s+ProductionLedgerView\s+from\s+"..\/views\/ProductionLedgerView\.vue";/,
|
||||||
"router should lazy load ProductionLedgerView"
|
"router should import ProductionLedgerView"
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
router,
|
router,
|
||||||
/path:\s*"\/production-ledger"[\s\S]*?name:\s*"production-ledger"[\s\S]*?component:\s*view\("ProductionLedgerView"\)[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
|
/path:\s*"\/production-ledger"[\s\S]*?name:\s*"production-ledger"[\s\S]*?component:\s*ProductionLedgerView[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
|
||||||
"router should define production-ledger route with MENU_WORK_ORDER permission"
|
"router should define production-ledger route with MENU_WORK_ORDER permission"
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@ -31,7 +31,7 @@ assert.match(
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/ref="salesOrderPickerRef"[\s\S]*class="[^"]*\border-tag-picker\b[^"]*"[\s\S]*@pointerdown\.stop[\s\S]*@mousedown\.stop[\s\S]*@click\.stop/,
|
/ref="salesOrderPickerRef"\s+class="order-tag-picker"\s+@pointerdown\.stop\s+@mousedown\.stop\s+@click\.stop/,
|
||||||
"sales order picker should keep internal clicks from bubbling into unrelated close handlers"
|
"sales order picker should keep internal clicks from bubbling into unrelated close handlers"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,7 @@ assert.doesNotMatch(source, /YL-NB|buildDefaultLotNo/, "purchase receipt UI must
|
|||||||
assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field");
|
assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field");
|
||||||
assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend");
|
assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend");
|
||||||
assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
|
assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
|
||||||
assert.match(
|
assert.match(source, /selectedPurchaseTargetType(?:\.value)?\s*===\s*["']RAW["'][\s\S]*运单号[\s\S]*辅助照片/, "raw purchase receipt UI should show waybill and auxiliary photo fields");
|
||||||
source,
|
|
||||||
/if\s*\(\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\)\s*\{[\s\S]*key:\s*["']waybill_no["'][\s\S]*label:\s*["']运单号["'][\s\S]*key:\s*["']order_photo["'][\s\S]*label:\s*["']辅助照片["']/,
|
|
||||||
"raw purchase receipt UI should show waybill and auxiliary photo fields"
|
|
||||||
);
|
|
||||||
assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting");
|
assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting");
|
||||||
assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number");
|
assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number");
|
||||||
assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL");
|
assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL");
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const requiredInventorySnippets = [
|
|||||||
"返工废料入库",
|
"返工废料入库",
|
||||||
"reworkScrapInbound",
|
"reworkScrapInbound",
|
||||||
"isReworkScrapInbound",
|
"isReworkScrapInbound",
|
||||||
"source_doc_type: isProductionScrapInbound.value ? \"PRODUCTION_LEDGER\" : isReworkScrapInbound.value ? \"WORK_ORDER\"",
|
"source_doc_type: isProductionScrapInbound.value || isReworkScrapInbound.value ? \"WORK_ORDER\"",
|
||||||
"inbound_qty: inboundQty"
|
"inbound_qty: inboundQty"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
assert.doesNotMatch(
|
|
||||||
routerSource,
|
|
||||||
/import\s+\w+View\s+from\s+"..\/views\/[^"]+\.vue";/,
|
|
||||||
"router should not statically import view components into the initial bundle"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(
|
|
||||||
routerSource,
|
|
||||||
/import\.meta\.glob\("\.\.\/views\/\*\.vue"\)/,
|
|
||||||
"router should register lazy view modules with Vite import.meta.glob"
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const viewName of [
|
|
||||||
"LoginView",
|
|
||||||
"DashboardView",
|
|
||||||
"InventoryLedgerView",
|
|
||||||
"PurchaseOrderView",
|
|
||||||
"ProductionLedgerView",
|
|
||||||
"SystemPermissionView"
|
|
||||||
]) {
|
|
||||||
assert.match(routerSource, new RegExp(`component:\\s*view\\("${viewName}"\\)`), `${viewName} route should use lazy view loader`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("router lazy loading static checks passed");
|
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const source = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8");
|
const appVuePath = resolve("frontend/src/App.vue");
|
||||||
|
const source = readFileSync(appVuePath, "utf8");
|
||||||
|
|
||||||
const navStart = source.indexOf("const navStages = [");
|
const navStart = source.indexOf("const navStages = [");
|
||||||
const navEnd = source.indexOf("\n];", navStart);
|
const navEnd = source.indexOf("\n];", navStart);
|
||||||
|
|||||||
@ -17,7 +17,8 @@ assert.match(router, /loadSmartOperationReportConfig/, "router should guard oper
|
|||||||
assert.match(app, /smartOperationReportEnabled/, "App should track switch state");
|
assert.match(app, /smartOperationReportEnabled/, "App should track switch state");
|
||||||
assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled");
|
assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled");
|
||||||
assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch");
|
assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch");
|
||||||
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail wording when enabled");
|
assert.doesNotMatch(productionLedger, /当前未对接智能报工小程序/, "production ledger page should not expose disabled integration wording");
|
||||||
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode");
|
assert.doesNotMatch(productionLedger, /工序明细隐藏/, "production ledger page should not expose hidden operation detail wording");
|
||||||
|
assert.doesNotMatch(productionLedger, /BOM毛重\/净重反推/, "production ledger page should not expose internal calculation wording");
|
||||||
|
|
||||||
console.log("smart operation report toggle static checks passed");
|
console.log("smart operation report toggle static checks passed");
|
||||||
|
|||||||
@ -139,19 +139,13 @@ const styleSource = readFileSync(
|
|||||||
fileURLToPath(new URL("../src/styles/main.css", import.meta.url)),
|
fileURLToPath(new URL("../src/styles/main.css", import.meta.url)),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
const stocktakeDiffModalStart = styleSource.indexOf(".stocktake-diff-modal {");
|
|
||||||
const stocktakeDiffModalEnd = styleSource.indexOf("\n}", stocktakeDiffModalStart);
|
|
||||||
assert.notEqual(stocktakeDiffModalStart, -1);
|
|
||||||
assert.notEqual(stocktakeDiffModalEnd, -1);
|
|
||||||
const stocktakeDiffModalBlock = styleSource.slice(stocktakeDiffModalStart, stocktakeDiffModalEnd + 2);
|
|
||||||
|
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-diff-table-v2[\s\S]*margin: 14px 18px 0;/);
|
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-diff-table-v2[\s\S]*margin: 14px 18px 0;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*min-height: 72px;/);
|
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*min-height: 72px;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*box-sizing: border-box;/);
|
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*box-sizing: border-box;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*z-index: 3;/);
|
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*z-index: 3;/);
|
||||||
assert.match(stocktakeDiffModalBlock, /height: min\(820px, 100%\);/);
|
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*height: min\(820px, 100%\);/);
|
||||||
assert.match(stocktakeDiffModalBlock, /max-height: 100%;/);
|
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*max-height: 100%;/);
|
||||||
assert.doesNotMatch(stocktakeDiffModalBlock, /calc\(100vh - 64px\)/);
|
assert.doesNotMatch(styleSource, /\.stocktake-diff-modal[\s\S]*calc\(100vh - 64px\)/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*justify-content: flex-end;/);
|
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*justify-content: flex-end;/);
|
||||||
assert.match(styleSource, /\.stocktake-confirm-mask[\s\S]*z-index: 4;/);
|
assert.match(styleSource, /\.stocktake-confirm-mask[\s\S]*z-index: 4;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/);
|
assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/);
|
||||||
|
|||||||
@ -18,8 +18,8 @@ assert.match(
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
routerSource,
|
routerSource,
|
||||||
/component:\s*view\("SystemPermissionView"\)/,
|
/import SystemPermissionView from "\.\.\/views\/SystemPermissionView\.vue";/,
|
||||||
"router should lazy load SystemPermissionView"
|
"router should import SystemPermissionView"
|
||||||
);
|
);
|
||||||
assert.match(routerSource, /path:\s*"\/system-management"[\s\S]*redirect:/, "router should add /system-management redirect");
|
assert.match(routerSource, /path:\s*"\/system-management"[\s\S]*redirect:/, "router should add /system-management redirect");
|
||||||
assert.match(routerSource, /path:\s*"\/system-permissions"/, "router should add /system-permissions route");
|
assert.match(routerSource, /path:\s*"\/system-permissions"/, "router should add /system-permissions route");
|
||||||
@ -203,7 +203,7 @@ assert.doesNotMatch(
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
systemPermissionSource,
|
systemPermissionSource,
|
||||||
/async function requestMindMapFitToScreen[\s\S]*mindMapFitRequestKey\.value \+= 1[\s\S]*function fitMindMapToScreen\(\)\s*\{\s*requestMindMapFitToScreen\(\{ showToast: true, closeMenu: true \}\);/m,
|
/function fitMindMapToScreen\(\)[\s\S]*mindMapFitRequestKey\.value \+= 1/,
|
||||||
"one-screen fit action should request a viewport fit explicitly"
|
"one-screen fit action should request a viewport fit explicitly"
|
||||||
);
|
);
|
||||||
assert.match(systemPermissionSource, /const updatePayload\s*=/, "org edit must build an update-only payload");
|
assert.match(systemPermissionSource, /const updatePayload\s*=/, "org edit must build an update-only payload");
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
function read(path) {
|
|
||||||
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ruleBlock(source, selector) {
|
|
||||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const match = source.match(new RegExp(`${escaped}\\s*\\{[\\s\\S]*?\\n\\}`, "m"));
|
|
||||||
assert.ok(match, `Expected to find CSS block for ${selector}`);
|
|
||||||
return match[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNoDeclaration(block, property) {
|
|
||||||
assert.ok(!new RegExp(`\\b${property}\\s*:`, "m").test(block), `Unexpected ${property} declaration in:\n${block}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainCss = read("src/styles/main.css");
|
|
||||||
const purchaseOrderView = read("src/views/PurchaseOrderView.vue");
|
|
||||||
const salesPlanningView = read("src/views/SalesPlanningView.vue");
|
|
||||||
const orgMindMap = read("src/components/OrgMindMap.vue");
|
|
||||||
|
|
||||||
assertNoDeclaration(ruleBlock(mainCss, ".workflow-drawer"), "border-left");
|
|
||||||
assertNoDeclaration(ruleBlock(mainCss, "body .login-console-frame"), "border-left");
|
|
||||||
assertNoDeclaration(ruleBlock(purchaseOrderView, ".section-title"), "border-left");
|
|
||||||
assertNoDeclaration(ruleBlock(salesPlanningView, ".section-title"), "border-left");
|
|
||||||
assertNoDeclaration(ruleBlock(purchaseOrderView, ".purchase-decision-section"), "border-right");
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
!/transition:[^;]*(?:stroke-width|\br\s+[0-9.]+s)/.test(mainCss),
|
|
||||||
"SVG DAG transitions should not animate stroke-width or radius-like r properties"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
!/transition:[^;]*(?:\bwidth\b|\bheight\b)/.test(orgMindMap),
|
|
||||||
"Org mind map canvas should not animate width or height"
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("ui antipattern guard ok");
|
|
||||||
@ -19,3 +19,13 @@ describe("App reset password account picker", () => {
|
|||||||
assert.match(source, /formatResetPasswordUserLabel\(user\)/);
|
assert.match(source, /formatResetPasswordUserLabel\(user\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("App user menu interactions", () => {
|
||||||
|
it("closes the avatar menu when the user clicks outside the menu shell", () => {
|
||||||
|
assert.match(source, /ref="userMenuShellRef"/);
|
||||||
|
assert.match(source, /function handleUserMenuOutsidePointerDown\(event\)/);
|
||||||
|
assert.match(source, /userMenuShellRef\.value\?\.contains\(event\.target\)/);
|
||||||
|
assert.match(source, /document\.addEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
|
||||||
|
assert.match(source, /document\.removeEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -110,7 +110,7 @@
|
|||||||
<span class="assistant-bot-eye"></span>
|
<span class="assistant-bot-eye"></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-menu-shell">
|
<div ref="userMenuShellRef" class="user-menu-shell">
|
||||||
<button class="user-avatar-button" type="button" @click="toggleUserMenu">
|
<button class="user-avatar-button" type="button" @click="toggleUserMenu">
|
||||||
<span class="user-avatar">{{ avatarInitial }}</span>
|
<span class="user-avatar">{{ avatarInitial }}</span>
|
||||||
<span class="user-avatar-meta">
|
<span class="user-avatar-meta">
|
||||||
@ -307,6 +307,7 @@ const activeBroadcasts = ref([]);
|
|||||||
const activeBroadcastIndex = ref(0);
|
const activeBroadcastIndex = ref(0);
|
||||||
const assistantDrawerOpen = ref(false);
|
const assistantDrawerOpen = ref(false);
|
||||||
const userMenuOpen = ref(false);
|
const userMenuOpen = ref(false);
|
||||||
|
const userMenuShellRef = ref(null);
|
||||||
const changePasswordOpen = ref(false);
|
const changePasswordOpen = ref(false);
|
||||||
const resetPasswordOpen = ref(false);
|
const resetPasswordOpen = ref(false);
|
||||||
const passwordSubmitting = ref(false);
|
const passwordSubmitting = ref(false);
|
||||||
@ -1011,9 +1012,20 @@ function handleTagContextAction(action) {
|
|||||||
function handleGlobalKeydown(event) {
|
function handleGlobalKeydown(event) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
closeTagContextMenu();
|
closeTagContextMenu();
|
||||||
|
userMenuOpen.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUserMenuOutsidePointerDown(event) {
|
||||||
|
if (!userMenuOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userMenuShellRef.value?.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function updateShellViewportState() {
|
function updateShellViewportState() {
|
||||||
narrowShell.value = window.innerWidth < 1024;
|
narrowShell.value = window.innerWidth < 1024;
|
||||||
if (narrowShell.value) {
|
if (narrowShell.value) {
|
||||||
@ -1040,6 +1052,7 @@ onMounted(() => {
|
|||||||
window.addEventListener("resize", updateShellViewportState);
|
window.addEventListener("resize", updateShellViewportState);
|
||||||
window.addEventListener("click", closeTagContextMenu);
|
window.addEventListener("click", closeTagContextMenu);
|
||||||
window.addEventListener("keydown", handleGlobalKeydown);
|
window.addEventListener("keydown", handleGlobalKeydown);
|
||||||
|
document.addEventListener("pointerdown", handleUserMenuOutsidePointerDown);
|
||||||
void loadActiveBroadcast();
|
void loadActiveBroadcast();
|
||||||
void loadSmartOperationFeature();
|
void loadSmartOperationFeature();
|
||||||
broadcastTimer = window.setInterval(() => {
|
broadcastTimer = window.setInterval(() => {
|
||||||
@ -1054,6 +1067,7 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener("resize", updateShellViewportState);
|
window.removeEventListener("resize", updateShellViewportState);
|
||||||
window.removeEventListener("click", closeTagContextMenu);
|
window.removeEventListener("click", closeTagContextMenu);
|
||||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||||
|
document.removeEventListener("pointerdown", handleUserMenuOutsidePointerDown);
|
||||||
if (broadcastTimer) {
|
if (broadcastTimer) {
|
||||||
window.clearInterval(broadcastTimer);
|
window.clearInterval(broadcastTimer);
|
||||||
broadcastTimer = null;
|
broadcastTimer = null;
|
||||||
|
|||||||
@ -484,6 +484,7 @@ defineExpose({
|
|||||||
|
|
||||||
.org-mind-map-canvas {
|
.org-mind-map-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
transition: width 0.18s ease, height 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-mind-map-connector {
|
.org-mind-map-connector {
|
||||||
|
|||||||
73
frontend/src/components/OrgPermissionTree.test.js
Normal file
73
frontend/src/components/OrgPermissionTree.test.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(join(__dirname, "OrgPermissionTree.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("OrgPermissionTree hierarchy lines", () => {
|
||||||
|
it("draws connector lines only between each node and its direct children", () => {
|
||||||
|
assert.match(source, /\.org-tree-children\s*>\s*\.org-tree-branch::before/);
|
||||||
|
assert.match(source, /\.org-tree-children\s*>\s*\.org-tree-branch\s*>\s*\.org-tree-node::before/);
|
||||||
|
assert.doesNotMatch(source, /\.org-tree-children::before/);
|
||||||
|
assert.doesNotMatch(source, /^\.org-tree-branch::before/m);
|
||||||
|
assert.match(source, /--tree-connector-x/);
|
||||||
|
assert.match(source, /--tree-node-start/);
|
||||||
|
assert.match(source, /--tree-arrow-gap/);
|
||||||
|
assert.match(source, /--tree-connector-color/);
|
||||||
|
assert.match(source, /--tree-connector-color:\s*rgba\(148,\s*163,\s*184/);
|
||||||
|
assert.match(source, /--tree-connector-width:\s*2px/);
|
||||||
|
assert.match(source, /border-left:\s*var\(--tree-connector-width\)\s*dashed\s*var\(--tree-connector-color\)/);
|
||||||
|
assert.match(source, /border-top:\s*var\(--tree-connector-width\)\s*dashed\s*var\(--tree-connector-color\)/);
|
||||||
|
assert.doesNotMatch(source, /background:\s*var\(--tree-connector-color\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OrgPermissionTree employee list", () => {
|
||||||
|
it("shows each employee organization path with full-value hover text", () => {
|
||||||
|
assert.match(source, /<th>组织路径<\/th>/);
|
||||||
|
assert.match(source, /employeeOrganizationPath\(employee\)/);
|
||||||
|
assert.match(source, /class="org-path-value"\s+:title="employeeOrganizationPath\(employee\)"/);
|
||||||
|
assert.match(source, /class="org-path-tail"\s+:title="employeeOrganizationPath\(employee\)"/);
|
||||||
|
assert.match(source, /function employeeOrganizationTail\(employee\)/);
|
||||||
|
assert.doesNotMatch(source, /org-path-pill/);
|
||||||
|
assert.match(source, /<td colspan="7" class="empty-row"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps row action buttons inside an inner flex container instead of flexing the table cell", () => {
|
||||||
|
assert.match(source, /<td class="action-row">\s*<div class="employee-actions">/);
|
||||||
|
assert.match(source, /\.employee-actions\s*\{[\s\S]*?display:\s*inline-flex;/);
|
||||||
|
assert.doesNotMatch(source, /\.action-row\s*\{[^}]*display:\s*flex;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OrgPermissionTree node action parity", () => {
|
||||||
|
it("exposes a visible current-node action entry that emits the shared node context menu event", () => {
|
||||||
|
assert.match(source, /节点操作/);
|
||||||
|
assert.match(source, /@click\.stop="openSelectedNodeMenu"/);
|
||||||
|
assert.match(source, /function openSelectedNodeMenu\(event\)/);
|
||||||
|
assert.match(source, /emit\("node-contextmenu"/);
|
||||||
|
assert.doesNotMatch(source, /function runNodeAction\(/);
|
||||||
|
assert.doesNotMatch(source, /function nodeActions\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original tree-node right-click path routed to the shared context menu event", () => {
|
||||||
|
assert.match(source, /@open-menu="openNodeMenu"/);
|
||||||
|
assert.match(source, /onContextmenu:\s*openMenu/);
|
||||||
|
assert.match(source, /function openMenu\(event\)[\s\S]*?branchEmit\("open-menu"/);
|
||||||
|
assert.match(source, /function openNodeMenu\(payload\)[\s\S]*?emit\("node-contextmenu",\s*payload\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OrgPermissionTree manager visibility", () => {
|
||||||
|
it("shows organization managers in both tree nodes and the selected-node header", () => {
|
||||||
|
assert.match(source, /class="node-manager-line"\s+:title="selectedNodeManagerText"/);
|
||||||
|
assert.match(source, /const selectedNodeManagerText = computed\(\(\) => selectedNode\.value \? nodeManagerText\(selectedNode\.value\) : "主管:未选择组织"\);/);
|
||||||
|
assert.match(source, /class:\s*"tree-node-manager",\s*title:\s*nodeManagerText\(branchProps\.node\)/);
|
||||||
|
assert.match(source, /function nodeManagerNames\(node\)/);
|
||||||
|
assert.match(source, /function nodeManagerText\(node\)/);
|
||||||
|
assert.match(source, /主管:未设置/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -35,8 +35,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">组织人员</p>
|
<p class="eyebrow">组织人员</p>
|
||||||
<h3>{{ selectedNodeLabel }}</h3>
|
<h3>{{ selectedNodeLabel }}</h3>
|
||||||
|
<p class="node-manager-line" :title="selectedNodeManagerText">{{ selectedNodeManagerText }}</p>
|
||||||
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="employee-head-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button node-action-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!selectedNode"
|
||||||
|
@click.stop="openSelectedNodeMenu"
|
||||||
|
>
|
||||||
|
节点操作
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="primary-button"
|
class="primary-button"
|
||||||
type="button"
|
type="button"
|
||||||
@ -46,6 +56,7 @@
|
|||||||
新增人员
|
新增人员
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="employee-search-field">
|
<label class="employee-search-field">
|
||||||
<span>搜索</span>
|
<span>搜索</span>
|
||||||
@ -54,9 +65,19 @@
|
|||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table compact-table org-employee-table">
|
<table class="data-table compact-table org-employee-table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="employee-col-name" />
|
||||||
|
<col class="employee-col-path" />
|
||||||
|
<col class="employee-col-phone" />
|
||||||
|
<col class="employee-col-position" />
|
||||||
|
<col class="employee-col-role" />
|
||||||
|
<col class="employee-col-status" />
|
||||||
|
<col class="employee-col-action" />
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>姓名</th>
|
<th>姓名</th>
|
||||||
|
<th>组织路径</th>
|
||||||
<th>电话号码 / 账号</th>
|
<th>电话号码 / 账号</th>
|
||||||
<th>岗位</th>
|
<th>岗位</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
@ -67,6 +88,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
|
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
|
||||||
<td>{{ employeeName(employee) }}</td>
|
<td>{{ employeeName(employee) }}</td>
|
||||||
|
<td class="org-path-cell">
|
||||||
|
<span class="org-path-value" :title="employeeOrganizationPath(employee)">{{ employeeOrganizationPath(employee) }}</span>
|
||||||
|
<span class="org-path-tail" :title="employeeOrganizationPath(employee)">{{ employeeOrganizationTail(employee) }}</span>
|
||||||
|
</td>
|
||||||
<td>{{ employeePhone(employee) }}</td>
|
<td>{{ employeePhone(employee) }}</td>
|
||||||
<td>{{ employeePosition(employee) }}</td>
|
<td>{{ employeePosition(employee) }}</td>
|
||||||
<td>{{ employeeRoles(employee) }}</td>
|
<td>{{ employeeRoles(employee) }}</td>
|
||||||
@ -74,12 +99,14 @@
|
|||||||
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
|
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="action-row">
|
<td class="action-row">
|
||||||
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">授权</button>
|
<div class="employee-actions">
|
||||||
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除</button>
|
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">人员授权</button>
|
||||||
|
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除记录</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!filteredEmployees.length">
|
<tr v-if="!filteredEmployees.length">
|
||||||
<td colspan="6" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
|
<td colspan="7" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -95,6 +122,7 @@ import { computed, defineComponent, h, ref, watch } from "vue";
|
|||||||
|
|
||||||
import PaginationBar from "./PaginationBar.vue";
|
import PaginationBar from "./PaginationBar.vue";
|
||||||
import StatusBadge from "./StatusBadge.vue";
|
import StatusBadge from "./StatusBadge.vue";
|
||||||
|
import { collectOrganizationEmployees, countOrganizationEmployees } from "../utils/orgTreeEmployees";
|
||||||
import { usePagination } from "../utils/pagination";
|
import { usePagination } from "../utils/pagination";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -164,13 +192,16 @@ const OrgTreeBranch = defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h("div", { class: "org-tree-branch" }, [
|
h("div", {
|
||||||
|
class: ["org-tree-branch", { "org-tree-branch-root": branchProps.level === 0 }],
|
||||||
|
style: treeLevelStyle(branchProps.level)
|
||||||
|
}, [
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
class: ["org-tree-node", { active: isSelected.value }],
|
class: ["org-tree-node", { active: isSelected.value }],
|
||||||
style: { "--tree-level": branchProps.level },
|
style: treeLevelStyle(branchProps.level),
|
||||||
onClick: selectCurrent,
|
onClick: selectCurrent,
|
||||||
onContextmenu: openMenu
|
onContextmenu: openMenu
|
||||||
},
|
},
|
||||||
@ -186,7 +217,8 @@ const OrgTreeBranch = defineComponent({
|
|||||||
h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
|
h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
|
||||||
h("span", { class: "tree-node-main" }, [
|
h("span", { class: "tree-node-main" }, [
|
||||||
h("strong", null, nodeLabel(branchProps.node)),
|
h("strong", null, nodeLabel(branchProps.node)),
|
||||||
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`)
|
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`),
|
||||||
|
h("small", { class: "tree-node-manager", title: nodeManagerText(branchProps.node) }, nodeManagerText(branchProps.node))
|
||||||
])
|
])
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@ -225,7 +257,8 @@ const requestedNode = computed(() => findNodeById(organizationNodes.value, props
|
|||||||
const selectedNode = computed(() => requestedNode.value || firstRootNode.value);
|
const selectedNode = computed(() => requestedNode.value || firstRootNode.value);
|
||||||
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
|
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
|
||||||
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织");
|
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织");
|
||||||
const selectedEmployees = computed(() => Array.isArray(selectedNode.value?.employees) ? selectedNode.value.employees : []);
|
const selectedNodeManagerText = computed(() => selectedNode.value ? nodeManagerText(selectedNode.value) : "主管:未选择组织");
|
||||||
|
const selectedEmployees = computed(() => collectOrganizationEmployees(selectedNode.value));
|
||||||
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
|
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
|
||||||
const filteredEmployees = computed(() => {
|
const filteredEmployees = computed(() => {
|
||||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||||
@ -233,7 +266,7 @@ const filteredEmployees = computed(() => {
|
|||||||
return selectedEmployees.value;
|
return selectedEmployees.value;
|
||||||
}
|
}
|
||||||
return selectedEmployees.value.filter((employee) =>
|
return selectedEmployees.value.filter((employee) =>
|
||||||
[employeeName(employee), employeePhone(employee), employee.employee_code, employee.username]
|
[employeeName(employee), employeeOrganizationPath(employee), employeePhone(employee), employee.employee_code, employee.username]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@ -263,6 +296,16 @@ function organizationChildren(node) {
|
|||||||
return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode);
|
return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function treeLevelStyle(level) {
|
||||||
|
const numericLevel = Math.max(Number(level || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--tree-level": numericLevel,
|
||||||
|
"--tree-connector-x": `${15 + Math.max(numericLevel - 1, 0) * 22}px`,
|
||||||
|
"--tree-node-start": `${6 + numericLevel * 22}px`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function filterOrgTreeByKeyword(nodes, keyword) {
|
function filterOrgTreeByKeyword(nodes, keyword) {
|
||||||
const normalizedKeyword = String(keyword || "").trim().toLowerCase();
|
const normalizedKeyword = String(keyword || "").trim().toLowerCase();
|
||||||
if (!normalizedKeyword) {
|
if (!normalizedKeyword) {
|
||||||
@ -344,26 +387,23 @@ function nodeTypeShortLabel(type) {
|
|||||||
}[type] || "组";
|
}[type] || "组";
|
||||||
}
|
}
|
||||||
|
|
||||||
function employeeCount(node) {
|
function nodeManagerNames(node) {
|
||||||
const employeeKeys = new Set();
|
if (Array.isArray(node?.manager_names) && node.manager_names.length) {
|
||||||
collectEmployeeKeys(node, employeeKeys);
|
return node.manager_names.filter(Boolean);
|
||||||
return employeeKeys.size;
|
}
|
||||||
|
if (node?.manager_name) {
|
||||||
|
return [node.manager_name];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectEmployeeKeys(node, employeeKeys) {
|
function nodeManagerText(node) {
|
||||||
if (!node) {
|
const names = nodeManagerNames(node);
|
||||||
return;
|
return names.length ? `主管:${names.join("、")}` : "主管:未设置";
|
||||||
}
|
|
||||||
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
|
|
||||||
employeeKeys.add(String(employeeKey(employee)));
|
|
||||||
}
|
|
||||||
for (const child of Array.isArray(node.children) ? node.children : []) {
|
|
||||||
if (isOrganizationNode(child)) {
|
|
||||||
collectEmployeeKeys(child, employeeKeys);
|
|
||||||
} else if (child?.employee_id) {
|
|
||||||
employeeKeys.add(String(employeeKey(child)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function employeeCount(node) {
|
||||||
|
return countOrganizationEmployees(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleNode(node) {
|
function toggleNode(node) {
|
||||||
@ -385,6 +425,18 @@ function openNodeMenu(payload) {
|
|||||||
emit("node-contextmenu", payload);
|
emit("node-contextmenu", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSelectedNodeMenu(event) {
|
||||||
|
if (!selectedNode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = event.currentTarget?.getBoundingClientRect?.();
|
||||||
|
emit("node-contextmenu", {
|
||||||
|
node: selectedNode.value,
|
||||||
|
x: rect ? rect.left : event.clientX,
|
||||||
|
y: rect ? rect.bottom + 6 : event.clientY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function employeeName(employee) {
|
function employeeName(employee) {
|
||||||
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
|
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
|
||||||
}
|
}
|
||||||
@ -397,6 +449,15 @@ function employeePosition(employee) {
|
|||||||
return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位";
|
return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function employeeOrganizationPath(employee) {
|
||||||
|
return employee?.organization_path || employee?.parent_node_label || "未设置组织";
|
||||||
|
}
|
||||||
|
|
||||||
|
function employeeOrganizationTail(employee) {
|
||||||
|
const pathParts = Array.isArray(employee?.organization_path_parts) ? employee.organization_path_parts.filter(Boolean) : [];
|
||||||
|
return pathParts.length ? pathParts[pathParts.length - 1] : employeeOrganizationPath(employee);
|
||||||
|
}
|
||||||
|
|
||||||
function employeeRoles(employee) {
|
function employeeRoles(employee) {
|
||||||
if (Array.isArray(employee?.role_names) && employee.role_names.length) {
|
if (Array.isArray(employee?.role_names) && employee.role_names.length) {
|
||||||
return employee.role_names.join("、");
|
return employee.role_names.join("、");
|
||||||
@ -424,8 +485,8 @@ function employeeKey(employee) {
|
|||||||
function employeeWithParent(employee) {
|
function employeeWithParent(employee) {
|
||||||
return {
|
return {
|
||||||
...employee,
|
...employee,
|
||||||
parent_node_id: getNodeId(selectedNode.value),
|
parent_node_id: employee?.parent_node_id ?? getNodeId(selectedNode.value),
|
||||||
parent_node_label: nodeLabel(selectedNode.value)
|
parent_node_label: employee?.parent_node_label ?? nodeLabel(selectedNode.value)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -489,6 +550,24 @@ function removeEmployee(employee) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employee-head-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-button {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||||
|
background: #ffffff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-button:hover:not(:disabled) {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
@ -567,6 +646,10 @@ function removeEmployee(employee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.org-tree-list {
|
.org-tree-list {
|
||||||
|
--tree-connector-color: rgba(148, 163, 184, 0.92);
|
||||||
|
--tree-connector-width: 2px;
|
||||||
|
--tree-arrow-gap: 6px;
|
||||||
|
--tree-node-center: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -579,12 +662,30 @@ function removeEmployee(employee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.org-tree-branch {
|
.org-tree-branch {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-tree-children > .org-tree-branch::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(-1 * var(--tree-connector-width));
|
||||||
|
bottom: calc(-1 * var(--tree-connector-width));
|
||||||
|
left: var(--tree-connector-x);
|
||||||
|
width: 0;
|
||||||
|
border-left: var(--tree-connector-width) dashed var(--tree-connector-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree-children > .org-tree-branch:last-child::before {
|
||||||
|
bottom: auto;
|
||||||
|
height: calc(var(--tree-node-center) + var(--tree-connector-width));
|
||||||
|
}
|
||||||
|
|
||||||
.org-tree-node {
|
.org-tree-node {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18px 24px minmax(0, 1fr);
|
grid-template-columns: 18px 24px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -600,6 +701,17 @@ function removeEmployee(employee) {
|
|||||||
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-tree-children > .org-tree-branch > .org-tree-node::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: var(--tree-node-center);
|
||||||
|
left: var(--tree-connector-x);
|
||||||
|
width: max(8px, calc(var(--tree-node-start) - var(--tree-connector-x) - var(--tree-arrow-gap)));
|
||||||
|
height: 0;
|
||||||
|
border-top: var(--tree-connector-width) dashed var(--tree-connector-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.org-tree-node:hover,
|
.org-tree-node:hover,
|
||||||
.org-tree-node.active {
|
.org-tree-node.active {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
@ -670,6 +782,7 @@ function removeEmployee(employee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.org-tree-children {
|
.org-tree-children {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@ -734,9 +847,23 @@ function removeEmployee(employee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
display: flex;
|
overflow: visible;
|
||||||
flex-wrap: wrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-actions .ghost-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-row {
|
.empty-row {
|
||||||
@ -749,13 +876,92 @@ function removeEmployee(employee) {
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-name {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-path {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-phone {
|
||||||
|
width: 13%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-position {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-role {
|
||||||
|
width: 11%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-status {
|
||||||
|
width: 7%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-employee-table .employee-col-action {
|
||||||
|
width: 19%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-path-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-path-value,
|
||||||
|
.org-path-tail {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-path-value {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-path-tail {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: #eef6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.org-tree-branch) {
|
:deep(.org-tree-branch) {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.org-tree-children > .org-tree-branch::before) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(-1 * var(--tree-connector-width));
|
||||||
|
bottom: calc(-1 * var(--tree-connector-width));
|
||||||
|
left: var(--tree-connector-x);
|
||||||
|
width: 0;
|
||||||
|
border-left: var(--tree-connector-width) dashed var(--tree-connector-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.org-tree-children > .org-tree-branch:last-child::before) {
|
||||||
|
bottom: auto;
|
||||||
|
height: calc(var(--tree-node-center) + var(--tree-connector-width));
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.org-tree-node) {
|
:deep(.org-tree-node) {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18px 24px minmax(0, 1fr);
|
grid-template-columns: 18px 24px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -772,6 +978,17 @@ function removeEmployee(employee) {
|
|||||||
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.org-tree-children > .org-tree-branch > .org-tree-node::before) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: var(--tree-node-center);
|
||||||
|
left: var(--tree-connector-x);
|
||||||
|
width: max(8px, calc(var(--tree-node-start) - var(--tree-connector-x) - var(--tree-arrow-gap)));
|
||||||
|
height: 0;
|
||||||
|
border-top: var(--tree-connector-width) dashed var(--tree-connector-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.org-tree-node:hover),
|
:deep(.org-tree-node:hover),
|
||||||
:deep(.org-tree-node.active) {
|
:deep(.org-tree-node.active) {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
@ -842,6 +1059,7 @@ function removeEmployee(employee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.org-tree-children) {
|
:deep(.org-tree-children) {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|||||||
53
frontend/src/components/documentForms/DocumentActionBar.vue
Normal file
53
frontend/src/components/documentForms/DocumentActionBar.vue
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<span ref="anchorRef" class="document-action-anchor" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<Teleport :to="actionHost" :disabled="!actionHost">
|
||||||
|
<div
|
||||||
|
class="document-action-bar"
|
||||||
|
:class="[
|
||||||
|
$attrs.class,
|
||||||
|
{ 'document-action-bar--with-state': $slots.state }
|
||||||
|
]"
|
||||||
|
@click.capture="handleActionClick"
|
||||||
|
>
|
||||||
|
<div v-if="$slots.state" class="document-action-state">
|
||||||
|
<slot name="state" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="document-action-buttons">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchorRef = ref(null);
|
||||||
|
const actionHost = ref(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
const form = anchorRef.value?.closest?.("form");
|
||||||
|
actionHost.value = form?.querySelector?.("[data-document-action-host]") || form?.querySelector?.("[data-warehouse-document-action-host]") || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleActionClick(event) {
|
||||||
|
const target = event.target;
|
||||||
|
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
|
||||||
|
if (!submitter || submitter.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = anchorRef.value?.closest?.("form");
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
55
frontend/src/components/documentForms/DocumentField.vue
Normal file
55
frontend/src/components/documentForms/DocumentField.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
class="document-paper-field"
|
||||||
|
:class="[
|
||||||
|
spanClass,
|
||||||
|
{
|
||||||
|
'document-paper-field-readonly': readonly,
|
||||||
|
'document-paper-field-upload': upload
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<slot>
|
||||||
|
<p class="document-paper-readonly-value">{{ value || "-" }}</p>
|
||||||
|
</slot>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
span: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
as: {
|
||||||
|
type: String,
|
||||||
|
default: "label"
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const spanClass = computed(() => {
|
||||||
|
if (String(props.span) === "full") return "document-paper-field-full";
|
||||||
|
if (Number(props.span) === 2) return "document-paper-field-wide";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
73
frontend/src/components/documentForms/DocumentFormShell.vue
Normal file
73
frontend/src/components/documentForms/DocumentFormShell.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<form class="document-form-shell" @submit.prevent="emit('submit')">
|
||||||
|
<DocumentPaper
|
||||||
|
:title="title"
|
||||||
|
:document-no="documentNo"
|
||||||
|
:company-name="companyName"
|
||||||
|
:tone="tone"
|
||||||
|
:signature-labels="signatureLabels"
|
||||||
|
>
|
||||||
|
<div class="document-form-meta" :aria-label="metaAriaLabel">
|
||||||
|
<span class="document-form-meta-pair">
|
||||||
|
<span>{{ metaLabel }}</span>
|
||||||
|
<strong>{{ companyName }}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="document-form-meta-pair document-form-meta-date">
|
||||||
|
<span>{{ dateLabel }}</span>
|
||||||
|
<strong>{{ displayBusinessDate }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="document-form-body">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</DocumentPaper>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import DocumentPaper from "./DocumentPaper.vue";
|
||||||
|
import { formatChineseDate } from "../../utils/formatters";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
documentNo: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
businessDate: {
|
||||||
|
type: [String, Date],
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: "green"
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: String,
|
||||||
|
default: "宁波嘉恒智能科技有限公司"
|
||||||
|
},
|
||||||
|
metaLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "业务联单"
|
||||||
|
},
|
||||||
|
dateLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "业务日期"
|
||||||
|
},
|
||||||
|
signatureLabels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ["制单", "经办", "审核", "财务"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["submit"]);
|
||||||
|
|
||||||
|
const displayBusinessDate = computed(() => formatChineseDate(props.businessDate));
|
||||||
|
const metaAriaLabel = computed(() => `${props.metaLabel}基础信息`);
|
||||||
|
</script>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="document-inline-summary" :class="toneClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: "green"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneClass = computed(() => `document-inline-summary-${props.tone}`);
|
||||||
|
</script>
|
||||||
@ -43,7 +43,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div class="document-paper-action-host" data-warehouse-document-action-host></div>
|
<div
|
||||||
|
class="document-paper-action-host"
|
||||||
|
data-document-action-host
|
||||||
|
data-warehouse-document-action-host
|
||||||
|
></div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
25
frontend/src/components/documentForms/DocumentSection.vue
Normal file
25
frontend/src/components/documentForms/DocumentSection.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<section class="document-section">
|
||||||
|
<header class="document-section-head">
|
||||||
|
<span class="document-section-index">{{ index }}</span>
|
||||||
|
<strong>{{ title }}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="document-section-grid">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
index: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<section class="document-selector-panel">
|
||||||
|
<div class="document-selector-heading">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<strong>{{ hint }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="document-selector-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
type: String,
|
||||||
|
default: "请选择"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<section class="document-settle-panel">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@ -1,47 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<span ref="anchorRef" class="warehouse-document-action-anchor" aria-hidden="true"></span>
|
<DocumentActionBar class="warehouse-document-action-bar warehouse-document-action-bar--shared">
|
||||||
|
<template v-if="$slots.state" #state>
|
||||||
<Teleport :to="actionHost" :disabled="!actionHost">
|
|
||||||
<div
|
|
||||||
class="warehouse-document-action-bar warehouse-document-action-bar--shared"
|
|
||||||
:class="{ 'warehouse-document-action-bar--with-state': $slots.state }"
|
|
||||||
@click.capture="handleActionClick"
|
|
||||||
>
|
|
||||||
<div v-if="$slots.state" class="warehouse-document-action-state">
|
|
||||||
<slot name="state" />
|
<slot name="state" />
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div class="warehouse-document-action-buttons">
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</DocumentActionBar>
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from "vue";
|
import DocumentActionBar from "./DocumentActionBar.vue";
|
||||||
|
|
||||||
const anchorRef = ref(null);
|
|
||||||
const actionHost = ref(null);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick();
|
|
||||||
actionHost.value = anchorRef.value
|
|
||||||
?.closest?.("form")
|
|
||||||
?.querySelector?.("[data-warehouse-document-action-host]") || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleActionClick(event) {
|
|
||||||
const target = event.target;
|
|
||||||
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
|
|
||||||
if (!submitter || submitter.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const form = anchorRef.value?.closest?.("form");
|
|
||||||
if (!form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,27 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<form class="warehouse-document-form" @submit.prevent="emit('submit')">
|
<DocumentFormShell
|
||||||
<DocumentPaper
|
class="warehouse-document-form"
|
||||||
:title="title"
|
:title="title"
|
||||||
:document-no="documentNo"
|
:document-no="documentNo"
|
||||||
:company-name="companyName"
|
:business-date="businessDate"
|
||||||
:tone="tone"
|
:tone="tone"
|
||||||
|
:company-name="companyName"
|
||||||
|
meta-label="仓库联单"
|
||||||
|
:signature-labels="signatureLabels"
|
||||||
|
@submit="emit('submit')"
|
||||||
>
|
>
|
||||||
<div class="warehouse-document-form-meta" aria-label="仓库单据基础信息">
|
|
||||||
<span>仓库联单</span>
|
|
||||||
<strong>{{ companyName }}</strong>
|
|
||||||
<span>业务日期</span>
|
|
||||||
<strong>{{ businessDate || "-" }}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="warehouse-document-body">
|
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</DocumentFormShell>
|
||||||
</DocumentPaper>
|
|
||||||
</form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import DocumentPaper from "./DocumentPaper.vue";
|
import DocumentFormShell from "./DocumentFormShell.vue";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
title: {
|
||||||
@ -33,7 +27,7 @@ defineProps({
|
|||||||
default: ""
|
default: ""
|
||||||
},
|
},
|
||||||
businessDate: {
|
businessDate: {
|
||||||
type: String,
|
type: [String, Date],
|
||||||
default: ""
|
default: ""
|
||||||
},
|
},
|
||||||
tone: {
|
tone: {
|
||||||
@ -43,6 +37,10 @@ defineProps({
|
|||||||
companyName: {
|
companyName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "嘉恒仓库"
|
default: "嘉恒仓库"
|
||||||
|
},
|
||||||
|
signatureLabels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ["制单", "审核", "仓库", "财务"]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="warehouse-document-section">
|
<DocumentSection
|
||||||
<header class="warehouse-document-section-head">
|
class="warehouse-document-section"
|
||||||
<span class="warehouse-document-section-index">{{ index }}</span>
|
:index="index"
|
||||||
<strong>{{ title }}</strong>
|
:title="title"
|
||||||
</header>
|
>
|
||||||
|
|
||||||
<div class="warehouse-document-section-grid">
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</DocumentSection>
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import DocumentSection from "./DocumentSection.vue";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
index: {
|
index: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
|
|||||||
83
frontend/src/components/documentForms/documentForms.test.js
Normal file
83
frontend/src/components/documentForms/documentForms.test.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function readComponent(name) {
|
||||||
|
return readFileSync(join(__dirname, name), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainCss = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
|
||||||
|
|
||||||
|
describe("document form foundation", () => {
|
||||||
|
it("provides generic system-wide paper form components", () => {
|
||||||
|
[
|
||||||
|
"DocumentFormShell.vue",
|
||||||
|
"DocumentSection.vue",
|
||||||
|
"DocumentActionBar.vue",
|
||||||
|
"DocumentField.vue",
|
||||||
|
"DocumentSelectorPanel.vue",
|
||||||
|
"DocumentInlineSummary.vue",
|
||||||
|
"DocumentSettlePanel.vue"
|
||||||
|
].forEach((name) => {
|
||||||
|
const source = readComponent(name);
|
||||||
|
assert.match(source, /document-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps warehouse wrappers as compatibility adapters over generic document components", () => {
|
||||||
|
assert.match(readComponent("WarehouseDocumentFormShell.vue"), /import DocumentFormShell/);
|
||||||
|
assert.match(readComponent("WarehouseDocumentSection.vue"), /import DocumentSection/);
|
||||||
|
assert.match(readComponent("WarehouseDocumentActionBar.vue"), /import DocumentActionBar/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a generic action host without removing the existing warehouse host", () => {
|
||||||
|
const paper = readComponent("DocumentPaper.vue");
|
||||||
|
assert.match(paper, /data-document-action-host/);
|
||||||
|
assert.match(paper, /data-warehouse-document-action-host/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines generic document paper controls and keeps warehouse aliases compatible", () => {
|
||||||
|
assert.match(mainCss, /\.document-form-shell/);
|
||||||
|
assert.match(mainCss, /\.document-section-grid/);
|
||||||
|
assert.match(mainCss, /\.document-paper-field/);
|
||||||
|
assert.match(mainCss, /\.document-selector-panel/);
|
||||||
|
assert.match(mainCss, /\.document-inline-summary/);
|
||||||
|
assert.match(mainCss, /\.document-settle-panel/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-control-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps document header labels close to their values instead of stretching business date apart", () => {
|
||||||
|
const shell = readComponent("DocumentFormShell.vue");
|
||||||
|
|
||||||
|
assert.match(shell, /class="document-form-meta-pair"/);
|
||||||
|
assert.match(shell, /class="document-form-meta-pair document-form-meta-date"/);
|
||||||
|
assert.match(mainCss, /\.document-form-meta\s*\{[\s\S]*display:\s*flex;/);
|
||||||
|
assert.match(mainCss, /\.document-form-meta-date\s*\{[\s\S]*margin-left:\s*auto;/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
mainCss.match(/\.document-form-meta\s*\{[\s\S]*?\}/)?.[0] || "",
|
||||||
|
/grid-template-columns:[^;]*1fr/,
|
||||||
|
"document form meta should not use a flexible spacer that separates 业务日期 from its date value"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the final paper layout contract after legacy warehouse grid rules", () => {
|
||||||
|
const legacyWarehouseGrid = mainCss.lastIndexOf(".warehouse-document-section-grid {");
|
||||||
|
const finalContract = mainCss.lastIndexOf("Final paper document layout contract");
|
||||||
|
const warehousePaperGrid = mainCss.lastIndexOf("body .warehouse-document-section-grid > .warehouse-paper-grid");
|
||||||
|
const genericWrappedWarehousePaperGrid = mainCss.lastIndexOf("body .document-section-grid > .warehouse-paper-grid");
|
||||||
|
const genericWrappedWarehouseMeta = mainCss.lastIndexOf("body .document-section-grid > .warehouse-document-meta-strip");
|
||||||
|
const genericDocumentGrid = mainCss.lastIndexOf("body .document-section-grid > .document-grid");
|
||||||
|
|
||||||
|
assert.ok(legacyWarehouseGrid > 0, "expected the legacy warehouse grid rule to exist");
|
||||||
|
assert.ok(finalContract > legacyWarehouseGrid, "expected final paper contract after legacy grid rules");
|
||||||
|
assert.ok(warehousePaperGrid > legacyWarehouseGrid, "expected warehouse paper grid full-width rule after legacy grid");
|
||||||
|
assert.ok(genericWrappedWarehousePaperGrid > legacyWarehouseGrid, "expected generic section wrapped warehouse paper grid rule after legacy grid");
|
||||||
|
assert.ok(genericWrappedWarehouseMeta > legacyWarehouseGrid, "expected generic section wrapped warehouse meta strip rule after legacy grid");
|
||||||
|
assert.ok(genericDocumentGrid > 0, "expected generic document grid full-width rule in final contract");
|
||||||
|
assert.match(mainCss.slice(finalContract), /grid-column: 1 \/ -1 !important;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,18 +1,30 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
|
import DashboardView from "../views/DashboardView.vue";
|
||||||
|
import SalesPlanningView from "../views/SalesPlanningView.vue";
|
||||||
|
import EmployeeManagementView from "../views/EmployeeManagementView.vue";
|
||||||
|
import ProductManagementView from "../views/ProductManagementView.vue";
|
||||||
|
import BomManagementView from "../views/BomManagementView.vue";
|
||||||
|
import ProcessRouteManagementView from "../views/ProcessRouteManagementView.vue";
|
||||||
|
import LoginView from "../views/LoginView.vue";
|
||||||
|
import SupplierManagementView from "../views/SupplierManagementView.vue";
|
||||||
|
import PurchaseOrderView from "../views/PurchaseOrderView.vue";
|
||||||
|
import PurchaseReceiptView from "../views/PurchaseReceiptView.vue";
|
||||||
|
import InventoryLedgerView from "../views/InventoryLedgerView.vue";
|
||||||
|
import ProductionLedgerView from "../views/ProductionLedgerView.vue";
|
||||||
|
import OperationReportView from "../views/OperationReportView.vue";
|
||||||
|
import DeliveryManagementView from "../views/DeliveryManagementView.vue";
|
||||||
|
import EquipmentManagementView from "../views/EquipmentManagementView.vue";
|
||||||
|
import QualityInspectionView from "../views/QualityInspectionView.vue";
|
||||||
|
import FinanceReportView from "../views/FinanceReportView.vue";
|
||||||
|
import ProductSpecLiteView from "../views/ProductSpecLiteView.vue";
|
||||||
|
import SupplierMaterialLiteView from "../views/SupplierMaterialLiteView.vue";
|
||||||
|
import SystemPermissionView from "../views/SystemPermissionView.vue";
|
||||||
|
import SystemExtensionView from "../views/SystemExtensionView.vue";
|
||||||
|
import MasterDataImportView from "../views/MasterDataImportView.vue";
|
||||||
import { isAuthenticated, readSession } from "../services/api";
|
import { isAuthenticated, readSession } from "../services/api";
|
||||||
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
|
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
|
||||||
|
|
||||||
const viewModules = import.meta.glob("../views/*.vue");
|
|
||||||
|
|
||||||
function view(name) {
|
|
||||||
const loader = viewModules[`../views/${name}.vue`];
|
|
||||||
if (!loader) {
|
|
||||||
throw new Error(`Missing route view: ${name}`);
|
|
||||||
}
|
|
||||||
return loader;
|
|
||||||
}
|
|
||||||
|
|
||||||
const masterDataPermissionOrder = [
|
const masterDataPermissionOrder = [
|
||||||
{
|
{
|
||||||
routeName: "supplier-management",
|
routeName: "supplier-management",
|
||||||
@ -212,13 +224,13 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
name: "login",
|
name: "login",
|
||||||
component: view("LoginView"),
|
component: LoginView,
|
||||||
meta: { title: "工业风登录", layout: "auth" }
|
meta: { title: "工业风登录", layout: "auth" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
component: view("DashboardView"),
|
component: DashboardView,
|
||||||
meta: { title: "原材料批次生命周期追踪", requiresAuth: true, permission: "MENU_DASHBOARD" }
|
meta: { title: "原材料批次生命周期追踪", requiresAuth: true, permission: "MENU_DASHBOARD" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -231,13 +243,13 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/customer-management",
|
path: "/customer-management",
|
||||||
name: "customer-management",
|
name: "customer-management",
|
||||||
component: view("SalesPlanningView"),
|
component: SalesPlanningView,
|
||||||
meta: { title: "客户名录", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
meta: { title: "客户名录", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/order-list",
|
path: "/order-list",
|
||||||
name: "order-list",
|
name: "order-list",
|
||||||
component: view("SalesPlanningView"),
|
component: SalesPlanningView,
|
||||||
meta: { title: "销售订单列表", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
meta: { title: "销售订单列表", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -300,7 +312,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/supplier-material-lite",
|
path: "/supplier-material-lite",
|
||||||
name: "supplier-material-lite",
|
name: "supplier-material-lite",
|
||||||
component: view("SupplierMaterialLiteView"),
|
component: SupplierMaterialLiteView,
|
||||||
meta: {
|
meta: {
|
||||||
title: "原材料名录",
|
title: "原材料名录",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -310,7 +322,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/product-spec-lite",
|
path: "/product-spec-lite",
|
||||||
name: "product-spec-lite",
|
name: "product-spec-lite",
|
||||||
component: view("ProductSpecLiteView"),
|
component: ProductSpecLiteView,
|
||||||
meta: {
|
meta: {
|
||||||
title: "产品需规清单",
|
title: "产品需规清单",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -320,31 +332,31 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/supplier-management",
|
path: "/supplier-management",
|
||||||
name: "supplier-management",
|
name: "supplier-management",
|
||||||
component: view("SupplierManagementView"),
|
component: SupplierManagementView,
|
||||||
meta: { title: "供应商名录", requiresAuth: true, permission: "MENU_SUPPLIER_MANAGEMENT" }
|
meta: { title: "供应商名录", requiresAuth: true, permission: "MENU_SUPPLIER_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/purchase-orders",
|
path: "/purchase-orders",
|
||||||
name: "purchase-orders",
|
name: "purchase-orders",
|
||||||
component: view("PurchaseOrderView"),
|
component: PurchaseOrderView,
|
||||||
meta: { title: "采购订单", requiresAuth: true, permission: "MENU_PURCHASE_ORDER" }
|
meta: { title: "采购订单", requiresAuth: true, permission: "MENU_PURCHASE_ORDER" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/purchase-receipts",
|
path: "/purchase-receipts",
|
||||||
name: "purchase-receipts",
|
name: "purchase-receipts",
|
||||||
component: view("PurchaseReceiptView"),
|
component: PurchaseReceiptView,
|
||||||
meta: { title: "到货入库", requiresAuth: true, permission: "MENU_PURCHASE_RECEIPT" }
|
meta: { title: "到货入库", requiresAuth: true, permission: "MENU_PURCHASE_RECEIPT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/inventory-ledger",
|
path: "/inventory-ledger",
|
||||||
name: "inventory-ledger",
|
name: "inventory-ledger",
|
||||||
component: view("InventoryLedgerView"),
|
component: InventoryLedgerView,
|
||||||
meta: { title: "原材料库存台账与批次", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
|
meta: { title: "原材料库存台账与批次", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/production-ledger",
|
path: "/production-ledger",
|
||||||
name: "production-ledger",
|
name: "production-ledger",
|
||||||
component: view("ProductionLedgerView"),
|
component: ProductionLedgerView,
|
||||||
meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
|
meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -356,7 +368,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/operation-reports",
|
path: "/operation-reports",
|
||||||
name: "operation-reports",
|
name: "operation-reports",
|
||||||
component: view("OperationReportView"),
|
component: OperationReportView,
|
||||||
meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" }
|
meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -368,25 +380,25 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/delivery-management",
|
path: "/delivery-management",
|
||||||
name: "delivery-management",
|
name: "delivery-management",
|
||||||
component: view("DeliveryManagementView"),
|
component: DeliveryManagementView,
|
||||||
meta: { title: "发货台账", requiresAuth: true, permission: "MENU_DELIVERY_MANAGEMENT" }
|
meta: { title: "发货台账", requiresAuth: true, permission: "MENU_DELIVERY_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/equipment-management",
|
path: "/equipment-management",
|
||||||
name: "equipment-management",
|
name: "equipment-management",
|
||||||
component: view("EquipmentManagementView"),
|
component: EquipmentManagementView,
|
||||||
meta: { title: "设备管理", requiresAuth: true, permission: "MENU_EQUIPMENT_MANAGEMENT" }
|
meta: { title: "设备管理", requiresAuth: true, permission: "MENU_EQUIPMENT_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/quality-inspection",
|
path: "/quality-inspection",
|
||||||
name: "quality-inspection",
|
name: "quality-inspection",
|
||||||
component: view("QualityInspectionView"),
|
component: QualityInspectionView,
|
||||||
meta: { title: "质量检验", requiresAuth: true, permission: "MENU_QUALITY_INSPECTION" }
|
meta: { title: "质量检验", requiresAuth: true, permission: "MENU_QUALITY_INSPECTION" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/finance-reports",
|
path: "/finance-reports",
|
||||||
name: "finance-reports",
|
name: "finance-reports",
|
||||||
component: view("FinanceReportView"),
|
component: FinanceReportView,
|
||||||
meta: { title: "冲压产品材料成本核算", requiresAuth: true, permission: "MENU_FINANCE_REPORT" }
|
meta: { title: "冲压产品材料成本核算", requiresAuth: true, permission: "MENU_FINANCE_REPORT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -404,25 +416,25 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/bom-management",
|
path: "/bom-management",
|
||||||
name: "bom-management",
|
name: "bom-management",
|
||||||
component: view("BomManagementView"),
|
component: BomManagementView,
|
||||||
meta: { title: "BOM管理", requiresAuth: true, permission: "MENU_BOM_MANAGEMENT" }
|
meta: { title: "BOM管理", requiresAuth: true, permission: "MENU_BOM_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/process-route-management",
|
path: "/process-route-management",
|
||||||
name: "process-route-management",
|
name: "process-route-management",
|
||||||
component: view("ProcessRouteManagementView"),
|
component: ProcessRouteManagementView,
|
||||||
meta: { title: "工艺路线管理", requiresAuth: true, permission: "MENU_ROUTE_MANAGEMENT" }
|
meta: { title: "工艺路线管理", requiresAuth: true, permission: "MENU_ROUTE_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/employee-management",
|
path: "/employee-management",
|
||||||
name: "employee-management",
|
name: "employee-management",
|
||||||
component: view("EmployeeManagementView"),
|
component: EmployeeManagementView,
|
||||||
meta: { title: "员工管理", requiresAuth: true, permission: "MENU_EMPLOYEE_MANAGEMENT" }
|
meta: { title: "员工管理", requiresAuth: true, permission: "MENU_EMPLOYEE_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/master-data-import",
|
path: "/master-data-import",
|
||||||
name: "master-data-import",
|
name: "master-data-import",
|
||||||
component: view("MasterDataImportView"),
|
component: MasterDataImportView,
|
||||||
meta: {
|
meta: {
|
||||||
title: "基础资料导入导出",
|
title: "基础资料导入导出",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -432,7 +444,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/product-management",
|
path: "/product-management",
|
||||||
name: "product-management",
|
name: "product-management",
|
||||||
component: view("ProductManagementView"),
|
component: ProductManagementView,
|
||||||
meta: { title: "产品管理", requiresAuth: true, permission: "MENU_PRODUCT_MANAGEMENT" }
|
meta: { title: "产品管理", requiresAuth: true, permission: "MENU_PRODUCT_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -448,13 +460,13 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/system-permissions",
|
path: "/system-permissions",
|
||||||
name: "system-permissions",
|
name: "system-permissions",
|
||||||
component: view("SystemPermissionView"),
|
component: SystemPermissionView,
|
||||||
meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" }
|
meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/system-extension",
|
path: "/system-extension",
|
||||||
name: "system-extension",
|
name: "system-extension",
|
||||||
component: view("SystemExtensionView"),
|
component: SystemExtensionView,
|
||||||
meta: { title: "系统拓展", requiresAuth: true, permission: "MENU_SYSTEM_EXTENSION" }
|
meta: { title: "系统拓展", requiresAuth: true, permission: "MENU_SYSTEM_EXTENSION" }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
|
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "http://localhost:8000/api";
|
||||||
const SESSION_KEY = "forgeflow.erp.session";
|
const SESSION_KEY = "forgeflow.erp.session";
|
||||||
|
|
||||||
async function parseResponse(response) {
|
export async function parseResponse(response) {
|
||||||
const contentType = response.headers.get("content-type") || "";
|
const contentType = response.headers.get("content-type") || "";
|
||||||
const isJson = contentType.includes("application/json");
|
const isJson = contentType.includes("application/json");
|
||||||
const data = isJson ? await response.json() : await response.text();
|
const hasEmptyBody = response.status === 204 || response.status === 205;
|
||||||
|
const rawText = hasEmptyBody ? "" : await response.text();
|
||||||
|
const data = isJson && rawText.trim() ? JSON.parse(rawText) : rawText || null;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
|
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
|
||||||
@ -245,64 +247,10 @@ export function isAuthenticated() {
|
|||||||
return Boolean(readSession());
|
return Boolean(readSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
function pemToArrayBuffer(pem) {
|
|
||||||
const base64Text = pem
|
|
||||||
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
|
|
||||||
.replace(/-----END PUBLIC KEY-----/g, "")
|
|
||||||
.replace(/\s/g, "");
|
|
||||||
const binary = window.atob(base64Text);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let index = 0; index < binary.length; index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer) {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = "";
|
|
||||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
|
||||||
binary += String.fromCharCode(bytes[index]);
|
|
||||||
}
|
|
||||||
return window.btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptLoginPassword(password, publicKeyPem) {
|
|
||||||
if (!window.crypto?.subtle) {
|
|
||||||
throw new Error("当前访问方式不支持安全加密登录,请使用 HTTPS 或 localhost 地址访问系统");
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicKey = await window.crypto.subtle.importKey(
|
|
||||||
"spki",
|
|
||||||
pemToArrayBuffer(publicKeyPem),
|
|
||||||
{
|
|
||||||
name: "RSA-OAEP",
|
|
||||||
hash: "SHA-256"
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
["encrypt"]
|
|
||||||
);
|
|
||||||
const ciphertext = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: "RSA-OAEP" },
|
|
||||||
publicKey,
|
|
||||||
new TextEncoder().encode(password)
|
|
||||||
);
|
|
||||||
return arrayBufferToBase64(ciphertext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(username, password) {
|
export async function login(username, password) {
|
||||||
const loginKey = await requestJson("/auth/login-key", {
|
|
||||||
method: "GET",
|
|
||||||
skipAuthRedirect: true
|
|
||||||
});
|
|
||||||
const passwordCiphertext = await encryptLoginPassword(password, loginKey.public_key_pem);
|
|
||||||
return requestJson("/auth/login", {
|
return requestJson("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username, password }),
|
||||||
username,
|
|
||||||
password_ciphertext: passwordCiphertext,
|
|
||||||
login_key_id: loginKey.key_id
|
|
||||||
}),
|
|
||||||
skipAuthRedirect: true
|
skipAuthRedirect: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
39
frontend/src/services/api.test.js
Normal file
39
frontend/src/services/api.test.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import { parseResponse } from "./api.js";
|
||||||
|
|
||||||
|
describe("api parseResponse", () => {
|
||||||
|
it("treats no-content JSON responses as empty success instead of parsing an empty body", async () => {
|
||||||
|
const response = new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await parseResponse(response), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses normal JSON response bodies", async () => {
|
||||||
|
const response = new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await parseResponse(response), { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps backend error details when JSON error responses include detail", async () => {
|
||||||
|
const response = new Response(JSON.stringify({ detail: "组织节点不存在" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(() => parseResponse(response), /组织节点不存在/);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@ -96,6 +96,7 @@ function createSemanticIcon(iconName) {
|
|||||||
function setButtonContent(button, semantics) {
|
function setButtonContent(button, semantics) {
|
||||||
let icon = button.querySelector(".semantic-action-icon");
|
let icon = button.querySelector(".semantic-action-icon");
|
||||||
let label = button.querySelector(".semantic-action-label");
|
let label = button.querySelector(".semantic-action-label");
|
||||||
|
const resolvedLabel = button.dataset.semanticLabel || semantics.label;
|
||||||
|
|
||||||
if (button.dataset.semanticActionIcon !== semantics.icon || !icon || !label) {
|
if (button.dataset.semanticActionIcon !== semantics.icon || !icon || !label) {
|
||||||
button.textContent = "";
|
button.textContent = "";
|
||||||
@ -105,7 +106,7 @@ function setButtonContent(button, semantics) {
|
|||||||
button.append(icon, label);
|
button.append(icon, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
label.textContent = semantics.label;
|
label.textContent = resolvedLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSemanticButton(button) {
|
function clearSemanticButton(button) {
|
||||||
@ -148,9 +149,10 @@ function enhanceButton(button) {
|
|||||||
button.dataset.buttonTone = semantics.tone;
|
button.dataset.buttonTone = semantics.tone;
|
||||||
button.dataset.semanticActionIcon = semantics.icon;
|
button.dataset.semanticActionIcon = semantics.icon;
|
||||||
setButtonContent(button, semantics);
|
setButtonContent(button, semantics);
|
||||||
button.setAttribute("aria-label", semantics.label);
|
const resolvedLabel = button.dataset.semanticLabel || semantics.label;
|
||||||
|
button.setAttribute("aria-label", resolvedLabel);
|
||||||
if (!button.title || button.dataset.autoIconTitle === "true") {
|
if (!button.title || button.dataset.autoIconTitle === "true") {
|
||||||
button.title = semantics.label;
|
button.title = resolvedLabel;
|
||||||
button.dataset.autoIconTitle = "true";
|
button.dataset.autoIconTitle = "true";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,35 @@ export function formatDate(value) {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return String(value).slice(0, 10);
|
const parts = resolveDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(value) {
|
export function formatDateTime(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return String(value).replace("T", " ").slice(0, 16);
|
const parts = resolveDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const time = resolveTimeParts(value);
|
||||||
|
const dateText = `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
return time ? `${dateText} ${time.hour}:${time.minute}` : dateText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatChineseDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const parts = resolveDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${parts.year}年${parts.month}月${parts.day}日`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(value) {
|
export function formatAmount(value) {
|
||||||
@ -33,3 +54,64 @@ export function formatWeight(value) {
|
|||||||
export function emptyToNull(value) {
|
export function emptyToNull(value) {
|
||||||
return value === "" || value === undefined ? null : value;
|
return value === "" || value === undefined ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDateParts(value) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return datePartsFromDate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringValue = String(value).trim();
|
||||||
|
const dateMatch = stringValue.match(/^(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})/);
|
||||||
|
if (dateMatch) {
|
||||||
|
return {
|
||||||
|
year: dateMatch[1],
|
||||||
|
month: padDatePart(dateMatch[2]),
|
||||||
|
day: padDatePart(dateMatch[3])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(stringValue);
|
||||||
|
return datePartsFromDate(parsedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeParts(value) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return timePartsFromDate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringValue = String(value).trim();
|
||||||
|
const timeMatch = stringValue.match(/(?:T|\s)(\d{1,2}):(\d{1,2})/);
|
||||||
|
if (timeMatch) {
|
||||||
|
return {
|
||||||
|
hour: padDatePart(timeMatch[1]),
|
||||||
|
minute: padDatePart(timeMatch[2])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datePartsFromDate(date) {
|
||||||
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
year: String(date.getFullYear()),
|
||||||
|
month: padDatePart(date.getMonth() + 1),
|
||||||
|
day: padDatePart(date.getDate())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timePartsFromDate(date) {
|
||||||
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hour: padDatePart(date.getHours()),
|
||||||
|
minute: padDatePart(date.getMinutes())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function padDatePart(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|||||||
34
frontend/src/utils/formatters.test.js
Normal file
34
frontend/src/utils/formatters.test.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import { formatChineseDate, formatDate, formatDateTime } from "./formatters.js";
|
||||||
|
|
||||||
|
describe("formatDate", () => {
|
||||||
|
it("formats Date values without browser-native English text", () => {
|
||||||
|
assert.equal(formatDate(new Date(2026, 5, 13, 9, 30)), "2026-06-13");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDateTime", () => {
|
||||||
|
it("formats Date values as numeric date-time text", () => {
|
||||||
|
assert.equal(formatDateTime(new Date(2026, 5, 13, 9, 30)), "2026-06-13 09:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps date-only strings as date text instead of adding synthetic time", () => {
|
||||||
|
assert.equal(formatDateTime("2026-06-13"), "2026-06-13");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatChineseDate", () => {
|
||||||
|
it("formats Date values as Chinese year-month-day text", () => {
|
||||||
|
assert.equal(formatChineseDate(new Date(2026, 5, 13, 9, 30)), "2026年06月13日");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats ISO date strings as Chinese year-month-day text", () => {
|
||||||
|
assert.equal(formatChineseDate("2026-06-13T15:45:00"), "2026年06月13日");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps empty values aligned with other display formatters", () => {
|
||||||
|
assert.equal(formatChineseDate(""), "-");
|
||||||
|
});
|
||||||
|
});
|
||||||
76
frontend/src/utils/orgTreeEmployees.js
Normal file
76
frontend/src/utils/orgTreeEmployees.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
export function collectOrganizationEmployees(node) {
|
||||||
|
const employees = [];
|
||||||
|
const seenKeys = new Set();
|
||||||
|
|
||||||
|
collectFromNode(node, node, employees, seenKeys, []);
|
||||||
|
return employees;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countOrganizationEmployees(node) {
|
||||||
|
return collectOrganizationEmployees(node).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromNode(node, parentNode, employees, seenKeys, pathParts) {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmployeeNode(node)) {
|
||||||
|
appendEmployee(node, parentNode, employees, seenKeys, pathParts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPathParts = [...pathParts, nodeLabel(node)].filter(Boolean);
|
||||||
|
|
||||||
|
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
|
||||||
|
appendEmployee(employee, node, employees, seenKeys, currentPathParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.isArray(node.children) ? node.children : []) {
|
||||||
|
if (isOrganizationNode(child)) {
|
||||||
|
collectFromNode(child, child, employees, seenKeys, currentPathParts);
|
||||||
|
} else if (isEmployeeNode(child)) {
|
||||||
|
appendEmployee(child, node, employees, seenKeys, currentPathParts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEmployee(employee, parentNode, employees, seenKeys, pathParts) {
|
||||||
|
const key = employeeKey(employee);
|
||||||
|
if (!key || seenKeys.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationPathParts = Array.isArray(employee.organization_path_parts) && employee.organization_path_parts.length
|
||||||
|
? employee.organization_path_parts
|
||||||
|
: pathParts;
|
||||||
|
|
||||||
|
seenKeys.add(key);
|
||||||
|
employees.push({
|
||||||
|
...employee,
|
||||||
|
parent_node_id: employee.parent_node_id ?? nodeId(parentNode),
|
||||||
|
parent_node_label: employee.parent_node_label ?? nodeLabel(parentNode),
|
||||||
|
organization_path_parts: organizationPathParts,
|
||||||
|
organization_path: employee.organization_path || organizationPathParts.join(" / ")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOrganizationNode(node) {
|
||||||
|
return Boolean(node) && node.node_type !== "EMPLOYEE" && !node.employee_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmployeeNode(node) {
|
||||||
|
return Boolean(node) && (node.node_type === "EMPLOYEE" || Boolean(node.employee_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function employeeKey(employee) {
|
||||||
|
return String(employee?.employee_id ?? employee?.user_id ?? employee?.username ?? employee?.mobile ?? employee?.phone ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeId(node) {
|
||||||
|
return node?.node_id ?? node?.id ?? node?.value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeLabel(node) {
|
||||||
|
return node?.node_label || node?.label || node?.name || "";
|
||||||
|
}
|
||||||
71
frontend/src/utils/orgTreeEmployees.test.js
Normal file
71
frontend/src/utils/orgTreeEmployees.test.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import { collectOrganizationEmployees, countOrganizationEmployees } from "./orgTreeEmployees.js";
|
||||||
|
|
||||||
|
describe("organization tree employee aggregation", () => {
|
||||||
|
it("collects employees from the selected organization and all descendant organizations", () => {
|
||||||
|
const branch = {
|
||||||
|
node_id: 10,
|
||||||
|
node_type: "BRANCH",
|
||||||
|
node_label: "旭升车间",
|
||||||
|
employees: [
|
||||||
|
{ employee_id: 1, employee_name: "直属主管", mobile: "13000000001" }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node_id: 11,
|
||||||
|
node_type: "DEPARTMENT",
|
||||||
|
node_label: "旭升加工部",
|
||||||
|
employees: [
|
||||||
|
{ employee_id: 2, employee_name: "张三", mobile: "13000000002" },
|
||||||
|
{ employee_id: 3, employee_name: "李四", mobile: "13000000003" }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node_id: 12,
|
||||||
|
node_type: "GROUP",
|
||||||
|
node_label: "冲压小组",
|
||||||
|
employees: [
|
||||||
|
{ employee_id: 4, employee_name: "王五", mobile: "13000000004" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const employees = collectOrganizationEmployees(branch);
|
||||||
|
|
||||||
|
assert.deepEqual(employees.map((employee) => employee.employee_name), ["直属主管", "张三", "李四", "王五"]);
|
||||||
|
assert.equal(employees[1].parent_node_id, 11);
|
||||||
|
assert.equal(employees[1].parent_node_label, "旭升加工部");
|
||||||
|
assert.deepEqual(employees[1].organization_path_parts, ["旭升车间", "旭升加工部"]);
|
||||||
|
assert.equal(employees[1].organization_path, "旭升车间 / 旭升加工部");
|
||||||
|
assert.equal(employees[3].organization_path, "旭升车间 / 旭升加工部 / 冲压小组");
|
||||||
|
assert.equal(countOrganizationEmployees(branch), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates employees that appear in multiple descendant branches", () => {
|
||||||
|
const employee = { employee_id: 9, employee_name: "重复人员", mobile: "13000000009" };
|
||||||
|
const branch = {
|
||||||
|
node_id: 20,
|
||||||
|
node_type: "BRANCH",
|
||||||
|
node_label: "嘉恒车间",
|
||||||
|
employees: [employee],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node_id: 21,
|
||||||
|
node_type: "DEPARTMENT",
|
||||||
|
node_label: "嘉恒生产部",
|
||||||
|
employees: [employee]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const employees = collectOrganizationEmployees(branch);
|
||||||
|
|
||||||
|
assert.equal(employees.length, 1);
|
||||||
|
assert.equal(countOrganizationEmployees(branch), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/views/DashboardView.test.js
Normal file
63
frontend/src/views/DashboardView.test.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(join(__dirname, "DashboardView.vue"), "utf8");
|
||||||
|
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
|
||||||
|
|
||||||
|
describe("DashboardView material lot lifecycle DAG", () => {
|
||||||
|
it("uses closed-loop material batch copy and stage labels", () => {
|
||||||
|
assert.match(source, /材料库存批次闭环图/);
|
||||||
|
assert.match(source, /生产台账/);
|
||||||
|
assert.match(source, /库外闭环/);
|
||||||
|
assert.match(source, /入库结果/);
|
||||||
|
assert.doesNotMatch(source, /原材料生命周期 DAG/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("knows all production ledger lifecycle node types", () => {
|
||||||
|
[
|
||||||
|
"production_ledger",
|
||||||
|
"production_issue_txn",
|
||||||
|
"ledger_outside_balance",
|
||||||
|
"production_ledger_settlement",
|
||||||
|
"finished_inbound",
|
||||||
|
"surplus_inbound",
|
||||||
|
"scrap_inbound",
|
||||||
|
"ledger_writeoff",
|
||||||
|
"ledger_reopen",
|
||||||
|
"operation_report",
|
||||||
|
"delivery"
|
||||||
|
].forEach((nodeType) => {
|
||||||
|
assert.match(source, new RegExp(nodeType));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frames operation reports as evidence instead of lifecycle control nodes", () => {
|
||||||
|
assert.match(source, /报工证据/);
|
||||||
|
assert.match(source, /报工仅作证据/);
|
||||||
|
assert.doesNotMatch(source, /stage:\s*standardStageLabel\(node\)/);
|
||||||
|
assert.match(source, /stageBandLabel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses parent-aware row placement before business priority", () => {
|
||||||
|
assert.match(source, /buildParentByTarget/);
|
||||||
|
assert.match(source, /buildParentRank/);
|
||||||
|
assert.match(source, /parentRank/);
|
||||||
|
assert.match(source, /compareDagNode\(left,\s*right,\s*parentRank/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("styles the new lifecycle legend and node type treatments", () => {
|
||||||
|
[
|
||||||
|
".dag-node-ledger",
|
||||||
|
".dag-node-balance",
|
||||||
|
".dag-node-settlement",
|
||||||
|
".dag-node-evidence",
|
||||||
|
".dag-legend"
|
||||||
|
].forEach((selector) => {
|
||||||
|
assert.match(mainCss, new RegExp(selector.replace(".", "\\.")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -87,7 +87,7 @@
|
|||||||
<section class="lifecycle-modal">
|
<section class="lifecycle-modal">
|
||||||
<header class="lifecycle-modal-head">
|
<header class="lifecycle-modal-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">原材料生命周期 DAG</p>
|
<p class="eyebrow">材料库存批次闭环图</p>
|
||||||
<h3>{{ lifecycle?.lot?.lot_no || activeMaterialLot?.lot_no || "库存批次号流转" }}</h3>
|
<h3>{{ lifecycle?.lot?.lot_no || activeMaterialLot?.lot_no || "库存批次号流转" }}</h3>
|
||||||
<p>{{ lifecycle?.lot ? `${lifecycle.lot.material_code} · ${lifecycle.lot.material_name}` : activeMaterialLot?.material_name || "-" }}</p>
|
<p>{{ lifecycle?.lot ? `${lifecycle.lot.material_code} · ${lifecycle.lot.material_name}` : activeMaterialLot?.material_name || "-" }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -96,7 +96,7 @@
|
|||||||
<input v-model="clickHighlightEnabled" type="checkbox" @change="handleClickHighlightModeChange" />
|
<input v-model="clickHighlightEnabled" type="checkbox" @change="handleClickHighlightModeChange" />
|
||||||
<span>点击常亮</span>
|
<span>点击常亮</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="table-chip">拖动画布 · 点击节点看详情</span>
|
<span class="table-chip">拖动画布 · 点击节点看详情 · 报工仅作证据</span>
|
||||||
<button class="ghost-button" type="button" title="放大" @click="zoomCanvasBy(0.08)">放大</button>
|
<button class="ghost-button" type="button" title="放大" @click="zoomCanvasBy(0.08)">放大</button>
|
||||||
<button class="ghost-button" type="button" title="缩小" @click="zoomCanvasBy(-0.08)">缩小</button>
|
<button class="ghost-button" type="button" title="缩小" @click="zoomCanvasBy(-0.08)">缩小</button>
|
||||||
<button class="ghost-button" type="button" @click="resetCanvas">重置视图</button>
|
<button class="ghost-button" type="button" @click="resetCanvas">重置视图</button>
|
||||||
@ -104,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载库存批次号全链路数据...</div>
|
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载材料库存批次闭环图...</div>
|
||||||
|
|
||||||
<div v-else class="lifecycle-content">
|
<div v-else class="lifecycle-content">
|
||||||
<div
|
<div
|
||||||
@ -115,6 +115,12 @@
|
|||||||
@mouseup="stopPan"
|
@mouseup="stopPan"
|
||||||
@mouseleave="handleDagViewportMouseLeave"
|
@mouseleave="handleDagViewportMouseLeave"
|
||||||
>
|
>
|
||||||
|
<div class="dag-legend" @mousedown.stop>
|
||||||
|
<span><i class="dag-legend-ledger"></i>生产台账</span>
|
||||||
|
<span><i class="dag-legend-balance"></i>库外余额</span>
|
||||||
|
<span><i class="dag-legend-inbound"></i>入库结果</span>
|
||||||
|
<span><i class="dag-legend-evidence"></i>报工证据</span>
|
||||||
|
</div>
|
||||||
<div class="dag-transform" :style="canvasTransformStyle">
|
<div class="dag-transform" :style="canvasTransformStyle">
|
||||||
<div class="dag-canvas" :style="{ width: `${canvasSize.width}px`, height: `${canvasSize.height}px` }">
|
<div class="dag-canvas" :style="{ width: `${canvasSize.width}px`, height: `${canvasSize.height}px` }">
|
||||||
<div
|
<div
|
||||||
@ -228,6 +234,7 @@
|
|||||||
class="dag-node-card"
|
class="dag-node-card"
|
||||||
:class="[
|
:class="[
|
||||||
`dag-node-${node.tone}`,
|
`dag-node-${node.tone}`,
|
||||||
|
dagNodeTypeClass(node),
|
||||||
{
|
{
|
||||||
'dag-node-active': selectedNode?.id === node.id,
|
'dag-node-active': selectedNode?.id === node.id,
|
||||||
'dag-node-locked': persistentDagNodeIds.has(node.id)
|
'dag-node-locked': persistentDagNodeIds.has(node.id)
|
||||||
@ -242,7 +249,7 @@
|
|||||||
>
|
>
|
||||||
<span class="dag-node-stage">{{ node.stage }}</span>
|
<span class="dag-node-stage">{{ node.stage }}</span>
|
||||||
<strong>{{ node.title }}</strong>
|
<strong>{{ node.title }}</strong>
|
||||||
<small>{{ node.subtitle || formatDashboardNodeTypeLabel(node.type) }}</small>
|
<small>{{ dagNodeSubtitle(node) }}</small>
|
||||||
<div class="dag-node-footer">
|
<div class="dag-node-footer">
|
||||||
<span>{{ displayStatus(node.status) }}</span>
|
<span>{{ displayStatus(node.status) }}</span>
|
||||||
<span>{{ node.metrics?.[0]?.value || "" }}</span>
|
<span>{{ node.metrics?.[0]?.value || "" }}</span>
|
||||||
@ -305,8 +312,29 @@ const EDGE_GAP_PER_LINE = 68;
|
|||||||
const ROW_GAP = 168;
|
const ROW_GAP = 168;
|
||||||
const LEFT_OFFSET = 78;
|
const LEFT_OFFSET = 78;
|
||||||
const TOP_OFFSET = 104;
|
const TOP_OFFSET = 104;
|
||||||
const REPORT_ROW_GAP = 150;
|
const STAGE_LABELS = {
|
||||||
const BRANCH_GAP = 46;
|
0: "原料入库",
|
||||||
|
1: "生产台账",
|
||||||
|
2: "生产出库",
|
||||||
|
3: "库外闭环",
|
||||||
|
4: "入库结算",
|
||||||
|
5: "入库结果",
|
||||||
|
6: "销售去向"
|
||||||
|
};
|
||||||
|
const NODE_TYPE_CLASS = {
|
||||||
|
raw_material_lot: "dag-node-raw",
|
||||||
|
production_ledger: "dag-node-ledger",
|
||||||
|
production_issue_txn: "dag-node-issue",
|
||||||
|
ledger_outside_balance: "dag-node-balance",
|
||||||
|
production_ledger_settlement: "dag-node-settlement",
|
||||||
|
finished_inbound: "dag-node-inbound",
|
||||||
|
surplus_inbound: "dag-node-inbound",
|
||||||
|
scrap_inbound: "dag-node-inbound",
|
||||||
|
ledger_writeoff: "dag-node-writeoff",
|
||||||
|
ledger_reopen: "dag-node-reopen",
|
||||||
|
operation_report: "dag-node-evidence",
|
||||||
|
delivery: "dag-node-delivery"
|
||||||
|
};
|
||||||
|
|
||||||
const materialLots = ref([]);
|
const materialLots = ref([]);
|
||||||
const loadingMaterialLots = ref(false);
|
const loadingMaterialLots = ref(false);
|
||||||
@ -360,57 +388,10 @@ const layoutNodes = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const edges = lifecycle.value?.edges || [];
|
const edges = lifecycle.value?.edges || [];
|
||||||
const nodeById = new Map(nodes.map((node, index) => [node.id, { ...node, __index: index }]));
|
const parentByTarget = buildParentByTarget(nodes, edges);
|
||||||
const childrenBySource = new Map();
|
const parentRank = buildParentRank(nodes, edges, parentByTarget);
|
||||||
const parentByTarget = new Map();
|
|
||||||
edges.forEach((edge) => {
|
|
||||||
if (!childrenBySource.has(edge.source)) {
|
|
||||||
childrenBySource.set(edge.source, []);
|
|
||||||
}
|
|
||||||
childrenBySource.get(edge.source).push(edge.target);
|
|
||||||
if (!parentByTarget.has(edge.target)) {
|
|
||||||
parentByTarget.set(edge.target, edge.source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const levelX = buildAdaptiveLevelX(nodes, edges);
|
const levelX = buildAdaptiveLevelX(nodes, edges);
|
||||||
const yById = new Map();
|
const yById = new Map();
|
||||||
const operationNodes = nodes
|
|
||||||
.filter((node) => node.type === "work_order_operation")
|
|
||||||
.sort(compareDagNode);
|
|
||||||
const reportNodes = nodes
|
|
||||||
.filter((node) => node.type === "operation_report")
|
|
||||||
.sort(compareDagNode);
|
|
||||||
const reportGroups = new Map();
|
|
||||||
reportNodes.forEach((node) => {
|
|
||||||
const parentId = parentByTarget.get(node.id) || "__unlinked_reports";
|
|
||||||
if (!reportGroups.has(parentId)) {
|
|
||||||
reportGroups.set(parentId, []);
|
|
||||||
}
|
|
||||||
reportGroups.get(parentId).push(node);
|
|
||||||
});
|
|
||||||
|
|
||||||
let cursorY = TOP_OFFSET;
|
|
||||||
operationNodes.forEach((operation) => {
|
|
||||||
const reports = reportGroups.get(operation.id) || [];
|
|
||||||
const rowCount = Math.max(1, reports.length);
|
|
||||||
const groupHeight = Math.max(NODE_HEIGHT, rowCount * NODE_HEIGHT + (rowCount - 1) * (REPORT_ROW_GAP - NODE_HEIGHT));
|
|
||||||
yById.set(operation.id, cursorY + Math.max(0, (groupHeight - NODE_HEIGHT) / 2));
|
|
||||||
reports.forEach((report, index) => {
|
|
||||||
yById.set(report.id, cursorY + index * REPORT_ROW_GAP);
|
|
||||||
});
|
|
||||||
cursorY += groupHeight + BRANCH_GAP;
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlinkedReports = reportGroups.get("__unlinked_reports") || [];
|
|
||||||
unlinkedReports.forEach((report) => {
|
|
||||||
if (!yById.has(report.id)) {
|
|
||||||
yById.set(report.id, cursorY);
|
|
||||||
cursorY += REPORT_ROW_GAP;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const branchHeight = Math.max(NODE_HEIGHT, cursorY - TOP_OFFSET - BRANCH_GAP);
|
|
||||||
const levels = new Map();
|
const levels = new Map();
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
if (!levels.has(node.level)) {
|
if (!levels.has(node.level)) {
|
||||||
@ -420,20 +401,9 @@ const layoutNodes = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Array.from(levels.entries()).forEach(([level, rows]) => {
|
Array.from(levels.entries()).forEach(([level, rows]) => {
|
||||||
const sortedRows = rows.slice().sort(compareDagNode);
|
const sortedRows = rows.slice().sort((left, right) => compareDagNode(left, right, parentRank));
|
||||||
if (level <= 2) {
|
|
||||||
const distributed = distributeLevelRows(sortedRows, branchHeight);
|
|
||||||
distributed.forEach(({ node, y }) => yById.set(node.id, y));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sortedRows.forEach((node, index) => {
|
sortedRows.forEach((node, index) => {
|
||||||
if (yById.has(node.id)) {
|
yById.set(node.id, TOP_OFFSET + index * ROW_GAP);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parent = nodeById.get(parentByTarget.get(node.id));
|
|
||||||
const parentY = parent ? yById.get(parent.id) : null;
|
|
||||||
yById.set(node.id, Number.isFinite(parentY) ? parentY : TOP_OFFSET + index * ROW_GAP);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -567,7 +537,7 @@ const stageBands = computed(() => {
|
|||||||
if (!stages.has(node.level)) {
|
if (!stages.has(node.level)) {
|
||||||
stages.set(node.level, {
|
stages.set(node.level, {
|
||||||
level: node.level,
|
level: node.level,
|
||||||
label: node.stage,
|
label: stageBandLabel(node.level),
|
||||||
left: Math.max(0, node.x - 18),
|
left: Math.max(0, node.x - 18),
|
||||||
width: NODE_WIDTH + 36
|
width: NODE_WIDTH + 36
|
||||||
});
|
});
|
||||||
@ -841,10 +811,18 @@ function segmentKey(leftLevel, rightLevel) {
|
|||||||
return `${leftLevel}->${rightLevel}`;
|
return `${leftLevel}->${rightLevel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareDagNode(left, right) {
|
function compareDagNode(left, right, parentRank = new Map()) {
|
||||||
if (left.level !== right.level) {
|
if (left.level !== right.level) {
|
||||||
return left.level - right.level;
|
return left.level - right.level;
|
||||||
}
|
}
|
||||||
|
const parentDelta = String(parentRank.get(left.id) || "").localeCompare(String(parentRank.get(right.id) || ""), "zh-Hans-CN");
|
||||||
|
if (parentDelta) {
|
||||||
|
return parentDelta;
|
||||||
|
}
|
||||||
|
const priorityDelta = dagRowPriority(left) - dagRowPriority(right);
|
||||||
|
if (priorityDelta) {
|
||||||
|
return priorityDelta;
|
||||||
|
}
|
||||||
const leftSeq = Number(String(left.title || "").match(/OP(\d+)/i)?.[1] || 0);
|
const leftSeq = Number(String(left.title || "").match(/OP(\d+)/i)?.[1] || 0);
|
||||||
const rightSeq = Number(String(right.title || "").match(/OP(\d+)/i)?.[1] || 0);
|
const rightSeq = Number(String(right.title || "").match(/OP(\d+)/i)?.[1] || 0);
|
||||||
if (leftSeq || rightSeq) {
|
if (leftSeq || rightSeq) {
|
||||||
@ -853,19 +831,106 @@ function compareDagNode(left, right) {
|
|||||||
return String(left.title || left.id).localeCompare(String(right.title || right.id), "zh-Hans-CN");
|
return String(left.title || left.id).localeCompare(String(right.title || right.id), "zh-Hans-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
function distributeLevelRows(rows, branchHeight) {
|
function buildParentByTarget(nodes, edges) {
|
||||||
if (rows.length <= 1) {
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||||
return rows.map((node) => ({
|
const parentByTarget = new Map();
|
||||||
node,
|
edges.forEach((edge) => {
|
||||||
y: TOP_OFFSET + Math.max(0, (branchHeight - NODE_HEIGHT) / 2)
|
const source = nodeById.get(edge.source);
|
||||||
}));
|
const target = nodeById.get(edge.target);
|
||||||
|
if (!source || !target || parentByTarget.has(edge.target)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const usableHeight = Math.max(branchHeight - NODE_HEIGHT, (rows.length - 1) * ROW_GAP);
|
parentByTarget.set(edge.target, edge.source);
|
||||||
const gap = usableHeight / Math.max(1, rows.length - 1);
|
});
|
||||||
return rows.map((node, index) => ({
|
return parentByTarget;
|
||||||
node,
|
}
|
||||||
y: TOP_OFFSET + index * gap
|
|
||||||
}));
|
function buildParentRank(nodes, edges, parentByTarget) {
|
||||||
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||||
|
const childrenByParent = new Map();
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!childrenByParent.has(edge.source)) {
|
||||||
|
childrenByParent.set(edge.source, []);
|
||||||
|
}
|
||||||
|
childrenByParent.get(edge.source).push(edge.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootOrder = nodes
|
||||||
|
.filter((node) => !parentByTarget.has(node.id))
|
||||||
|
.slice()
|
||||||
|
.sort(compareDagNode)
|
||||||
|
.map((node) => node.id);
|
||||||
|
const rootIndexById = new Map(rootOrder.map((id, index) => [id, index]));
|
||||||
|
const siblingIndexById = new Map();
|
||||||
|
childrenByParent.forEach((childIds) => {
|
||||||
|
childIds
|
||||||
|
.slice()
|
||||||
|
.sort((leftId, rightId) => compareDagNode(nodeById.get(leftId), nodeById.get(rightId)))
|
||||||
|
.forEach((childId, index) => siblingIndexById.set(childId, index));
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankById = new Map();
|
||||||
|
const visiting = new Set();
|
||||||
|
const rankFor = (nodeId) => {
|
||||||
|
if (rankById.has(nodeId)) {
|
||||||
|
return rankById.get(nodeId);
|
||||||
|
}
|
||||||
|
if (visiting.has(nodeId)) {
|
||||||
|
return padRank(rootIndexById.get(nodeId) ?? 9999);
|
||||||
|
}
|
||||||
|
visiting.add(nodeId);
|
||||||
|
const parentId = parentByTarget.get(nodeId);
|
||||||
|
const rank = parentId && nodeById.has(parentId)
|
||||||
|
? `${rankFor(parentId)}.${padRank(siblingIndexById.get(nodeId) ?? 9999)}`
|
||||||
|
: padRank(rootIndexById.get(nodeId) ?? nodes.findIndex((node) => node.id === nodeId));
|
||||||
|
visiting.delete(nodeId);
|
||||||
|
rankById.set(nodeId, rank);
|
||||||
|
return rank;
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.forEach((node) => rankFor(node.id));
|
||||||
|
return rankById;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padRank(value) {
|
||||||
|
return String(Math.max(0, Number(value) || 0)).padStart(4, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dagRowPriority(node) {
|
||||||
|
if (["raw_material_lot", "production_ledger", "production_issue_txn", "production_ledger_settlement", "finished_inbound", "delivery"].includes(node.type)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (["ledger_outside_balance", "surplus_inbound"].includes(node.type)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (["operation_report", "scrap_inbound"].includes(node.type)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (node.type === "ledger_writeoff") {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (node.type === "ledger_reopen") {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageBandLabel(level) {
|
||||||
|
return STAGE_LABELS[Number(level)] || `阶段 ${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dagNodeTypeClass(node) {
|
||||||
|
return NODE_TYPE_CLASS[node.type] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dagNodeSubtitle(node) {
|
||||||
|
if (node.type === "operation_report") {
|
||||||
|
return node.subtitle ? `报工证据 · ${node.subtitle}` : "报工证据";
|
||||||
|
}
|
||||||
|
return node.subtitle || formatDashboardNodeTypeLabel(node.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLevelCollisions(nodes, yById) {
|
function resolveLevelCollisions(nodes, yById) {
|
||||||
|
|||||||
@ -14,6 +14,14 @@ function functionBody(name) {
|
|||||||
return match[0];
|
return match[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawerBlock(openBinding, nextMarker) {
|
||||||
|
const start = source.indexOf(`:open="${openBinding}"`);
|
||||||
|
assert.ok(start >= 0, `expected drawer ${openBinding} to exist`);
|
||||||
|
const end = source.indexOf(nextMarker, start + openBinding.length);
|
||||||
|
assert.ok(end > start, `expected drawer ${openBinding} to end before ${nextMarker}`);
|
||||||
|
return source.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
describe("InventoryLedgerView warehouse action layout", () => {
|
describe("InventoryLedgerView warehouse action layout", () => {
|
||||||
it("keeps stocktake inside the tool action group instead of a standalone rack", () => {
|
it("keeps stocktake inside the tool action group instead of a standalone rack", () => {
|
||||||
assert.equal(source.includes("warehouse-stocktake-rack"), false);
|
assert.equal(source.includes("warehouse-stocktake-rack"), false);
|
||||||
@ -65,6 +73,146 @@ describe("InventoryLedgerView warehouse action layout", () => {
|
|||||||
assert.match(source, /<WarehouseDocumentActionBar/);
|
assert.match(source, /<WarehouseDocumentActionBar/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes native dates to the paper document shell so it can render Chinese business dates", () => {
|
||||||
|
assert.doesNotMatch(source, /formatDateTime\(new Date\(\)\)/);
|
||||||
|
assert.match(source, /:business-date="new Date\(\)"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps production inbound preview fields in a full-width paper grid", () => {
|
||||||
|
assert.doesNotMatch(source, /summary-grid compact-summary-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-summary-grid/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-summary-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-summary-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps customer supplied inbound paper fields full width instead of squeezed into a side column", () => {
|
||||||
|
assert.match(source, /title="客料入库单"/);
|
||||||
|
assert.match(source, /class="warehouse-document-meta-strip"/);
|
||||||
|
assert.match(source, /class="warehouse-paper-grid"/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-operation-form\s*\{[\s\S]*width: 100%;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-document-meta-strip\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-field > span\s*\{[\s\S]*font-size: 13px;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-field input,[\s\S]*?\.warehouse-paper-field textarea\s*\{[\s\S]*font-size: 15px;/);
|
||||||
|
assert.match(mainCss, /@media \(max-width: 520px\)[\s\S]*\.document-paper-binder,[\s\S]*\.document-retained-mark\s*\{[\s\S]*display: none;/);
|
||||||
|
assert.match(mainCss, /@media \(max-width: 520px\)[\s\S]*\.document-paper-sheet\s*\{[\s\S]*padding: 18px 10px 22px;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\)\s*\{[\s\S]*width: min\(1280px, calc\(100vw - 24px\)\) !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.form-drawer-body\s*\{[\s\S]*overflow-x: hidden !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*font-size: clamp\(22px, 7\.2vw, 28px\) !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*white-space: normal !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*overflow-wrap: anywhere !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-number-stamp\s*\{[\s\S]*width: min\(176px, 100%\) !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.form-drawer-body\s*\{[\s\S]*padding: 10px !important;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps production-ledger controlled inbound forms in the shared paper control language", () => {
|
||||||
|
assert.match(source, /warehouse-paper-control-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(source, /warehouse-paper-inline-summary/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-toggle/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-remark/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-control-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-control-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-settle-toggle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps special adjustment documents inside the paper document language", () => {
|
||||||
|
const specialAdjustmentBlock = source.slice(
|
||||||
|
source.indexOf(':open="specialAdjustmentDrawerOpen"'),
|
||||||
|
source.indexOf(':open="productionDrawerOpen"')
|
||||||
|
);
|
||||||
|
assert.match(source, /special-adjustment-paper-grid/);
|
||||||
|
assert.match(source, /special-adjustment-paper-warning/);
|
||||||
|
assert.match(source, /special-adjustment-paper-lines/);
|
||||||
|
assert.match(source, /special-adjustment-paper-line-card/);
|
||||||
|
assert.doesNotMatch(specialAdjustmentBlock, /<div class="double-field">[\s\S]*?specialAdjustmentForm\.reason/);
|
||||||
|
assert.match(mainCss, /\.special-adjustment-paper-grid/);
|
||||||
|
assert.match(mainCss, /\.special-adjustment-paper-line-card/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps production outbound document controls paper-native", () => {
|
||||||
|
assert.match(source, /production-out-paper-grid/);
|
||||||
|
assert.match(source, /production-out-source-lot-panel/);
|
||||||
|
assert.match(source, /production-out-source-lot-table/);
|
||||||
|
assert.doesNotMatch(source, /<div class="workflow-tip">选择原材料库存批次出库/);
|
||||||
|
assert.match(mainCss, /\.production-out-source-lot-panel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps production ledger inbound settlement controls paper-native", () => {
|
||||||
|
const productionLedgerInboundBlock = source.slice(
|
||||||
|
source.indexOf(':open="productionWorkOrderInboundDrawerOpen"'),
|
||||||
|
source.indexOf("<StocktakeDialog")
|
||||||
|
);
|
||||||
|
assert.match(source, /production-ledger-inbound-paper-grid/);
|
||||||
|
assert.match(source, /production-ledger-inbound-summary/);
|
||||||
|
assert.match(source, /production-ledger-inbound-metrics/);
|
||||||
|
assert.doesNotMatch(productionLedgerInboundBlock, /<div class="triple-field">[\s\S]*?productionWorkOrderInboundForm\.finished_qty/);
|
||||||
|
assert.match(mainCss, /\.production-ledger-inbound-metrics/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps raw material return outbound documents paper-native", () => {
|
||||||
|
const block = drawerBlock("rawReturnDrawerOpen", ':open="returnInboundDrawerOpen"');
|
||||||
|
assert.match(block, /raw-return-paper-grid/);
|
||||||
|
assert.match(block, /raw-return-logistics-grid/);
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
assert.doesNotMatch(block, /modern-upload/);
|
||||||
|
assert.match(mainCss, /\.raw-return-paper-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps return inbound and return scrap inbound documents paper-native", () => {
|
||||||
|
const returnBlock = drawerBlock("returnInboundDrawerOpen", ':open="returnScrapInboundDrawerOpen"');
|
||||||
|
const scrapBlock = drawerBlock("returnScrapInboundDrawerOpen", ':open="openingImportDrawerOpen"');
|
||||||
|
[returnBlock, scrapBlock].forEach((block) => {
|
||||||
|
assert.match(block, /return-paper-grid/);
|
||||||
|
assert.match(block, /return-trace-summary/);
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
});
|
||||||
|
assert.match(mainCss, /\.return-paper-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps finished inbound and sales outbound documents paper-native", () => {
|
||||||
|
const completionBlock = drawerBlock("completionDrawerOpen", ':open="deliveryDrawerOpen"');
|
||||||
|
const deliveryBlock = drawerBlock("deliveryDrawerOpen", ':open="returnReworkDrawerOpen"');
|
||||||
|
assert.match(completionBlock, /completion-paper-grid/);
|
||||||
|
assert.match(completionBlock, /completion-settle-panel/);
|
||||||
|
assert.match(deliveryBlock, /sales-delivery-paper-grid/);
|
||||||
|
assert.match(deliveryBlock, /sales-delivery-allocation-table/);
|
||||||
|
[completionBlock, deliveryBlock].forEach((block) => {
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
assert.doesNotMatch(block, /modern-upload/);
|
||||||
|
});
|
||||||
|
assert.match(mainCss, /\.completion-paper-grid/);
|
||||||
|
assert.match(mainCss, /\.sales-delivery-paper-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps return rework and production ledger closeout tails paper-native", () => {
|
||||||
|
const reworkBlock = drawerBlock("returnReworkDrawerOpen", ':open="warehouseLedgerDrawerOpen"');
|
||||||
|
const productionBlock = drawerBlock("productionDrawerOpen", ':open="completionDrawerOpen"');
|
||||||
|
const ledgerInboundBlock = drawerBlock("productionWorkOrderInboundDrawerOpen", "<StocktakeDialog");
|
||||||
|
assert.match(reworkBlock, /return-rework-paper-grid/);
|
||||||
|
assert.match(reworkBlock, /return-rework-trace-summary/);
|
||||||
|
assert.match(productionBlock, /production-out-paper-remark/);
|
||||||
|
assert.match(ledgerInboundBlock, /production-ledger-inbound-settle-panel/);
|
||||||
|
assert.match(ledgerInboundBlock, /production-ledger-inbound-remark/);
|
||||||
|
[reworkBlock, productionBlock, ledgerInboundBlock].forEach((block) => {
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
});
|
||||||
|
assert.match(mainCss, /\.return-rework-paper-grid/);
|
||||||
|
assert.match(mainCss, /\.production-ledger-inbound-settle-panel/);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces generated PDF archive feedback after independent warehouse saves", () => {
|
it("surfaces generated PDF archive feedback after independent warehouse saves", () => {
|
||||||
[
|
[
|
||||||
["submitRawReturnOutbound", "退货出库"],
|
["submitRawReturnOutbound", "退货出库"],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -160,7 +160,6 @@ async function handleLogin() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await login(form.username, form.password);
|
const user = await login(form.username, form.password);
|
||||||
form.password = "";
|
|
||||||
saveSession(user);
|
saveSession(user);
|
||||||
message.value = `欢迎回来,${user.display_name}`;
|
message.value = `欢迎回来,${user.display_name}`;
|
||||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||||
|
|||||||
47
frontend/src/views/ProductSpecLiteView.test.js
Normal file
47
frontend/src/views/ProductSpecLiteView.test.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(join(__dirname, "ProductSpecLiteView.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("ProductSpecLiteView operation foundation loading", () => {
|
||||||
|
it("loads process and work-center master data sequentially so backend default seeding can settle", () => {
|
||||||
|
assert.match(source, /const processRows = await fetchResource\("\/master-data\/processes", \[\]\);/);
|
||||||
|
assert.match(source, /const centerRows = await fetchResource\("\/master-data\/work-centers", \[\]\);/);
|
||||||
|
assert.ok(
|
||||||
|
source.indexOf('fetchResource("/master-data/processes", [])') <
|
||||||
|
source.indexOf('fetchResource("/master-data/work-centers", [])')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepares route operations before creating a new product so validation cannot leave partial records", () => {
|
||||||
|
assert.match(source, /async function ensureOperationFoundationReady\(\)/);
|
||||||
|
assert.match(source, /await ensureOperationFoundationReady\(\);/);
|
||||||
|
assert.match(source, /const preparedRouteOperations = buildRouteOperationsPayload\(processRowsForSave\);/);
|
||||||
|
assert.ok(
|
||||||
|
source.indexOf("const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);") <
|
||||||
|
source.indexOf('await postResource("/master-data/products", productPayload)')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans newly created product-spec records if a later save step fails", () => {
|
||||||
|
assert.match(source, /let createdProductItemId = null;/);
|
||||||
|
assert.match(source, /const createdMiniappPayloads = \[\];/);
|
||||||
|
assert.match(source, /createdProductItemId = productItemId;/);
|
||||||
|
assert.match(source, /createdMiniappPayloads\.push\(payload\);/);
|
||||||
|
assert.match(source, /await deleteResource\(buildMiniappDeletePath\(payload\)\)\.catch\(\(\) => null\);/);
|
||||||
|
assert.match(source, /await deleteResource\(`\/master-data\/product-specs\/\$\{createdProductItemId\}`\)\.catch\(\(\) => null\);/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a one-screen table layout so the action and pagination areas do not get pushed into the edge", () => {
|
||||||
|
assert.match(source, /class="table-wrap product-spec-table-wrap"/);
|
||||||
|
assert.match(source, /class="data-table compact-table product-spec-table"/);
|
||||||
|
assert.match(source, /<colgroup>[\s\S]*product-spec-col-action[\s\S]*<\/colgroup>/);
|
||||||
|
assert.match(source, /\.product-spec-table-wrap\.table-view-wide,[\s\S]*?overflow-x:\s*hidden !important;/);
|
||||||
|
assert.match(source, /\.product-spec-table[\s\S]*?table-layout:\s*fixed !important;/);
|
||||||
|
assert.match(source, /\.product-spec-table\s+\.action-row[\s\S]*?width:\s*100% !important;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,8 +23,25 @@
|
|||||||
placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序"
|
placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap product-spec-table-wrap">
|
||||||
<table class="data-table compact-table">
|
<table class="data-table compact-table product-spec-table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="product-spec-col-code" />
|
||||||
|
<col class="product-spec-col-attendance" />
|
||||||
|
<col class="product-spec-col-project" />
|
||||||
|
<col class="product-spec-col-product" />
|
||||||
|
<col class="product-spec-col-version" />
|
||||||
|
<col class="product-spec-col-weight" />
|
||||||
|
<col class="product-spec-col-material" />
|
||||||
|
<col class="product-spec-col-gross" />
|
||||||
|
<col class="product-spec-col-rate" />
|
||||||
|
<col class="product-spec-col-rate" />
|
||||||
|
<col class="product-spec-col-price" />
|
||||||
|
<col class="product-spec-col-process" />
|
||||||
|
<col class="product-spec-col-beat" />
|
||||||
|
<col class="product-spec-col-status" />
|
||||||
|
<col class="product-spec-col-action" />
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>产品编码</th>
|
<th>产品编码</th>
|
||||||
@ -73,6 +90,7 @@
|
|||||||
<td class="action-row">
|
<td class="action-row">
|
||||||
<button
|
<button
|
||||||
class="ghost-button"
|
class="ghost-button"
|
||||||
|
data-semantic-label="编辑"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canModifySpec(row)"
|
:disabled="!canModifySpec(row)"
|
||||||
:title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'"
|
:title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'"
|
||||||
@ -82,6 +100,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ghost-button danger-ghost"
|
class="ghost-button danger-ghost"
|
||||||
|
data-semantic-label="删除"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canModifySpec(row)"
|
:disabled="!canModifySpec(row)"
|
||||||
:title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'"
|
:title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'"
|
||||||
@ -97,7 +116,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="filteredRows.length" />
|
<PaginationBar class="product-spec-pagination" v-model:page="page" v-model:page-size="pageSize" :total="filteredRows.length" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<FormDrawer
|
<FormDrawer
|
||||||
@ -856,19 +875,23 @@ function buildAutoRouteOperation(processRow, index) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRoutePayload(product, processRowsForSave) {
|
function buildRouteOperationsPayload(processRowsForSave) {
|
||||||
const productItemId = product.item_id;
|
|
||||||
const processRows = buildProcessGroupForRoute(processRowsForSave);
|
const processRows = buildProcessGroupForRoute(processRowsForSave);
|
||||||
if (!processRows.length) {
|
if (!processRows.length) {
|
||||||
throw new Error("请至少维护一道小程序工序");
|
throw new Error("请至少维护一道小程序工序");
|
||||||
}
|
}
|
||||||
|
return processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoutePayload(product, processRowsForSave, preparedOperations = null) {
|
||||||
|
const productItemId = product.item_id;
|
||||||
return {
|
return {
|
||||||
route_name: `${form.item_name} 工艺路线`,
|
route_name: `${form.item_name} 工艺路线`,
|
||||||
product_item_id: productItemId,
|
product_item_id: productItemId,
|
||||||
version_no: form.version_no,
|
version_no: form.version_no,
|
||||||
is_default: true,
|
is_default: true,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
operations: processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index))
|
operations: preparedOperations || buildRouteOperationsPayload(processRowsForSave)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -931,16 +954,16 @@ watch(calculatedLossRate, (lossRate) => {
|
|||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
async function fetchMasterData() {
|
async function fetchMasterData() {
|
||||||
const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows, processRows, centerRows] = await Promise.all([
|
const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows] = await Promise.all([
|
||||||
fetchResource("/master-data/products?limit=200", []),
|
fetchResource("/master-data/products?limit=200", []),
|
||||||
fetchResource("/master-data/boms?limit=500", []),
|
fetchResource("/master-data/boms?limit=500", []),
|
||||||
fetchResource("/master-data/process-routes?limit=500", []),
|
fetchResource("/master-data/process-routes?limit=500", []),
|
||||||
fetchResource("/master-data/materials?limit=200", []),
|
fetchResource("/master-data/materials?limit=200", []),
|
||||||
fetchResource("/master-data/miniapp-products?limit=1000", []),
|
fetchResource("/master-data/miniapp-products?limit=1000", []),
|
||||||
fetchResource("/master-data/miniapp-attendance-points", []),
|
fetchResource("/master-data/miniapp-attendance-points", [])
|
||||||
fetchResource("/master-data/processes", []),
|
|
||||||
fetchResource("/master-data/work-centers", [])
|
|
||||||
]);
|
]);
|
||||||
|
const processRows = await fetchResource("/master-data/processes", []);
|
||||||
|
const centerRows = await fetchResource("/master-data/work-centers", []);
|
||||||
products.value = productRows;
|
products.value = productRows;
|
||||||
boms.value = bomRows;
|
boms.value = bomRows;
|
||||||
routes.value = routeRows;
|
routes.value = routeRows;
|
||||||
@ -951,6 +974,18 @@ async function fetchMasterData() {
|
|||||||
workCenters.value = centerRows;
|
workCenters.value = centerRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureOperationFoundationReady() {
|
||||||
|
if (!processes.value.length) {
|
||||||
|
processes.value = await fetchResource("/master-data/processes", []);
|
||||||
|
}
|
||||||
|
if (!workCenters.value.length) {
|
||||||
|
workCenters.value = await fetchResource("/master-data/work-centers", []);
|
||||||
|
}
|
||||||
|
if (!processes.value.length || !workCenters.value.length) {
|
||||||
|
throw new Error("系统无法准备默认工艺基础资料,请稍后重试或联系管理员");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncMiniappInBackground() {
|
async function syncMiniappInBackground() {
|
||||||
if (miniappSyncing.value) {
|
if (miniappSyncing.value) {
|
||||||
return;
|
return;
|
||||||
@ -977,8 +1012,12 @@ async function submitSpec() {
|
|||||||
feedbackMessage.value = "";
|
feedbackMessage.value = "";
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
let createdProductItemId = null;
|
||||||
|
const createdMiniappPayloads = [];
|
||||||
try {
|
try {
|
||||||
const processRowsForSave = normalizeProcessFormRowsForSave();
|
const processRowsForSave = normalizeProcessFormRowsForSave();
|
||||||
|
await ensureOperationFoundationReady();
|
||||||
|
const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);
|
||||||
const productPayload = {
|
const productPayload = {
|
||||||
item_name: form.item_name,
|
item_name: form.item_name,
|
||||||
specification: editingRow.value?.specification || null,
|
specification: editingRow.value?.specification || null,
|
||||||
@ -997,6 +1036,9 @@ async function submitSpec() {
|
|||||||
? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload)
|
? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload)
|
||||||
: await postResource("/master-data/products", productPayload);
|
: await postResource("/master-data/products", productPayload);
|
||||||
const productItemId = product.item_id;
|
const productItemId = product.item_id;
|
||||||
|
if (!shouldUpdateErpProduct) {
|
||||||
|
createdProductItemId = productItemId;
|
||||||
|
}
|
||||||
const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId);
|
const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId);
|
||||||
if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) {
|
if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) {
|
||||||
await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId));
|
await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId));
|
||||||
@ -1004,19 +1046,27 @@ async function submitSpec() {
|
|||||||
await postResource("/master-data/boms", buildBomPayload(productItemId));
|
await postResource("/master-data/boms", buildBomPayload(productItemId));
|
||||||
}
|
}
|
||||||
if (canUpdateExistingSpec && editingRow.value.route?.route_id) {
|
if (canUpdateExistingSpec && editingRow.value.route?.route_id) {
|
||||||
await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave));
|
await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
|
||||||
} else {
|
} else {
|
||||||
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave));
|
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
|
||||||
}
|
}
|
||||||
const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave);
|
const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave);
|
||||||
for (const payload of miniappPayloads) {
|
for (const payload of miniappPayloads) {
|
||||||
await postResource("/master-data/miniapp-products?sync=false", payload);
|
await postResource("/master-data/miniapp-products?sync=false", payload);
|
||||||
|
createdMiniappPayloads.push(payload);
|
||||||
}
|
}
|
||||||
await postResource("/master-data/miniapp-materials/sync?force=true", {});
|
await postResource("/master-data/miniapp-materials/sync?force=true", {});
|
||||||
feedbackMessage.value = `产品需规 ${product.item_code} 已保存`;
|
feedbackMessage.value = `产品需规 ${product.item_code} 已保存`;
|
||||||
drawerOpen.value = false;
|
drawerOpen.value = false;
|
||||||
await loadAll();
|
await loadAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (createdProductItemId) {
|
||||||
|
for (const payload of createdMiniappPayloads) {
|
||||||
|
await deleteResource(buildMiniappDeletePath(payload)).catch(() => null);
|
||||||
|
}
|
||||||
|
await deleteResource(`/master-data/product-specs/${createdProductItemId}`).catch(() => null);
|
||||||
|
await fetchMasterData().catch(() => null);
|
||||||
|
}
|
||||||
errorMessage.value = error.message || "产品需规保存失败";
|
errorMessage.value = error.message || "产品需规保存失败";
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
@ -1038,6 +1088,177 @@ onActivated(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.product-spec-table-wrap,
|
||||||
|
.product-spec-table-wrap.table-view-wide,
|
||||||
|
.product-spec-table-wrap.table-view-overview {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table,
|
||||||
|
.product-spec-table.data-table-wide,
|
||||||
|
.product-spec-table.data-table-overview,
|
||||||
|
.product-spec-table-wrap.table-view-wide .product-spec-table,
|
||||||
|
.product-spec-table-wrap.table-view-overview .product-spec-table {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
table-layout: fixed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-code {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-attendance {
|
||||||
|
width: 5.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-project {
|
||||||
|
width: 8.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-product {
|
||||||
|
width: 10.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-version {
|
||||||
|
width: 4.2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-weight {
|
||||||
|
width: 4.6%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-material {
|
||||||
|
width: 11%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-gross {
|
||||||
|
width: 5.4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-rate {
|
||||||
|
width: 5.7%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-price {
|
||||||
|
width: 5.4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-process {
|
||||||
|
width: 6.2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-beat {
|
||||||
|
width: 5.2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-status {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-col-action {
|
||||||
|
width: 8.6%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table th,
|
||||||
|
.product-spec-table td,
|
||||||
|
.product-spec-table.data-table-wide th,
|
||||||
|
.product-spec-table.data-table-overview th,
|
||||||
|
.product-spec-table-wrap.table-view-wide .product-spec-table th,
|
||||||
|
.product-spec-table-wrap.table-view-overview .product-spec-table th {
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table th,
|
||||||
|
.product-spec-table th :deep(.sortable-th-button),
|
||||||
|
.product-spec-table th :deep(.sortable-th-label) {
|
||||||
|
overflow: visible !important;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
word-break: keep-all !important;
|
||||||
|
overflow-wrap: anywhere !important;
|
||||||
|
line-height: 1.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table th :deep(.sortable-th-button) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 16px !important;
|
||||||
|
column-gap: 4px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table th :deep(.sortable-th-label) {
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table td:not(.action-row),
|
||||||
|
.product-spec-table.data-table-wide td:not(.action-row),
|
||||||
|
.product-spec-table.data-table-overview td:not(.action-row) {
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table .action-row,
|
||||||
|
.product-spec-table.data-table-wide .action-row,
|
||||||
|
.product-spec-table.data-table-overview .action-row {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table-wrap.table-view-wide .product-spec-table:has(td.action-row) thead th:last-child,
|
||||||
|
.product-spec-table-wrap.table-view-overview .product-spec-table:has(td.action-row) thead th:last-child,
|
||||||
|
.product-spec-table.data-table-wide:has(td.action-row) thead th:last-child,
|
||||||
|
.product-spec-table.data-table-overview:has(td.action-row) thead th:last-child {
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table .action-row .ghost-button,
|
||||||
|
.product-spec-table .action-row :deep(.semantic-action-button) {
|
||||||
|
width: calc((100% - 6px) / 2) !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
padding: 0 6px !important;
|
||||||
|
gap: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table .action-row .ghost-button + .ghost-button,
|
||||||
|
.product-spec-table .action-row :deep(.semantic-action-button + .semantic-action-button) {
|
||||||
|
margin-left: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-table .action-row :deep(.semantic-action-label) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-pagination {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-spec-pagination :deep(.pagination-actions) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.process-field-list {
|
.process-field-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@ -13,15 +13,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!smartOperationReportEnabled"
|
|
||||||
class="production-compact-tip production-mode-tip"
|
|
||||||
title="当前未对接智能报工小程序,生产进度按成品入库数量和BOM毛重/净重反推;工序明细在此模式下隐藏。"
|
|
||||||
>
|
|
||||||
<strong>未对接智能报工小程序</strong>
|
|
||||||
<span>生产进度按成品入库数量和BOM毛重/净重反推,工序明细隐藏。</span>
|
|
||||||
<FieldHint text="当前未对接智能报工小程序,生产进度按成品入库数量和BOM毛重/净重反推;工序明细在此模式下隐藏。" />
|
|
||||||
</div>
|
|
||||||
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
||||||
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||||
|
|
||||||
@ -169,23 +160,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DrawerDataTable>
|
</DrawerDataTable>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="smartOperationReportEnabled"
|
|
||||||
class="production-compact-tip production-drawer-tip"
|
|
||||||
title="工序明细由智能报工小程序按材料库存批次号回传,并在生产台账中用于辅助判断生产进度。"
|
|
||||||
>
|
|
||||||
<strong>小程序工序明细</strong>
|
|
||||||
<span>按材料库存批次号回传,用于辅助判断生产进度。</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="production-compact-tip production-drawer-tip"
|
|
||||||
title="当前未对接智能报工小程序,工序明细隐藏;系统按ERP入库数据反推生产进度。"
|
|
||||||
>
|
|
||||||
<strong>工序明细隐藏</strong>
|
|
||||||
<span>当前未对接智能报工小程序,系统按ERP入库数据反推生产进度。</span>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -198,13 +172,11 @@ import { computed, onMounted, ref } from "vue";
|
|||||||
|
|
||||||
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
|
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
|
||||||
import DrawerDataTable from "../components/DrawerDataTable.vue";
|
import DrawerDataTable from "../components/DrawerDataTable.vue";
|
||||||
import FieldHint from "../components/FieldHint.vue";
|
|
||||||
import PaginationBar from "../components/PaginationBar.vue";
|
import PaginationBar from "../components/PaginationBar.vue";
|
||||||
import StatusBadge from "../components/StatusBadge.vue";
|
import StatusBadge from "../components/StatusBadge.vue";
|
||||||
import TableControls from "../components/TableControls.vue";
|
import TableControls from "../components/TableControls.vue";
|
||||||
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
||||||
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
|
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
|
||||||
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
|
|
||||||
import { formatDateTime, formatQty, formatWeight } from "../utils/formatters";
|
import { formatDateTime, formatQty, formatWeight } from "../utils/formatters";
|
||||||
import { usePagination } from "../utils/pagination";
|
import { usePagination } from "../utils/pagination";
|
||||||
import { useTableControls } from "../utils/tableControls";
|
import { useTableControls } from "../utils/tableControls";
|
||||||
@ -216,7 +188,6 @@ const expandedTxnRows = ref([]);
|
|||||||
const feedbackMessage = ref("");
|
const feedbackMessage = ref("");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const lockingLedger = ref(false);
|
const lockingLedger = ref(false);
|
||||||
const smartOperationReportEnabled = ref(true);
|
|
||||||
|
|
||||||
const activeLedger = computed(() => ledgers.value.find((item) => Number(item.production_ledger_id) === Number(selectedLedgerId.value)) || null);
|
const activeLedger = computed(() => ledgers.value.find((item) => Number(item.production_ledger_id) === Number(selectedLedgerId.value)) || null);
|
||||||
const activeLedgerTxns = computed(() => activeLedger.value?.txns || []);
|
const activeLedgerTxns = computed(() => activeLedger.value?.txns || []);
|
||||||
@ -358,12 +329,8 @@ async function regenerateDocumentArchive(typeKey, businessId) {
|
|||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
const [rows, config] = await Promise.all([
|
const rows = await fetchResource("/production/production-ledger?limit=500", []);
|
||||||
fetchResource("/production/production-ledger?limit=500", []),
|
|
||||||
loadSmartOperationReportConfig()
|
|
||||||
]);
|
|
||||||
ledgers.value = Array.isArray(rows) ? rows : [];
|
ledgers.value = Array.isArray(rows) ? rows : [];
|
||||||
smartOperationReportEnabled.value = config.enabled !== false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@ -1444,7 +1444,7 @@ function renderPurchaseContractHtml(contractOrders) {
|
|||||||
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
||||||
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
||||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
||||||
.section-title { margin: 22px 0 10px; padding: 6px 10px; border: 1px solid #bfdbfe; border-radius: 4px; background: #eff6ff; font-weight: 800; font-size: 16px; }
|
.section-title { margin: 22px 0 10px; padding-left: 10px; border-left: 4px solid #2563eb; font-weight: 800; font-size: 16px; }
|
||||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
||||||
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
||||||
th { background: #eaf2ff; text-align: center; }
|
th { background: #eaf2ff; text-align: center; }
|
||||||
@ -2281,7 +2281,7 @@ onUnmounted(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
box-shadow: inset -1px 0 0 var(--document-paper-grid, rgba(20, 14, 6, 0.88));
|
border-right: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -857,10 +857,13 @@ onMounted(async () => {
|
|||||||
.receipt-document-line-static {
|
.receipt-document-line-static {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
color: var(--document-paper-ink, #17120a);
|
color: var(--document-paper-ink, #17120a);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-document-line-static {
|
.receipt-document-line-static {
|
||||||
|
|||||||
50
frontend/src/views/ReturnManagementView.test.js
Normal file
50
frontend/src/views/ReturnManagementView.test.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(join(__dirname, "ReturnManagementView.vue"), "utf8");
|
||||||
|
|
||||||
|
function drawerBlock(openBinding, nextMarker) {
|
||||||
|
const start = source.indexOf(`:open="${openBinding}"`);
|
||||||
|
assert.ok(start >= 0, `expected drawer ${openBinding} to exist`);
|
||||||
|
const end = source.indexOf(nextMarker, start + openBinding.length);
|
||||||
|
assert.ok(end > start, `expected drawer ${openBinding} to end before ${nextMarker}`);
|
||||||
|
return source.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReturnManagementView formal document layout", () => {
|
||||||
|
it("renders return registration as a formal paper document instead of a stack form", () => {
|
||||||
|
const block = drawerBlock("returnDrawerOpen", ':open="dispositionDrawerOpen"');
|
||||||
|
|
||||||
|
assert.match(block, /<DocumentFormShell[\s\S]*title="退货登记单"/);
|
||||||
|
assert.match(block, /<DocumentSection index="01" title="退货来源信息"/);
|
||||||
|
assert.match(block, /<DocumentSection index="02" title="退货明细"/);
|
||||||
|
assert.match(block, /<DocumentGrid :fields="returnDocumentHeaderFields"/);
|
||||||
|
assert.match(block, /<DocumentLineTable title="退货明细"/);
|
||||||
|
assert.match(block, /<DocumentActionBar>/);
|
||||||
|
assert.doesNotMatch(block, /<form class="stack-form"/);
|
||||||
|
assert.doesNotMatch(block, /order-item-card/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders return disposition as a formal paper document instead of a stack form", () => {
|
||||||
|
const block = drawerBlock("dispositionDrawerOpen", ':open="dispositionRecordsDrawerOpen"');
|
||||||
|
|
||||||
|
assert.match(block, /<DocumentFormShell[\s\S]*title="退货处置单"/);
|
||||||
|
assert.match(block, /<DocumentSection index="01" title="处置信息"/);
|
||||||
|
assert.match(block, /<DocumentSection index="02" title="处置对象预览"/);
|
||||||
|
assert.match(block, /<DocumentGrid :fields="dispositionDocumentHeaderFields"/);
|
||||||
|
assert.match(block, /<DocumentLineTable title="退货行明细"/);
|
||||||
|
assert.match(block, /<DocumentActionBar>/);
|
||||||
|
assert.doesNotMatch(block, /<form class="stack-form"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the formal document field definitions explicit", () => {
|
||||||
|
assert.match(source, /const returnDocumentHeaderFields = computed/);
|
||||||
|
assert.match(source, /const returnDocumentLineColumns = \[/);
|
||||||
|
assert.match(source, /const dispositionDocumentHeaderFields = computed/);
|
||||||
|
assert.match(source, /const dispositionDocumentLineColumns = \[/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -73,120 +73,116 @@
|
|||||||
wide
|
wide
|
||||||
@close="returnDrawerOpen = false"
|
@close="returnDrawerOpen = false"
|
||||||
>
|
>
|
||||||
<form class="stack-form" @submit.prevent="submitReturnOrder">
|
<DocumentFormShell
|
||||||
<div class="double-field">
|
class="return-document-form"
|
||||||
<label class="form-field">
|
title="退货登记单"
|
||||||
<span>客户</span>
|
document-no="提交后自动生成"
|
||||||
|
meta-label="售后联单"
|
||||||
|
:business-date="new Date()"
|
||||||
|
:signature-labels="['售后登记', '仓库接收', '质量复核', '制单人']"
|
||||||
|
tone="green"
|
||||||
|
@submit="submitReturnOrder"
|
||||||
|
>
|
||||||
|
<DocumentSection index="01" title="退货来源信息">
|
||||||
|
<DocumentGrid :fields="returnDocumentHeaderFields" columns="3">
|
||||||
|
<template #customer>
|
||||||
<select v-model.number="orderForm.customer_id" required>
|
<select v-model.number="orderForm.customer_id" required>
|
||||||
<option disabled value="">请选择客户</option>
|
<option disabled value="">请选择客户</option>
|
||||||
<option v-for="customer in customers" :key="customer.id" :value="customer.id">
|
<option v-for="customer in customers" :key="customer.id" :value="customer.id">
|
||||||
{{ customer.customer_name }}
|
{{ customer.customer_name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</template>
|
||||||
<label class="form-field">
|
<template #sales_order>
|
||||||
<span>订单</span>
|
|
||||||
<select v-model.number="orderForm.sales_order_id">
|
<select v-model.number="orderForm.sales_order_id">
|
||||||
<option value="">不关联</option>
|
<option value="">不关联</option>
|
||||||
<option v-for="order in orders" :key="order.order_id" :value="order.order_id">
|
<option v-for="order in orders" :key="order.order_id" :value="order.order_id">
|
||||||
{{ order.order_no }}
|
{{ order.order_no }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</template>
|
||||||
</div>
|
<template #delivery>
|
||||||
|
|
||||||
<div class="double-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>发货单</span>
|
|
||||||
<select v-model.number="orderForm.delivery_id">
|
<select v-model.number="orderForm.delivery_id">
|
||||||
<option value="">不关联</option>
|
<option value="">不关联</option>
|
||||||
<option v-for="delivery in deliveries" :key="delivery.delivery_id" :value="delivery.delivery_id">
|
<option v-for="delivery in deliveries" :key="delivery.delivery_id" :value="delivery.delivery_id">
|
||||||
{{ delivery.delivery_no }} · {{ delivery.order_no }}
|
{{ delivery.delivery_no }} · {{ delivery.order_no }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</template>
|
||||||
<label class="form-field">
|
<template #handler>
|
||||||
<span>处理人</span>
|
|
||||||
<select v-model.number="orderForm.handler_employee_id">
|
<select v-model.number="orderForm.handler_employee_id">
|
||||||
<option value="">请选择</option>
|
<option value="">请选择</option>
|
||||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||||
{{ employeeOptionLabel(employee) }}
|
{{ employeeOptionLabel(employee) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</template>
|
||||||
</div>
|
<template #return_reason>
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>退货原因</span>
|
|
||||||
<textarea v-model.trim="orderForm.return_reason" rows="2"></textarea>
|
<textarea v-model.trim="orderForm.return_reason" rows="2"></textarea>
|
||||||
</label>
|
</template>
|
||||||
|
</DocumentGrid>
|
||||||
|
</DocumentSection>
|
||||||
|
|
||||||
<div class="subsection-head">
|
<DocumentSection index="02" title="退货明细">
|
||||||
<strong>退货明细</strong>
|
<DocumentLineTable title="退货明细" :columns="returnDocumentLineColumns">
|
||||||
|
<template #actions>
|
||||||
<button class="ghost-button" type="button" @click="addRow">增加一行</button>
|
<button class="ghost-button" type="button" @click="addRow">增加一行</button>
|
||||||
</div>
|
</template>
|
||||||
|
<tr v-for="(item, index) in orderForm.items" :key="index">
|
||||||
<div class="order-item-stack">
|
<td class="document-line-index">{{ index + 1 }}</td>
|
||||||
<article v-for="(item, index) in orderForm.items" :key="index" class="order-item-card">
|
<td>
|
||||||
<div class="subsection-head">
|
|
||||||
<strong>明细 {{ index + 1 }}</strong>
|
|
||||||
<button v-if="orderForm.items.length > 1" class="ghost-button danger-ghost" type="button" @click="removeRow(index)">
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>订单行</span>
|
|
||||||
<select v-model.number="item.sales_order_item_id" @change="applyOrderItem(index)">
|
<select v-model.number="item.sales_order_item_id" @change="applyOrderItem(index)">
|
||||||
<option value="">手工选产品</option>
|
<option value="">手工选产品</option>
|
||||||
<option v-for="orderItem in orderItems" :key="orderItem.sales_order_item_id" :value="orderItem.sales_order_item_id">
|
<option v-for="orderItem in orderItems" :key="orderItem.sales_order_item_id" :value="orderItem.sales_order_item_id">
|
||||||
{{ orderItem.order_no }} · {{ orderItem.product_name }}
|
{{ orderItem.order_no }} · {{ orderItem.product_name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</td>
|
||||||
|
<td>
|
||||||
<div class="double-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>产品</span>
|
|
||||||
<select v-model.number="item.product_item_id" required @change="recalculateReturnWeight(index)">
|
<select v-model.number="item.product_item_id" required @change="recalculateReturnWeight(index)">
|
||||||
<option disabled value="">请选择产品</option>
|
<option disabled value="">请选择产品</option>
|
||||||
<option v-for="product in products" :key="product.item_id" :value="product.item_id">
|
<option v-for="product in products" :key="product.item_id" :value="product.item_id">
|
||||||
{{ product.item_code }} · {{ product.item_name }}
|
{{ product.item_code }} · {{ product.item_name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</td>
|
||||||
<label class="form-field">
|
<td>
|
||||||
<span>来源批次</span>
|
|
||||||
<select v-model.number="item.source_lot_id">
|
<select v-model.number="item.source_lot_id">
|
||||||
<option value="">不指定</option>
|
<option value="">不指定</option>
|
||||||
<option v-for="lot in finishedLots" :key="lot.lot_id" :value="lot.lot_id">
|
<option v-for="lot in finishedLots" :key="lot.lot_id" :value="lot.lot_id">
|
||||||
{{ lot.lot_no }} · {{ lot.item_name }}
|
{{ lot.lot_no }} · {{ lot.item_name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</td>
|
||||||
</div>
|
<td>
|
||||||
|
|
||||||
<div class="triple-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>退货数量</span>
|
|
||||||
<input v-model.number="item.return_qty" type="number" min="0" step="1" @input="recalculateReturnWeight(index)" />
|
<input v-model.number="item.return_qty" type="number" min="0" step="1" @input="recalculateReturnWeight(index)" />
|
||||||
</label>
|
</td>
|
||||||
<label class="form-field">
|
<td>
|
||||||
<span>退货重量(kg)</span>
|
|
||||||
<input v-model.number="item.return_weight_kg" type="number" min="0" step="0.001" />
|
<input v-model.number="item.return_weight_kg" type="number" min="0" step="0.001" />
|
||||||
</label>
|
</td>
|
||||||
<label class="form-field">
|
<td>
|
||||||
<span>责任归属</span>
|
|
||||||
<input v-model.trim="item.responsibility_type" type="text" placeholder="内部 / 外部 / 客户" />
|
<input v-model.trim="item.responsibility_type" type="text" placeholder="内部 / 外部 / 客户" />
|
||||||
</label>
|
</td>
|
||||||
</div>
|
<td>
|
||||||
</article>
|
<button
|
||||||
</div>
|
v-if="orderForm.items.length > 1"
|
||||||
|
class="ghost-button danger-ghost table-action-button"
|
||||||
|
type="button"
|
||||||
|
@click="removeRow(index)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</DocumentLineTable>
|
||||||
|
</DocumentSection>
|
||||||
|
|
||||||
|
<DocumentActionBar>
|
||||||
<button class="primary-button" type="submit" :disabled="submittingOrder">
|
<button class="primary-button" type="submit" :disabled="submittingOrder">
|
||||||
{{ submittingOrder ? "提交中..." : "创建退货单" }}
|
{{ submittingOrder ? "提交中..." : "创建退货单" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</DocumentActionBar>
|
||||||
|
</DocumentFormShell>
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
|
|
||||||
<FormDrawer
|
<FormDrawer
|
||||||
@ -198,50 +194,70 @@
|
|||||||
wide
|
wide
|
||||||
@close="dispositionDrawerOpen = false"
|
@close="dispositionDrawerOpen = false"
|
||||||
>
|
>
|
||||||
<form class="stack-form" @submit.prevent="submitDisposition">
|
<DocumentFormShell
|
||||||
<label class="form-field">
|
class="return-document-form"
|
||||||
<span>待处置退货行</span>
|
title="退货处置单"
|
||||||
|
document-no="保存后自动生成"
|
||||||
|
meta-label="售后处置联单"
|
||||||
|
:business-date="new Date()"
|
||||||
|
:signature-labels="['售后确认', '仓库复核', '生产接收', '制单人']"
|
||||||
|
tone="green"
|
||||||
|
@submit="submitDisposition"
|
||||||
|
>
|
||||||
|
<DocumentSection index="01" title="处置信息">
|
||||||
|
<DocumentGrid :fields="dispositionDocumentHeaderFields" columns="3">
|
||||||
|
<template #return_item>
|
||||||
<select v-model.number="dispositionForm.return_item_id" required @change="applyDispositionItem">
|
<select v-model.number="dispositionForm.return_item_id" required @change="applyDispositionItem">
|
||||||
<option disabled value="">请选择退货行</option>
|
<option disabled value="">请选择退货行</option>
|
||||||
<option v-for="item in currentDispositionItems" :key="item.return_item_id" :value="item.return_item_id">
|
<option v-for="item in currentDispositionItems" :key="item.return_item_id" :value="item.return_item_id">
|
||||||
{{ item.return_no }} · {{ item.product_name }} · {{ formatQty(item.return_qty) }}
|
{{ item.return_no }} · {{ item.product_name }} · {{ formatQty(item.return_qty) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</template>
|
||||||
|
<template #disposition_type>
|
||||||
<label class="form-field">
|
|
||||||
<span>处置方式</span>
|
|
||||||
<select v-model="dispositionForm.disposition_type" @change="applyDispositionType">
|
<select v-model="dispositionForm.disposition_type" @change="applyDispositionType">
|
||||||
<option value="REWORK">返工</option>
|
<option value="REWORK">返工</option>
|
||||||
<option value="SCRAP">报废</option>
|
<option value="SCRAP">报废</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</template>
|
||||||
|
<template #extra_cost_amount>
|
||||||
<label v-if="dispositionForm.disposition_type === 'REWORK'" class="form-field">
|
|
||||||
<span>额外人工成本</span>
|
|
||||||
<input v-model.number="dispositionForm.extra_cost_amount" type="number" min="0" step="0.01" />
|
<input v-model.number="dispositionForm.extra_cost_amount" type="number" min="0" step="0.01" />
|
||||||
</label>
|
</template>
|
||||||
|
<template #scrap_weight_kg>
|
||||||
<div v-if="dispositionForm.disposition_type === 'SCRAP'" class="double-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>报废重量(kg)</span>
|
|
||||||
<input v-model.number="dispositionForm.scrap_weight_kg" type="number" min="0" step="0.001" />
|
<input v-model.number="dispositionForm.scrap_weight_kg" type="number" min="0" step="0.001" />
|
||||||
</label>
|
</template>
|
||||||
<label class="form-field">
|
<template #scrap_sale_amount>
|
||||||
<span>废料销售金额</span>
|
|
||||||
<input v-model.number="dispositionForm.scrap_sale_amount" type="number" min="0" step="0.01" />
|
<input v-model.number="dispositionForm.scrap_sale_amount" type="number" min="0" step="0.01" />
|
||||||
</label>
|
</template>
|
||||||
</div>
|
<template #remark>
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>备注</span>
|
|
||||||
<textarea v-model.trim="dispositionForm.remark" rows="2"></textarea>
|
<textarea v-model.trim="dispositionForm.remark" rows="2"></textarea>
|
||||||
</label>
|
</template>
|
||||||
|
</DocumentGrid>
|
||||||
|
</DocumentSection>
|
||||||
|
|
||||||
|
<DocumentSection index="02" title="处置对象预览">
|
||||||
|
<DocumentLineTable title="退货行明细" :columns="dispositionDocumentLineColumns">
|
||||||
|
<tr v-if="activeDispositionItem">
|
||||||
|
<td class="document-line-index">1</td>
|
||||||
|
<td>{{ activeDispositionItem.return_no || "-" }}</td>
|
||||||
|
<td>{{ activeDispositionItem.product_name || "-" }}</td>
|
||||||
|
<td>{{ activeDispositionItem.source_lot_no || "-" }}</td>
|
||||||
|
<td>{{ formatQty(activeDispositionItem.return_qty) }}</td>
|
||||||
|
<td>{{ formatWeight(activeDispositionItem.return_weight_kg) }}</td>
|
||||||
|
<td>{{ activeDispositionItem.responsibility_type || "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="7" class="empty-row">请选择待处置退货行</td>
|
||||||
|
</tr>
|
||||||
|
</DocumentLineTable>
|
||||||
|
</DocumentSection>
|
||||||
|
|
||||||
|
<DocumentActionBar>
|
||||||
<button class="primary-button" type="submit" :disabled="submittingDisposition">
|
<button class="primary-button" type="submit" :disabled="submittingDisposition">
|
||||||
{{ submittingDisposition ? "处理中..." : "提交处置" }}
|
{{ submittingDisposition ? "处理中..." : "提交处置" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</DocumentActionBar>
|
||||||
|
</DocumentFormShell>
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
|
|
||||||
<FormDrawer
|
<FormDrawer
|
||||||
@ -287,6 +303,11 @@
|
|||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import DrawerDataTable from "../components/DrawerDataTable.vue";
|
import DrawerDataTable from "../components/DrawerDataTable.vue";
|
||||||
|
import DocumentActionBar from "../components/documentForms/DocumentActionBar.vue";
|
||||||
|
import DocumentFormShell from "../components/documentForms/DocumentFormShell.vue";
|
||||||
|
import DocumentGrid from "../components/documentForms/DocumentGrid.vue";
|
||||||
|
import DocumentLineTable from "../components/documentForms/DocumentLineTable.vue";
|
||||||
|
import DocumentSection from "../components/documentForms/DocumentSection.vue";
|
||||||
import FormDrawer from "../components/FormDrawer.vue";
|
import FormDrawer from "../components/FormDrawer.vue";
|
||||||
import PaginationBar from "../components/PaginationBar.vue";
|
import PaginationBar from "../components/PaginationBar.vue";
|
||||||
import TableControls from "../components/TableControls.vue";
|
import TableControls from "../components/TableControls.vue";
|
||||||
@ -330,6 +351,7 @@ const currentDispositionItems = computed(() => {
|
|||||||
}
|
}
|
||||||
return pendingItems.value.filter((item) => item.return_order_id === Number(selectedReturnOrderId.value));
|
return pendingItems.value.filter((item) => item.return_order_id === Number(selectedReturnOrderId.value));
|
||||||
});
|
});
|
||||||
|
const activeDispositionItem = computed(() => selectedDispositionItem());
|
||||||
const visibleDispositions = computed(() => {
|
const visibleDispositions = computed(() => {
|
||||||
const order = activeReturnOrder.value;
|
const order = activeReturnOrder.value;
|
||||||
if (!order) {
|
if (!order) {
|
||||||
@ -385,6 +407,48 @@ const dispositionDrawerColumns = computed(() => [
|
|||||||
{ key: "rework_work_order_no", label: "返工工单", detailOnly: true },
|
{ key: "rework_work_order_no", label: "返工工单", detailOnly: true },
|
||||||
{ key: "scrap_sale_amount", label: "废料销售金额", detailOnly: true, format: (value) => `¥${formatAmount(value)}` }
|
{ key: "scrap_sale_amount", label: "废料销售金额", detailOnly: true, format: (value) => `¥${formatAmount(value)}` }
|
||||||
]);
|
]);
|
||||||
|
const returnDocumentHeaderFields = computed(() => [
|
||||||
|
{ key: "customer", label: "客户" },
|
||||||
|
{ key: "sales_order", label: "订单" },
|
||||||
|
{ key: "delivery", label: "发货单" },
|
||||||
|
{ key: "handler", label: "处理人" },
|
||||||
|
{ key: "return_reason", label: "退货原因", class: "document-grid-cell-wide" }
|
||||||
|
]);
|
||||||
|
const returnDocumentLineColumns = [
|
||||||
|
{ key: "index", label: "序号", width: "58px" },
|
||||||
|
{ key: "sales_order_item", label: "订单行", width: "22%" },
|
||||||
|
{ key: "product", label: "产品", width: "20%" },
|
||||||
|
{ key: "source_lot", label: "来源批次", width: "18%" },
|
||||||
|
{ key: "return_qty", label: "退货数量", width: "104px" },
|
||||||
|
{ key: "return_weight_kg", label: "退货重量(kg)", width: "126px" },
|
||||||
|
{ key: "responsibility_type", label: "责任归属", width: "132px" },
|
||||||
|
{ key: "actions", label: "操作", width: "92px" }
|
||||||
|
];
|
||||||
|
const dispositionDocumentHeaderFields = computed(() => {
|
||||||
|
const fields = [
|
||||||
|
{ key: "return_item", label: "待处置退货行", class: "document-grid-cell-wide" },
|
||||||
|
{ key: "disposition_type", label: "处置方式" }
|
||||||
|
];
|
||||||
|
if (dispositionForm.disposition_type === "SCRAP") {
|
||||||
|
fields.push(
|
||||||
|
{ key: "scrap_weight_kg", label: "报废重量(kg)" },
|
||||||
|
{ key: "scrap_sale_amount", label: "废料销售金额" }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fields.push({ key: "extra_cost_amount", label: "额外人工成本" });
|
||||||
|
}
|
||||||
|
fields.push({ key: "remark", label: "备注", class: "document-grid-cell-wide" });
|
||||||
|
return fields;
|
||||||
|
});
|
||||||
|
const dispositionDocumentLineColumns = [
|
||||||
|
{ key: "index", label: "序号", width: "58px" },
|
||||||
|
{ key: "return_no", label: "退货单号", width: "18%" },
|
||||||
|
{ key: "product", label: "产品", width: "24%" },
|
||||||
|
{ key: "source_lot", label: "来源批次", width: "20%" },
|
||||||
|
{ key: "return_qty", label: "退货数量", width: "104px" },
|
||||||
|
{ key: "return_weight_kg", label: "退货重量", width: "112px" },
|
||||||
|
{ key: "responsibility_type", label: "责任归属", width: "120px" }
|
||||||
|
];
|
||||||
const {
|
const {
|
||||||
page: returnOrderPage,
|
page: returnOrderPage,
|
||||||
pageSize: returnOrderPageSize,
|
pageSize: returnOrderPageSize,
|
||||||
|
|||||||
@ -932,7 +932,7 @@ function renderContractHtml(orders) {
|
|||||||
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
||||||
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
||||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
||||||
.section-title { margin: 22px 0 10px; padding: 6px 10px; border: 1px solid #bfdbfe; border-radius: 4px; background: #eff6ff; font-weight: 800; font-size: 16px; }
|
.section-title { margin: 22px 0 10px; padding-left: 10px; border-left: 4px solid #2563eb; font-weight: 800; font-size: 16px; }
|
||||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
||||||
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
||||||
th { background: #eaf2ff; text-align: center; }
|
th { background: #eaf2ff; text-align: center; }
|
||||||
|
|||||||
79
frontend/src/views/SystemExtensionView.test.js
Normal file
79
frontend/src/views/SystemExtensionView.test.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(join(__dirname, "SystemExtensionView.vue"), "utf8");
|
||||||
|
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
|
||||||
|
|
||||||
|
describe("SystemExtensionView workbench redesign", () => {
|
||||||
|
it("removes the old hero explanation card and admin-only copy", () => {
|
||||||
|
assert.doesNotMatch(source, /extension-hero/);
|
||||||
|
assert.doesNotMatch(source, /extension-status-card/);
|
||||||
|
assert.doesNotMatch(source, /广播通知与 AI 助手配置中心/);
|
||||||
|
assert.doesNotMatch(source, /ADMIN ONLY/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a compact system extension workbench shell", () => {
|
||||||
|
[
|
||||||
|
"system-extension-workbench",
|
||||||
|
"extension-status-strip",
|
||||||
|
"extension-config-shell",
|
||||||
|
"extension-nav-rail",
|
||||||
|
"extension-module-panel",
|
||||||
|
"extension-save-dock"
|
||||||
|
].forEach((className) => {
|
||||||
|
assert.match(source, new RegExp(className));
|
||||||
|
assert.match(mainCss, new RegExp(`\\.${className}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps auxiliary explanations out of the permanent workspace", () => {
|
||||||
|
assert.doesNotMatch(source, /<small>{{ section\.desc }}<\/small>/);
|
||||||
|
assert.doesNotMatch(source, /activeSectionMeta\.eyebrow/);
|
||||||
|
assert.doesNotMatch(source, /extension-impact-list/);
|
||||||
|
assert.match(source, /extension-detail-note/);
|
||||||
|
assert.match(source, /extension-preview-disclosure/);
|
||||||
|
assert.match(source, /extension-field-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens broadcast editing from a dialog instead of a permanent form block", () => {
|
||||||
|
assert.match(source, /showBroadcastDialog/);
|
||||||
|
assert.match(source, /openNewBroadcastDialog/);
|
||||||
|
assert.match(source, /extension-modal-backdrop/);
|
||||||
|
assert.match(source, /extension-broadcast-ledger/);
|
||||||
|
assert.match(source, /data-semantic-label="新增广播"/);
|
||||||
|
assert.match(source, /新增广播/);
|
||||||
|
assert.doesNotMatch(source, /<section v-if="activeSection === 'broadcast'" class="extension-broadcast-grid">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the four configuration modules as a navigation directory", () => {
|
||||||
|
["广播通知", "库存批次规则", "生产对接模式", "AI 助手配置"].forEach((label) => {
|
||||||
|
assert.match(source, new RegExp(label));
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(source, /activeSection/);
|
||||||
|
assert.match(source, /extensionSections/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fixed save dock with explicit save and reset actions", () => {
|
||||||
|
assert.match(source, /放弃修改/);
|
||||||
|
assert.match(source, /保存配置/);
|
||||||
|
assert.match(source, /saveActiveSection/);
|
||||||
|
assert.match(source, /resetActiveSection/);
|
||||||
|
assert.match(source, /activeSectionDirty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing system extension API endpoints unchanged", () => {
|
||||||
|
[
|
||||||
|
"/system-extension/broadcasts",
|
||||||
|
"/system-extension/assistant-config",
|
||||||
|
"/system-extension/raw-material-lot-prefix",
|
||||||
|
"/system-extension/smart-operation-report"
|
||||||
|
].forEach((endpoint) => {
|
||||||
|
assert.ok(source.includes(endpoint), `${endpoint} endpoint should remain in source`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,39 +1,233 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="page-grid">
|
<section class="system-extension-workbench">
|
||||||
<section class="panel panel-hero extension-hero">
|
<header class="extension-workbench-head">
|
||||||
<div class="hero-copy">
|
|
||||||
<p class="eyebrow">系统拓展</p>
|
|
||||||
<span
|
|
||||||
class="title-with-help"
|
|
||||||
title="该模块仅系统管理员可见,用于维护全局右上角喇叭广播,以及后续 AI 聊天机器人接入免费 LLM API 的配置。"
|
|
||||||
>
|
|
||||||
<h3>广播通知与 AI 助手配置中心</h3>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="extension-status-card">
|
|
||||||
<span class="panel-tag">ADMIN ONLY</span>
|
|
||||||
<strong>{{ assistantConfig.enabled ? "真实 LLM 已启用" : "当前为占位助手模式" }}</strong>
|
|
||||||
<p>{{ assistantConfig.model_name || "尚未配置模型名称" }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">广播内容管理</p>
|
<p class="eyebrow">系统管理</p>
|
||||||
<h3>右上角小喇叭横幅</h3>
|
<h2>系统拓展</h2>
|
||||||
</div>
|
|
||||||
<div class="workflow-inline-actions">
|
|
||||||
<span class="panel-tag">{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</span>
|
|
||||||
<button class="ghost-button" type="button" @click="resetBroadcastForm">新建</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="extension-status-strip" aria-label="系统拓展状态概览">
|
||||||
|
<article
|
||||||
|
v-for="card in statusCards"
|
||||||
|
:key="card.label"
|
||||||
|
class="extension-status-pill"
|
||||||
|
:class="`extension-status-${card.tone}`"
|
||||||
|
>
|
||||||
|
<span>{{ card.label }}</span>
|
||||||
|
<strong>{{ card.value }}</strong>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
|
||||||
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
|
||||||
|
|
||||||
<form class="stack-form compact-extension-form" @submit.prevent="saveBroadcast">
|
<section class="extension-config-shell">
|
||||||
<div class="double-field">
|
<nav class="extension-nav-rail" aria-label="系统拓展配置目录">
|
||||||
|
<button
|
||||||
|
v-for="section in extensionSections"
|
||||||
|
:key="section.key"
|
||||||
|
class="extension-nav-button"
|
||||||
|
:class="[{ active: activeSection === section.key }, `extension-nav-${section.accent}`]"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="activeSection === section.key"
|
||||||
|
@click="activeSection = section.key"
|
||||||
|
>
|
||||||
|
<span class="extension-nav-mark" aria-hidden="true"></span>
|
||||||
|
<strong>{{ section.title }}</strong>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="extension-module-panel">
|
||||||
|
<div class="extension-module-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ activeSectionMeta.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<span v-if="activeSectionDirty" class="panel-tag warning-tag">有未保存修改</span>
|
||||||
|
<span v-else class="panel-tag">已同步</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="activeSection === 'broadcast'" class="extension-broadcast-ledger">
|
||||||
|
<section class="extension-list-card extension-list-card-full">
|
||||||
|
<div class="form-section-title extension-list-title">
|
||||||
|
<div class="extension-title-stack">
|
||||||
|
<strong>广播记录</strong>
|
||||||
|
<span class="panel-tag">{{ filteredBroadcasts.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="primary-button extension-add-button"
|
||||||
|
type="button"
|
||||||
|
data-semantic-label="新增广播"
|
||||||
|
@click="openNewBroadcastDialog"
|
||||||
|
>
|
||||||
|
新增广播
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableControls
|
||||||
|
v-model:search="broadcastControls.search.value"
|
||||||
|
v-model:sort-key="broadcastControls.sortKey.value"
|
||||||
|
v-model:sort-direction="broadcastControls.sortDirection.value"
|
||||||
|
:sort-options="broadcastControls.sortOptions"
|
||||||
|
placeholder="搜索标题、内容、状态、类型"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="table-wrap extension-table-wrap">
|
||||||
|
<table class="data-table compact-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>考勤点</th>
|
||||||
|
<th>内容</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>开始</th>
|
||||||
|
<th>结束</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
|
||||||
|
<td>{{ item.title }}</td>
|
||||||
|
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
|
||||||
|
<td>{{ item.content }}</td>
|
||||||
|
<td><span class="status-chip" :class="`status-${String(item.tone || 'INFO').toLowerCase()}`">{{ formatToneLabel(item.tone) }}</span></td>
|
||||||
|
<td><span class="status-chip" :class="item.status === 'ACTIVE' ? 'status-active' : 'status-paused'">{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</span></td>
|
||||||
|
<td>{{ formatDateTime(item.starts_at) }}</td>
|
||||||
|
<td>{{ formatDateTime(item.ends_at) }}</td>
|
||||||
|
<td>{{ item.sort_no }}</td>
|
||||||
|
<td class="action-row">
|
||||||
|
<button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!filteredBroadcasts.length">
|
||||||
|
<td colspan="9" class="empty-row">暂无广播内容</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="activeSection === 'rawLotPrefix'"
|
||||||
|
id="extension-form-rawLotPrefix"
|
||||||
|
class="stack-form extension-form-card"
|
||||||
|
@submit.prevent="saveActiveSection"
|
||||||
|
>
|
||||||
|
<div class="extension-setting-block" title="序号达到 9999 后自然扩展为下一位数,不截断、不补新规则。">
|
||||||
|
<span class="setting-kicker">当前规则</span>
|
||||||
|
<strong>{{ rawLotPrefixForm.config_value || "YL" }}0001</strong>
|
||||||
|
<em>自动递增</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="extension-field-grid extension-field-grid-2">
|
||||||
|
<label class="form-field">
|
||||||
|
<span>前缀</span>
|
||||||
|
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>说明</span>
|
||||||
|
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="activeSection === 'productionMode'"
|
||||||
|
id="extension-form-productionMode"
|
||||||
|
class="stack-form extension-form-card"
|
||||||
|
@submit.prevent="saveActiveSection"
|
||||||
|
>
|
||||||
|
<label class="extension-mode-switch" :class="{ active: smartOperationReportForm.enabled }">
|
||||||
|
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
<strong>对接智能报工小程序</strong>
|
||||||
|
<small class="extension-inline-state">{{ smartOperationReportForm.enabled ? "当前:已对接" : "当前:未对接" }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<details class="extension-detail-note">
|
||||||
|
<summary>查看影响范围</summary>
|
||||||
|
<p>开启时显示工序报工和小程序证据;关闭时隐藏工序报工,生产按 ERP 入库闭环推进。</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>说明</span>
|
||||||
|
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="activeSection === 'assistant'"
|
||||||
|
id="extension-form-assistant"
|
||||||
|
class="stack-form extension-form-card"
|
||||||
|
@submit.prevent="saveActiveSection"
|
||||||
|
>
|
||||||
|
<div class="extension-field-grid extension-field-grid-3">
|
||||||
|
<label class="form-field">
|
||||||
|
<span>助手名称</span>
|
||||||
|
<input v-model.trim="assistantForm.assistant_name" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>供应商</span>
|
||||||
|
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如:OpenRouter / SiliconFlow" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>模型名称</span>
|
||||||
|
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如:deepseek-chat" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="extension-field-grid extension-field-grid-2">
|
||||||
|
<label class="form-field">
|
||||||
|
<span>API Base URL</span>
|
||||||
|
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="extension-field-grid extension-field-grid-2">
|
||||||
|
<label class="form-field">
|
||||||
|
<span>温度</span>
|
||||||
|
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
|
||||||
|
</label>
|
||||||
|
<label class="extension-mode-switch" :class="{ active: assistantForm.enabled }">
|
||||||
|
<input v-model="assistantForm.enabled" type="checkbox" />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
<strong>启用真实 LLM 调用</strong>
|
||||||
|
<small class="extension-inline-state">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>每次新建对话的系统提示词</span>
|
||||||
|
<textarea v-model.trim="assistantForm.system_prompt" rows="6"></textarea>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showBroadcastDialog" class="extension-modal-backdrop" @click.self="closeBroadcastDialog">
|
||||||
|
<section class="extension-modal-card" role="dialog" aria-modal="true" aria-labelledby="broadcast-dialog-title">
|
||||||
|
<header class="extension-modal-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">广播通知</p>
|
||||||
|
<h3 id="broadcast-dialog-title">{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button extension-modal-close" type="button" @click="closeBroadcastDialog">关闭</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="extension-form-broadcast" class="stack-form extension-dialog-form" @submit.prevent="saveBroadcast">
|
||||||
|
<div class="extension-field-grid extension-field-grid-2">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>广播标题 <i class="required-mark">*</i></span>
|
<span>广播标题 <i class="required-mark">*</i></span>
|
||||||
<input v-model.trim="broadcastForm.title" type="text" required />
|
<input v-model.trim="broadcastForm.title" type="text" required />
|
||||||
@ -56,7 +250,7 @@
|
|||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<span>通知考勤点 <i class="required-mark">*</i></span>
|
<span>通知考勤点 <i class="required-mark">*</i></span>
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid extension-checkbox-grid">
|
||||||
<label v-for="point in attendancePoints" :key="point.name" class="checkbox-field">
|
<label v-for="point in attendancePoints" :key="point.name" class="checkbox-field">
|
||||||
<input v-model="broadcastForm.attendance_point_names" type="checkbox" :value="point.name" />
|
<input v-model="broadcastForm.attendance_point_names" type="checkbox" :value="point.name" />
|
||||||
<span>{{ point.name }}</span>
|
<span>{{ point.name }}</span>
|
||||||
@ -64,7 +258,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="triple-field">
|
<div class="extension-field-grid extension-field-grid-3">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>开始时间</span>
|
<span>开始时间</span>
|
||||||
<input v-model="broadcastForm.starts_at" type="datetime-local" />
|
<input v-model="broadcastForm.starts_at" type="datetime-local" />
|
||||||
@ -79,7 +273,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="double-field">
|
<div class="extension-field-grid extension-broadcast-status-row">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
<select v-model="broadcastForm.status">
|
<select v-model="broadcastForm.status">
|
||||||
@ -87,9 +281,10 @@
|
|||||||
<option value="PAUSED">暂停</option>
|
<option value="PAUSED">暂停</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-field extension-preview-field">
|
<details class="extension-preview-disclosure">
|
||||||
<span>横幅预览</span>
|
<summary>查看横幅预览</summary>
|
||||||
<div class="broadcast-banner extension-preview-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
|
<div class="extension-banner-preview">
|
||||||
|
<div class="broadcast-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
|
||||||
<span class="broadcast-speaker" aria-hidden="true">
|
<span class="broadcast-speaker" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" role="img">
|
<svg viewBox="0 0 24 24" role="img">
|
||||||
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
|
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
|
||||||
@ -100,179 +295,31 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
|
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
|
||||||
<span>{{ broadcastForm.content || "广播内容会显示在系统右上角" }}</span>
|
<span>{{ broadcastForm.content || "广播内容" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="primary-button" type="submit">{{ editingBroadcastId ? "更新广播" : "保存广播" }}</button>
|
<footer class="extension-dialog-actions">
|
||||||
|
<button class="ghost-button" type="button" @click="closeBroadcastDialog">取消</button>
|
||||||
|
<button class="primary-button" type="submit">{{ editingBroadcastId ? "保存修改" : "保存广播" }}</button>
|
||||||
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<section class="panel">
|
<div v-if="activeSectionDirty" class="extension-save-dock">
|
||||||
<div class="panel-header">
|
<span>{{ activeSectionMeta.title }}有未保存修改</span>
|
||||||
<div>
|
<button class="ghost-button" type="button" @click="resetActiveSection">放弃修改</button>
|
||||||
<p class="eyebrow">仓库编号配置</p>
|
<button class="primary-button" type="submit" :form="activeSectionFormId">保存配置</button>
|
||||||
<h3>原材料库存批次号前缀</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="panel-tag">{{ rawLotPrefixConfig.config_value || "YL" }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="stack-form compact-extension-form" @submit.prevent="saveRawLotPrefixConfig">
|
|
||||||
<div class="double-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>前缀</span>
|
|
||||||
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>说明</span>
|
|
||||||
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-tip">
|
|
||||||
生成效果示例:{{ rawLotPrefixForm.config_value || "YL" }}0001;序号达到 9999 后会自然扩展为 {{ rawLotPrefixForm.config_value || "YL" }}10000。
|
|
||||||
</div>
|
|
||||||
<button class="primary-button" type="submit">保存前缀</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">生产模式配置</p>
|
|
||||||
<h3>对接智能报工小程序</h3>
|
|
||||||
</div>
|
|
||||||
<span class="panel-tag">{{ smartOperationReportForm.enabled ? "已对接" : "未对接" }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="stack-form compact-extension-form" @submit.prevent="saveSmartOperationReportConfig">
|
|
||||||
<label class="checkbox-field extension-switch-field">
|
|
||||||
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
|
|
||||||
<span>对接智能报工小程序</span>
|
|
||||||
</label>
|
|
||||||
<div class="workflow-tip">
|
|
||||||
{{ smartOperationReportForm.enabled ? "开启后显示工序报工,并按小程序报工数据辅助计算生产进度。" : "关闭后隐藏工序报工,生产进度按ERP入库数据和BOM反推。" }}
|
|
||||||
</div>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>说明</span>
|
|
||||||
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
|
|
||||||
</label>
|
|
||||||
<button class="primary-button" type="submit">保存生产模式</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">广播列表</p>
|
|
||||||
<h3>当前系统广播记录</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TableControls
|
|
||||||
v-model:search="broadcastControls.search.value"
|
|
||||||
v-model:sort-key="broadcastControls.sortKey.value"
|
|
||||||
v-model:sort-direction="broadcastControls.sortDirection.value"
|
|
||||||
:sort-options="broadcastControls.sortOptions"
|
|
||||||
placeholder="搜索标题、内容、状态、类型"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table compact-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>标题</th>
|
|
||||||
<th>考勤点</th>
|
|
||||||
<th>内容</th>
|
|
||||||
<th>类型</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>开始</th>
|
|
||||||
<th>结束</th>
|
|
||||||
<th>排序</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
|
|
||||||
<td>{{ item.title }}</td>
|
|
||||||
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
|
|
||||||
<td>{{ item.content }}</td>
|
|
||||||
<td>{{ formatToneLabel(item.tone) }}</td>
|
|
||||||
<td>{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</td>
|
|
||||||
<td>{{ formatDateTime(item.starts_at) }}</td>
|
|
||||||
<td>{{ formatDateTime(item.ends_at) }}</td>
|
|
||||||
<td>{{ item.sort_no }}</td>
|
|
||||||
<td class="action-row"><button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="!filteredBroadcasts.length">
|
|
||||||
<td colspan="9" class="empty-row">暂无广播内容</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">AI 机器人配置</p>
|
|
||||||
<h3>免费 LLM API 与新对话系统提示词</h3>
|
|
||||||
</div>
|
|
||||||
<span class="panel-tag">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="stack-form" @submit.prevent="saveAssistantConfig">
|
|
||||||
<div class="triple-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>助手名称</span>
|
|
||||||
<input v-model.trim="assistantForm.assistant_name" type="text" required />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>供应商</span>
|
|
||||||
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如:OpenRouter / SiliconFlow" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>模型名称</span>
|
|
||||||
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如:deepseek-chat" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="double-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>API Base URL</span>
|
|
||||||
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>API Key</span>
|
|
||||||
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="double-field">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>温度</span>
|
|
||||||
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
|
|
||||||
</label>
|
|
||||||
<label class="form-field checkbox-field">
|
|
||||||
<input v-model="assistantForm.enabled" type="checkbox" />
|
|
||||||
<span>启用真实 LLM 调用</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>每次新建对话的系统提示词</span>
|
|
||||||
<textarea v-model.trim="assistantForm.system_prompt" rows="5"></textarea>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button class="primary-button" type="submit">保存 AI 配置</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import PaginationBar from "../components/PaginationBar.vue";
|
import PaginationBar from "../components/PaginationBar.vue";
|
||||||
import TableControls from "../components/TableControls.vue";
|
import TableControls from "../components/TableControls.vue";
|
||||||
@ -291,6 +338,46 @@ const smartOperationReportConfig = ref({});
|
|||||||
const feedbackMessage = ref("");
|
const feedbackMessage = ref("");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const editingBroadcastId = ref(null);
|
const editingBroadcastId = ref(null);
|
||||||
|
const showBroadcastDialog = ref(false);
|
||||||
|
const activeSection = ref("broadcast");
|
||||||
|
const extensionLoaded = ref(false);
|
||||||
|
const sectionSnapshots = reactive({
|
||||||
|
broadcast: "",
|
||||||
|
rawLotPrefix: "",
|
||||||
|
productionMode: "",
|
||||||
|
assistant: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const extensionSections = [
|
||||||
|
{
|
||||||
|
key: "broadcast",
|
||||||
|
eyebrow: "Broadcast",
|
||||||
|
title: "广播通知",
|
||||||
|
desc: "维护右上角小喇叭通知",
|
||||||
|
accent: "blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rawLotPrefix",
|
||||||
|
eyebrow: "Lot Rule",
|
||||||
|
title: "库存批次规则",
|
||||||
|
desc: "维护原材料库存批次号前缀",
|
||||||
|
accent: "steel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "productionMode",
|
||||||
|
eyebrow: "Production",
|
||||||
|
title: "生产对接模式",
|
||||||
|
desc: "控制是否对接智能报工小程序",
|
||||||
|
accent: "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
eyebrow: "AI Assistant",
|
||||||
|
title: "AI 助手配置",
|
||||||
|
desc: "维护 LLM 供应商、模型与提示词",
|
||||||
|
accent: "orange"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const broadcastForm = reactive(buildEmptyBroadcastForm());
|
const broadcastForm = reactive(buildEmptyBroadcastForm());
|
||||||
const assistantForm = reactive(buildEmptyAssistantForm());
|
const assistantForm = reactive(buildEmptyAssistantForm());
|
||||||
@ -364,6 +451,17 @@ function resetBroadcastForm() {
|
|||||||
editingBroadcastId.value = null;
|
editingBroadcastId.value = null;
|
||||||
Object.assign(broadcastForm, buildEmptyBroadcastForm());
|
Object.assign(broadcastForm, buildEmptyBroadcastForm());
|
||||||
broadcastForm.attendance_point_names = attendancePoints.value.map((point) => point.name);
|
broadcastForm.attendance_point_names = attendancePoints.value.map((point) => point.name);
|
||||||
|
captureSectionSnapshot("broadcast");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewBroadcastDialog() {
|
||||||
|
resetBroadcastForm();
|
||||||
|
showBroadcastDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBroadcastDialog() {
|
||||||
|
showBroadcastDialog.value = false;
|
||||||
|
resetBroadcastForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editBroadcast(item) {
|
function editBroadcast(item) {
|
||||||
@ -380,12 +478,110 @@ function editBroadcast(item) {
|
|||||||
ends_at: toDateTimeInput(item.ends_at),
|
ends_at: toDateTimeInput(item.ends_at),
|
||||||
sort_no: Number(item.sort_no || 0)
|
sort_no: Number(item.sort_no || 0)
|
||||||
});
|
});
|
||||||
|
captureSectionSnapshot("broadcast");
|
||||||
|
showBroadcastDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAttendancePoints(pointNames) {
|
function formatAttendancePoints(pointNames) {
|
||||||
return pointNames?.length ? pointNames.join(" / ") : "-";
|
return pointNames?.length ? pointNames.join(" / ") : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clonePlain(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeValue(value) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSectionState(sectionKey) {
|
||||||
|
if (sectionKey === "broadcast") {
|
||||||
|
return {
|
||||||
|
editingBroadcastId: editingBroadcastId.value,
|
||||||
|
...clonePlain(broadcastForm)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "rawLotPrefix") {
|
||||||
|
return clonePlain(rawLotPrefixForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "productionMode") {
|
||||||
|
return clonePlain(smartOperationReportForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "assistant") {
|
||||||
|
return {
|
||||||
|
...clonePlain(assistantForm),
|
||||||
|
api_key: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSectionSnapshot(sectionKey) {
|
||||||
|
sectionSnapshots[sectionKey] = serializeValue(getSectionState(sectionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureAllSectionSnapshots() {
|
||||||
|
extensionSections.forEach((section) => {
|
||||||
|
captureSectionSnapshot(section.key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSectionSnapshot(sectionKey) {
|
||||||
|
const snapshot = sectionSnapshots[sectionKey] ? JSON.parse(sectionSnapshots[sectionKey]) : null;
|
||||||
|
if (!snapshot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "broadcast") {
|
||||||
|
editingBroadcastId.value = snapshot.editingBroadcastId ?? null;
|
||||||
|
Object.assign(broadcastForm, {
|
||||||
|
title: snapshot.title || "",
|
||||||
|
content: snapshot.content || "",
|
||||||
|
attendance_point_names: Array.isArray(snapshot.attendance_point_names)
|
||||||
|
? [...snapshot.attendance_point_names]
|
||||||
|
: attendancePoints.value.map((point) => point.name),
|
||||||
|
tone: snapshot.tone || "INFO",
|
||||||
|
status: snapshot.status || "ACTIVE",
|
||||||
|
starts_at: snapshot.starts_at || "",
|
||||||
|
ends_at: snapshot.ends_at || "",
|
||||||
|
sort_no: Number(snapshot.sort_no ?? 10)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "rawLotPrefix") {
|
||||||
|
rawLotPrefixForm.config_value = snapshot.config_value || "YL";
|
||||||
|
rawLotPrefixForm.remark = snapshot.remark || "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "productionMode") {
|
||||||
|
smartOperationReportForm.enabled = snapshot.enabled !== false;
|
||||||
|
smartOperationReportForm.remark = snapshot.remark || "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionKey === "assistant") {
|
||||||
|
Object.assign(assistantForm, {
|
||||||
|
assistant_name: snapshot.assistant_name || "嘉恒工艺助手",
|
||||||
|
provider_name: snapshot.provider_name || "",
|
||||||
|
api_base_url: snapshot.api_base_url || "",
|
||||||
|
api_key: "",
|
||||||
|
model_name: snapshot.model_name || "",
|
||||||
|
temperature: Number(snapshot.temperature ?? 0.3),
|
||||||
|
system_prompt: snapshot.system_prompt || buildEmptyAssistantForm().system_prompt,
|
||||||
|
enabled: Boolean(snapshot.enabled)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function applyAssistantConfig(config) {
|
function applyAssistantConfig(config) {
|
||||||
assistantConfig.value = config || {};
|
assistantConfig.value = config || {};
|
||||||
Object.assign(assistantForm, {
|
Object.assign(assistantForm, {
|
||||||
@ -412,6 +608,62 @@ function applySmartOperationReportConfig(config) {
|
|||||||
smartOperationReportForm.remark = config?.remark || "";
|
smartOperationReportForm.remark = config?.remark || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSectionMeta = computed(() => {
|
||||||
|
return extensionSections.find((section) => section.key === activeSection.value) || extensionSections[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSectionFormId = computed(() => `extension-form-${activeSection.value}`);
|
||||||
|
|
||||||
|
const activeBroadcastCount = computed(() => {
|
||||||
|
return broadcasts.value.filter((item) => item.status === "ACTIVE").length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantStateLabel = computed(() => {
|
||||||
|
if (assistantConfig.value.enabled && assistantConfig.value.api_key_configured) {
|
||||||
|
return "已启用";
|
||||||
|
}
|
||||||
|
if (assistantConfig.value.api_key_configured) {
|
||||||
|
return "密钥已配置";
|
||||||
|
}
|
||||||
|
return "占位模式";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCards = computed(() => [
|
||||||
|
{
|
||||||
|
label: "广播启用",
|
||||||
|
value: `${activeBroadcastCount.value} 条`,
|
||||||
|
tone: activeBroadcastCount.value ? "success" : "muted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "库存批次前缀",
|
||||||
|
value: rawLotPrefixConfig.value.config_value || rawLotPrefixForm.config_value || "YL",
|
||||||
|
tone: "info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "报工小程序",
|
||||||
|
value: smartOperationReportForm.enabled ? "已对接" : "未对接",
|
||||||
|
tone: smartOperationReportForm.enabled ? "success" : "warning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "AI 助手",
|
||||||
|
value: assistantStateLabel.value,
|
||||||
|
tone: assistantConfig.value.enabled ? "success" : "muted"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeSectionDirty = computed(() => {
|
||||||
|
if (!extensionLoaded.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (activeSection.value === "broadcast") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (activeSection.value === "assistant" && assistantForm.api_key.trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return sectionSnapshots[activeSection.value] !== serializeValue(getSectionState(activeSection.value));
|
||||||
|
});
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const [pointRows, broadcastRows, config, rawLotPrefix, smartOperationReport] = await Promise.all([
|
const [pointRows, broadcastRows, config, rawLotPrefix, smartOperationReport] = await Promise.all([
|
||||||
fetchResource("/master-data/miniapp-attendance-points", []),
|
fetchResource("/master-data/miniapp-attendance-points", []),
|
||||||
@ -451,10 +703,18 @@ async function saveBroadcast() {
|
|||||||
? await putResource(`/system-extension/broadcasts/${editingBroadcastId.value}`, payload)
|
? await putResource(`/system-extension/broadcasts/${editingBroadcastId.value}`, payload)
|
||||||
: await postResource("/system-extension/broadcasts", payload);
|
: await postResource("/system-extension/broadcasts", payload);
|
||||||
feedbackMessage.value = `广播「${result.title}」已保存`;
|
feedbackMessage.value = `广播「${result.title}」已保存`;
|
||||||
|
showBroadcastDialog.value = false;
|
||||||
resetBroadcastForm();
|
resetBroadcastForm();
|
||||||
|
try {
|
||||||
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
|
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
|
||||||
|
} catch (refreshError) {
|
||||||
|
errorMessage.value = `广播已保存,但列表刷新失败:${refreshError.message || "请稍后刷新页面"}`;
|
||||||
|
}
|
||||||
|
captureSectionSnapshot("broadcast");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error.message || "广播保存失败";
|
errorMessage.value = error.message || "广播保存失败";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,8 +735,11 @@ async function saveAssistantConfig() {
|
|||||||
const result = await putResource("/system-extension/assistant-config", payload);
|
const result = await putResource("/system-extension/assistant-config", payload);
|
||||||
applyAssistantConfig(result);
|
applyAssistantConfig(result);
|
||||||
feedbackMessage.value = "AI 机器人配置已保存";
|
feedbackMessage.value = "AI 机器人配置已保存";
|
||||||
|
captureSectionSnapshot("assistant");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error.message || "AI 配置保存失败";
|
errorMessage.value = error.message || "AI 配置保存失败";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,8 +753,11 @@ async function saveRawLotPrefixConfig() {
|
|||||||
});
|
});
|
||||||
applyRawLotPrefixConfig(result);
|
applyRawLotPrefixConfig(result);
|
||||||
feedbackMessage.value = "原材料库存批次号前缀已保存";
|
feedbackMessage.value = "原材料库存批次号前缀已保存";
|
||||||
|
captureSectionSnapshot("rawLotPrefix");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error.message || "原材料库存批次号前缀保存失败";
|
errorMessage.value = error.message || "原材料库存批次号前缀保存失败";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -506,12 +772,46 @@ async function saveSmartOperationReportConfig() {
|
|||||||
applySmartOperationReportConfig(result);
|
applySmartOperationReportConfig(result);
|
||||||
resetSmartOperationReportConfigCache(result);
|
resetSmartOperationReportConfigCache(result);
|
||||||
feedbackMessage.value = "生产模式配置已保存";
|
feedbackMessage.value = "生产模式配置已保存";
|
||||||
|
captureSectionSnapshot("productionMode");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error.message || "生产模式配置保存失败";
|
errorMessage.value = error.message || "生产模式配置保存失败";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveActiveSection() {
|
||||||
|
if (activeSection.value === "broadcast") {
|
||||||
|
return saveBroadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === "rawLotPrefix") {
|
||||||
|
return saveRawLotPrefixConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === "productionMode") {
|
||||||
|
return saveSmartOperationReportConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === "assistant") {
|
||||||
|
return saveAssistantConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetActiveSection() {
|
||||||
|
restoreSectionSnapshot(activeSection.value);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || "系统拓展配置加载失败";
|
||||||
|
} finally {
|
||||||
|
captureAllSectionSnapshots();
|
||||||
|
extensionLoaded.value = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -46,3 +46,55 @@ describe("SystemPermissionView maintenance scrolling", () => {
|
|||||||
assert.match(mainStyles, /\.system-permission-page\.system-permission-page--maintenance\s+\.system-permission-maintenance-card\s*\{[\s\S]*?overflow:\s*visible;/);
|
assert.match(mainStyles, /\.system-permission-page\.system-permission-page--maintenance\s+\.system-permission-maintenance-card\s*\{[\s\S]*?overflow:\s*visible;/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SystemPermissionView organization employee table columns", () => {
|
||||||
|
it("keeps the organization employee table aligned after adding the organization path column", () => {
|
||||||
|
assert.match(mainStyles, /\.tree-layout-card\s+\.org-employee-table th:nth-child\(7\),[\s\S]*?td:nth-child\(7\)\s*\{[\s\S]*?width:\s*19%\s*!important;/);
|
||||||
|
assert.match(mainStyles, /\.tree-layout-card\s+\.org-employee-table \.action-row\s*\{[\s\S]*?display:\s*table-cell\s*!important;/);
|
||||||
|
assert.match(mainStyles, /\.tree-layout-card\s+\.org-employee-table \.employee-actions\s*\{[\s\S]*?display:\s*inline-flex\s*!important;[\s\S]*?min-width:\s*max-content\s*!important;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SystemPermissionView authorize drawer", () => {
|
||||||
|
it("uses card-style role selection instead of a native multi-select", () => {
|
||||||
|
const authorizeStart = source.indexOf('v-if="showAuthorizeDrawer"');
|
||||||
|
const authorizeEnd = source.indexOf('v-if="showRoleDrawer"', authorizeStart);
|
||||||
|
const authorizeSource = source.slice(authorizeStart, authorizeEnd);
|
||||||
|
|
||||||
|
assert.doesNotMatch(authorizeSource, /<select\s+v-model="authorizeForm\.role_ids"\s+multiple>/);
|
||||||
|
assert.match(authorizeSource, /class="authorize-role-grid"/);
|
||||||
|
assert.match(authorizeSource, /v-for="role in roles"/);
|
||||||
|
assert.match(authorizeSource, /@click="toggleAuthorizeRole\(role\.role_id\)"/);
|
||||||
|
assert.match(authorizeSource, /isAuthorizeRoleSelected\(role\.role_id\)/);
|
||||||
|
assert.match(authorizeSource, /selectedAuthorizeRoleNames/);
|
||||||
|
assert.match(authorizeSource, /roleRiskText\(role\)/);
|
||||||
|
assert.match(authorizeSource, /:disabled="!authorizeForm\.role_ids\.length"\s+@click="submitAuthorizeUser"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SystemPermissionView organization view action parity", () => {
|
||||||
|
it("routes both mind map and tree node actions through the same context menu action pipeline", () => {
|
||||||
|
assert.match(source, /<OrgMindMap[\s\S]*?@node-contextmenu="openContextMenu"/);
|
||||||
|
assert.match(source, /<OrgPermissionTree[\s\S]*?@node-contextmenu="openContextMenu"/);
|
||||||
|
assert.match(source, /const contextMenuActions = computed\(\(\) => \(contextMenu\.node \? nodeActions\(contextMenu\.node\) : \[\]\)\);/);
|
||||||
|
assert.match(source, /function runNodeAction\(actionKey, node\)/);
|
||||||
|
assert.match(source, /openOrgRenameDrawer\(node\)/);
|
||||||
|
assert.match(source, /openOrgManagerDrawer\(node\)/);
|
||||||
|
assert.match(source, /openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
|
||||||
|
assert.match(source, /openOrgEmployeeDrawer\(node\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows company nodes to create both branches and direct departments", () => {
|
||||||
|
assert.match(source, /node\.node_type === "COMPANY"[\s\S]*?actions\.push\(\{ key: "addBranch", label: "新增分公司节点" \}\);[\s\S]*?actions\.push\(\{ key: "addDepartment", label: "新增直属部门" \}\);/);
|
||||||
|
assert.match(source, /actionKey === "addDepartment"[\s\S]*?openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the shared context menu inside the current viewport", () => {
|
||||||
|
assert.match(source, /const menuWidth = 220;/);
|
||||||
|
assert.match(source, /const menuHeight = 280;/);
|
||||||
|
assert.match(source, /const viewportWidth = window\.innerWidth \|\| 1280;/);
|
||||||
|
assert.match(source, /const viewportHeight = window\.innerHeight \|\| 720;/);
|
||||||
|
assert.match(source, /contextMenu\.x = Math\.max\(12,\s*Math\.min\(Number\(eventPayload\.x \|\| 12\),\s*viewportWidth - menuWidth - 12\)\);/);
|
||||||
|
assert.match(source, /contextMenu\.y = Math\.max\(12,\s*Math\.min\(Number\(eventPayload\.y \|\| 12\),\s*viewportHeight - menuHeight - 12\)\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -156,42 +156,87 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showAuthorizeDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showAuthorizeDrawer = false">
|
<div v-if="showAuthorizeDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showAuthorizeDrawer = false">
|
||||||
<aside class="form-drawer system-permission-drawer">
|
<aside class="form-drawer system-permission-drawer authorize-drawer">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header authorize-drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">人员授权</p>
|
<p class="eyebrow">人员授权</p>
|
||||||
<h2>{{ authorizingEmployee?.employee_name || "人员授权" }}</h2>
|
<h2>{{ authorizingEmployee?.employee_name || "人员授权" }}</h2>
|
||||||
|
<p class="authorize-header-note">为该人员创建或更新系统账号,并分配可访问的系统角色。</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">关闭</button>
|
<button class="ghost-button authorize-close-button" type="button" @click="showAuthorizeDrawer = false">关闭窗口</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="authorize-summary">
|
<section class="authorize-profile-card" aria-label="授权对象">
|
||||||
|
<div class="authorize-avatar">{{ authorizingEmployeeInitial }}</div>
|
||||||
|
<div class="authorize-profile-main">
|
||||||
|
<strong>{{ authorizingEmployee?.employee_name || "未命名人员" }}</strong>
|
||||||
|
<span>{{ authorizingOrganizationPath }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="authorize-profile-meta">
|
||||||
<span>电话 / 账号:{{ authorizingEmployeePhone || "未设置电话号码" }}</span>
|
<span>电话 / 账号:{{ authorizingEmployeePhone || "未设置电话号码" }}</span>
|
||||||
<span>所属组织:{{ authorizingEmployee?.parent_node_label || "未标记组织" }}</span>
|
<span>账号状态:<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? accountStatusLabel(authorizingSystemUser.status) : '未创建账号'" /></span>
|
||||||
<span>账号状态:<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? '' : '未创建账号'" /></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<label class="form-field">
|
<section class="authorize-section authorize-role-section" aria-label="角色授权">
|
||||||
<span>角色</span>
|
<div class="authorize-section-head">
|
||||||
<select v-model="authorizeForm.role_ids" multiple>
|
<div>
|
||||||
<option v-for="role in roles" :key="role.role_id" :value="role.role_id">
|
<p class="eyebrow">角色授权</p>
|
||||||
{{ role.role_name || role.role_code }}
|
<h3>选择系统角色</h3>
|
||||||
</option>
|
</div>
|
||||||
</select>
|
<span class="authorize-selected-count">已选 {{ selectedAuthorizeRoles.length }} 个角色</span>
|
||||||
</label>
|
</div>
|
||||||
<label v-if="!authorizingSystemUser" class="form-field">
|
<div class="authorize-selected-strip">
|
||||||
<span>密码</span>
|
<span v-if="selectedAuthorizeRoleNames.length">{{ selectedAuthorizeRoleNames.join("、") }}</span>
|
||||||
|
<span v-else>尚未选择角色,至少选择一个角色后才能保存。</span>
|
||||||
|
</div>
|
||||||
|
<div class="authorize-role-grid">
|
||||||
|
<button
|
||||||
|
v-for="role in roles"
|
||||||
|
:key="role.role_id"
|
||||||
|
type="button"
|
||||||
|
class="authorize-role-card"
|
||||||
|
:class="{ active: isAuthorizeRoleSelected(role.role_id), danger: isHighRiskRole(role) }"
|
||||||
|
@click="toggleAuthorizeRole(role.role_id)"
|
||||||
|
>
|
||||||
|
<span class="authorize-role-check" aria-hidden="true">{{ isAuthorizeRoleSelected(role.role_id) ? "✓" : "" }}</span>
|
||||||
|
<span class="authorize-role-body">
|
||||||
|
<strong>{{ roleDisplayName(role) }}</strong>
|
||||||
|
<small>{{ roleDisplayCode(role) }}</small>
|
||||||
|
<em>{{ roleRiskText(role) }}</em>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="authorize-section authorize-account-section" aria-label="账号设置">
|
||||||
|
<div class="authorize-section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">账号设置</p>
|
||||||
|
<h3>{{ authorizingSystemUser ? "账号状态" : "创建账号" }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label v-if="!authorizingSystemUser" class="form-field authorize-password-field">
|
||||||
|
<span>初始密码</span>
|
||||||
<input v-model="authorizeForm.password" type="password" placeholder="首次创建账号必填,最少6位" />
|
<input v-model="authorizeForm.password" type="password" placeholder="首次创建账号必填,最少6位" />
|
||||||
</label>
|
</label>
|
||||||
<label class="form-field">
|
<div v-else class="authorize-account-note">
|
||||||
<span>账号状态</span>
|
该人员已创建系统账号;本次仅更新角色和账号启停状态,如需修改密码请使用右上角账号重置入口。
|
||||||
<select v-model="authorizeForm.status">
|
</div>
|
||||||
<option value="ACTIVE">启用</option>
|
<div class="authorize-status-switch" role="group" aria-label="账号状态">
|
||||||
<option value="INACTIVE">停用</option>
|
<button type="button" :class="{ active: authorizeForm.status === 'ACTIVE' }" @click="authorizeForm.status = 'ACTIVE'">启用账号</button>
|
||||||
</select>
|
<button type="button" :class="{ active: authorizeForm.status === 'INACTIVE' }" @click="authorizeForm.status = 'INACTIVE'">停用账号</button>
|
||||||
</label>
|
</div>
|
||||||
<div class="drawer-actions">
|
</section>
|
||||||
<button class="primary-button" type="button" @click="submitAuthorizeUser">保存授权</button>
|
|
||||||
|
<div class="authorize-save-summary">
|
||||||
|
将为 <strong>{{ authorizingEmployee?.employee_name || "该人员" }}</strong>
|
||||||
|
授权 <strong>{{ selectedAuthorizeRoles.length }}</strong> 个角色,账号状态为
|
||||||
|
<strong>{{ accountStatusLabel(authorizeForm.status) }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-actions authorize-actions">
|
||||||
|
<button class="primary-button" type="button" :disabled="!authorizeForm.role_ids.length" @click="submitAuthorizeUser">保存授权</button>
|
||||||
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">取消</button>
|
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -450,6 +495,22 @@ const authorizingEmployeePhone = computed(() =>
|
|||||||
authorizingEmployee.value?.mobile || authorizingSystemUser.value?.mobile || authorizingSystemUser.value?.username || ""
|
authorizingEmployee.value?.mobile || authorizingSystemUser.value?.mobile || authorizingSystemUser.value?.username || ""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const authorizingOrganizationPath = computed(() =>
|
||||||
|
authorizingEmployee.value?.organization_path || authorizingEmployee.value?.parent_node_label || "未标记组织"
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorizingEmployeeInitial = computed(() => {
|
||||||
|
const name = authorizingEmployee.value?.employee_name || authorizingEmployee.value?.display_name || authorizingEmployee.value?.username || "人";
|
||||||
|
return String(name).slice(0, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedAuthorizeRoles = computed(() => {
|
||||||
|
const selectedIds = new Set(authorizeForm.role_ids.map(String));
|
||||||
|
return roles.value.filter((role) => selectedIds.has(String(role.role_id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedAuthorizeRoleNames = computed(() => selectedAuthorizeRoles.value.map((role) => roleDisplayName(role)));
|
||||||
|
|
||||||
const orgDrawerTitle = computed(() => {
|
const orgDrawerTitle = computed(() => {
|
||||||
if (orgDrawerMode.value === "rename") {
|
if (orgDrawerMode.value === "rename") {
|
||||||
return "重命名";
|
return "重命名";
|
||||||
@ -598,6 +659,46 @@ function defaultDeptType(type) {
|
|||||||
}[type] || "ADMIN";
|
}[type] || "ADMIN";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthorizeRoleSelected(roleId) {
|
||||||
|
return authorizeForm.role_ids.map(String).includes(String(roleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAuthorizeRole(roleId) {
|
||||||
|
const targetId = String(roleId);
|
||||||
|
if (isAuthorizeRoleSelected(targetId)) {
|
||||||
|
authorizeForm.role_ids = authorizeForm.role_ids.filter((id) => String(id) !== targetId);
|
||||||
|
} else {
|
||||||
|
authorizeForm.role_ids = [...authorizeForm.role_ids, targetId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleDisplayName(role) {
|
||||||
|
return role?.role_name || role?.name || role?.role_code || "未命名角色";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleDisplayCode(role) {
|
||||||
|
return role?.role_code ? `角色编码:${role.role_code}` : "未设置角色编码";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighRiskRole(role) {
|
||||||
|
const text = [role?.role_code, roleDisplayName(role)].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return text.includes("ADMIN") || text.includes("超级") || text.includes("管理员");
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleRiskText(role) {
|
||||||
|
if (isHighRiskRole(role)) {
|
||||||
|
return "高权限角色,请谨慎授权";
|
||||||
|
}
|
||||||
|
return "业务角色,可按岗位职责授权";
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountStatusLabel(status) {
|
||||||
|
if (status === "INACTIVE") {
|
||||||
|
return "停用";
|
||||||
|
}
|
||||||
|
return "启用";
|
||||||
|
}
|
||||||
|
|
||||||
function nodeActions(node) {
|
function nodeActions(node) {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return [];
|
return [];
|
||||||
@ -614,6 +715,7 @@ function nodeActions(node) {
|
|||||||
];
|
];
|
||||||
if (node.node_type === "COMPANY") {
|
if (node.node_type === "COMPANY") {
|
||||||
actions.push({ key: "addBranch", label: "新增分公司节点" });
|
actions.push({ key: "addBranch", label: "新增分公司节点" });
|
||||||
|
actions.push({ key: "addDepartment", label: "新增直属部门" });
|
||||||
}
|
}
|
||||||
if (node.node_type === "BRANCH") {
|
if (node.node_type === "BRANCH") {
|
||||||
actions.push({ key: "addDepartment", label: "新增部门节点" });
|
actions.push({ key: "addDepartment", label: "新增部门节点" });
|
||||||
@ -633,9 +735,13 @@ function nodeActions(node) {
|
|||||||
|
|
||||||
function openContextMenu(eventPayload) {
|
function openContextMenu(eventPayload) {
|
||||||
selectedNode.value = eventPayload.node;
|
selectedNode.value = eventPayload.node;
|
||||||
|
const menuWidth = 220;
|
||||||
|
const menuHeight = 280;
|
||||||
|
const viewportWidth = window.innerWidth || 1280;
|
||||||
|
const viewportHeight = window.innerHeight || 720;
|
||||||
contextMenu.visible = true;
|
contextMenu.visible = true;
|
||||||
contextMenu.x = eventPayload.x;
|
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
|
||||||
contextMenu.y = eventPayload.y;
|
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
|
||||||
contextMenu.node = eventPayload.node;
|
contextMenu.node = eventPayload.node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1291,6 +1397,294 @@ onMounted(async () => {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorize-drawer {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-drawer-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-header-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-close-button {
|
||||||
|
min-width: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-profile-card,
|
||||||
|
.authorize-section,
|
||||||
|
.authorize-save-summary {
|
||||||
|
border: 1px solid rgba(190, 205, 224, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-profile-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr) minmax(220px, 0.72fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #38bdf8);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-profile-main,
|
||||||
|
.authorize-profile-meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-profile-main strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-profile-main span,
|
||||||
|
.authorize-profile-meta span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-selected-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-selected-strip {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px dashed rgba(96, 165, 250, 0.5);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: #f8fbff;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr);
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 96px;
|
||||||
|
border: 1px solid rgba(190, 205, 224, 0.95);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||||
|
color: #0f172a;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease, background 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card:hover {
|
||||||
|
border-color: rgba(37, 99, 235, 0.42);
|
||||||
|
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card.active {
|
||||||
|
border-color: rgba(37, 99, 235, 0.78);
|
||||||
|
background: linear-gradient(180deg, #eff6ff, #ffffff);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18), 0 14px 28px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card.danger.active {
|
||||||
|
border-color: rgba(249, 115, 22, 0.86);
|
||||||
|
background: linear-gradient(180deg, #fff7ed, #ffffff);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.2), 0 14px 28px rgba(249, 115, 22, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-check {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.72);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card.active .authorize-role-check {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card.danger.active .authorize-role-check {
|
||||||
|
border-color: #f97316;
|
||||||
|
background: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-body {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-body strong,
|
||||||
|
.authorize-role-body small,
|
||||||
|
.authorize-role-body em {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-body strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-body small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-body em {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #eef6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-role-card.danger .authorize-role-body em {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-account-section {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(255, 255, 255, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-password-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-account-note {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-status-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
border: 1px solid rgba(190, 205, 224, 0.95);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #f8fafc;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-status-switch button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-status-switch button.active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-save-summary {
|
||||||
|
padding: 13px 16px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-save-summary strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorize-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.permission-tree {
|
.permission-tree {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user