Initial commit

This commit is contained in:
汤学会
2026-06-12 16:00:56 +08:00
commit c927886888
337 changed files with 160396 additions and 0 deletions
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const inventoryLedger = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const purchaseOrder = readFileSync(new URL("../src/views/PurchaseOrderView.vue", import.meta.url), "utf8");
const purchaseReceipt = readFileSync(new URL("../src/views/PurchaseReceiptView.vue", import.meta.url), "utf8");
assert.match(inventoryLedger, /const activeWeightOnly = computed\(\(\) => \["raw", "scrap"\]\.includes\(activeInventoryTab\.value\)\)/);
assert.match(inventoryLedger, /const selectedWeightOnly = computed\(\s*\(\) => \["RAW", "SCRAP"\]\.includes\(selectedWarehouseType\.value\)\s*\)/);
assert.match(inventoryLedger, /const activeQuantityOnly = computed\(\(\) => activeInventoryTab\.value === "auxiliary"\)/);
assert.match(inventoryLedger, /const selectedQuantityOnly = computed\(\(\) => selectedWarehouseType\.value === "AUX"\)/);
assert.match(inventoryLedger, /normalizeWarehouseType\(lot\) === "AUX"[\s\S]*return lotAvailableQty\(lot\) > 0/);
assert.match(inventoryLedger, /<th v-if="!activeQuantityOnly">现存重量\(kg\)<\/th>/);
assert.match(inventoryLedger, /<td v-if="!activeQuantityOnly">[\s\S]*formatWeight\(balance\.weight_on_hand_kg\)/);
assert.match(inventoryLedger, /<th v-if="!activeQuantityOnly">变动重量\(kg\)<\/th>/);
assert.match(inventoryLedger, /selectedQuantityOnly\.value[\s\S]*return "可用数量"/);
assert.match(inventoryLedger, /isSelectedFinishedWarehouse\.value \|\| selectedQuantityOnly\.value[\s\S]*key: "inbound_qty"/);
assert.match(inventoryLedger, /!\s*selectedQuantityOnly\.value \? \[\s*\{ key: "inbound_weight_kg"/);
assert.match(inventoryLedger, /activeWarehouseType\.value === "AUX"\s*\?\s*Number\(genericForm\.qty \|\| 0\)/);
assert.match(inventoryLedger, /辅料生产出库必须填写用途/);
assert.match(inventoryLedger, /activeWarehouseType === "AUX" \? "单价\(元\/件\)" : "单价\(元\/kg\)"/);
assert.match(inventoryLedger, /<th v-if="activeQuantityOnly">单位<\/th>/);
assert.match(inventoryLedger, /<td v-if="activeQuantityOnly">\{\{ formatInventoryUnitLabel\(balance\) \}\}<\/td>/);
assert.match(inventoryLedger, /searchFields: \["item_code", "item_name", "specification", "stock_unit_name", "purchase_unit_name", "sales_unit_name", "warehouse_name", "location_name", "operation_label", "source_material_summary"\]/);
assert.match(inventoryLedger, /const specificationLabel = balance\.specification \? ` · \$\{balance\.specification\}` : ""/);
assert.match(inventoryLedger, /const baseLabel = `\$\{balance\.item_code\} · \$\{balance\.item_name\}\$\{specificationLabel\}`/);
assert.match(inventoryLedger, /function formatInventoryUnitLabel\(item\)/);
assert.match(inventoryLedger, /计量单位:\(\[\^;;\]\+\)/);
assert.match(purchaseOrder, /v-model\.number="item\.order_qty"/);
assert.match(purchaseOrder, /order_qty: form\.target_warehouse_type === "AUX" \? Number\(item\.order_qty \|\| 0\) : 0/);
assert.match(purchaseOrder, /order_weight_kg: form\.target_warehouse_type === "AUX" \? 0 : Number\(item\.order_weight_kg \|\| 0\)/);
assert.match(purchaseOrder, /formatOrderTotal\(order\)/);
assert.match(purchaseOrder, /formatPurchaseUnitPriceDisplay\(row\)/);
assert.match(purchaseReceipt, /const selectedPurchaseTargetType = computed/);
assert.match(purchaseReceipt, /v-model\.number="item\.received_qty"/);
assert.match(purchaseReceipt, /received_qty: selectedPurchaseTargetType\.value === "AUX" \? Number\(item\.received_qty \|\| 0\) : 0/);
assert.match(purchaseReceipt, /received_weight_kg: selectedPurchaseTargetType\.value === "AUX" \? 0 : Number\(item\.received_weight_kg \|\| 0\)/);
assert.match(purchaseReceipt, /formatReceiptItemReceived\(row\)/);
console.log("辅料库数量口径前端静态检查通过");
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const dashboardSource = readFileSync(new URL("../src/views/DashboardView.vue", import.meta.url), "utf8");
assert.match(dashboardSource, /原材料生命周期 DAG/, "经营总览应保留原材料生命周期 DAG");
assert.match(dashboardSource, /拖动画布 · 点击节点看详情/, "DAG 操作提示不应再提滚轮缩放");
assert.match(dashboardSource, /title="放大"[\s\S]*@click="zoomCanvasBy\(0\.08\)"/, "DAG 右上角应提供放大按钮");
assert.match(dashboardSource, /title="缩小"[\s\S]*@click="zoomCanvasBy\(-0\.08\)"/, "DAG 右上角应提供缩小按钮");
assert.match(dashboardSource, /function zoomCanvasBy\(delta\)/, "DAG 应通过按钮函数调整缩放比例");
assert.doesNotMatch(dashboardSource, /@wheel/, "DAG 视图不应绑定鼠标滚轮缩放");
assert.doesNotMatch(dashboardSource, /function zoomCanvas\(event\)/, "DAG 不应保留鼠标滚轮缩放函数");
assert.doesNotMatch(dashboardSource, /deltaY/, "DAG 缩放逻辑不应依赖鼠标滚轮 deltaY");
console.log("dashboard lifecycle DAG zoom checks passed");
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
const inventory = readSource("src/views/InventoryLedgerView.vue");
const delivery = readSource("src/views/DeliveryManagementView.vue");
const app = readSource("src/App.vue");
const router = readSource("src/router/index.js");
const deliveryWorkspace = readSource("src/views/DeliveryWorkspaceView.vue");
assert.match(inventory, /delivery_mode:\s*"SALES_ORDER"/, "sales-out form should default to sales-order delivery mode");
assert.match(inventory, /直接客户发货/, "warehouse sales-out drawer should support direct customer delivery");
assert.match(inventory, /销售订单发货/, "warehouse sales-out drawer should keep sales-order delivery mode");
assert.match(inventory, /isSalesOrderDelivery/, "sales-out drawer should centralize mode-dependent behavior");
assert.match(inventory, /setDeliveryMode\("DIRECT_CUSTOMER"\)/, "direct customer mode should be selectable");
assert.match(inventory, /deliveryForm\.delivery_mode === "SALES_ORDER"/, "sales order fields should be conditional");
assert.match(inventory, /source_material_summary/, "pre-submit allocation rows should expose source material summary");
assert.match(inventory, />来源材料摘要</, "pre-submit allocation table should show source material summary column");
assert.match(inventory, />成品库存批次号</, "pre-submit allocation table should show finished stock lot column");
assert.match(inventory, />来源原材料库存批次号</, "pre-submit allocation table should show source raw-material lot column");
assert.match(inventory, /sales_order_id:\s*isSalesOrderDelivery\.value \? Number\(deliveryForm\.sales_order_id\) : null/, "direct customer delivery should send null sales_order_id");
assert.match(inventory, /sales_order_item_id:\s*isSalesOrderDelivery\.value \? Number\(deliveryForm\.sales_order_item_id\) : null/, "direct customer delivery lines should send null sales_order_item_id");
assert.match(delivery, /发货台账/, "delivery management should be renamed to delivery ledger");
assert.match(delivery, /交付查询/, "delivery ledger should describe query/trace use");
assert.match(delivery, />销售订单号</, "delivery ledger should show sales order number");
assert.match(delivery, /source_material_summary/, "delivery ledger detail should keep material trace summary");
assert.doesNotMatch(delivery, /新增发货/, "delivery ledger must not expose create-delivery button");
assert.doesNotMatch(delivery, /openCreateDrawer/, "delivery ledger must not keep create drawer action");
assert.doesNotMatch(delivery, /submitDelivery/, "delivery ledger must not keep create submit handler");
assert.doesNotMatch(delivery, /postResource/, "delivery ledger must not import write API helpers");
assert.doesNotMatch(delivery, /uploadResource/, "delivery ledger must not upload logistics photos");
assert.doesNotMatch(delivery, /title="新增发货"/, "delivery ledger must not contain create drawer");
assert.match(app, /label: "发货台账"/, "main navigation should expose delivery ledger naming");
assert.match(router, /title: "发货台账"/, "route tab title should expose delivery ledger naming");
assert.match(deliveryWorkspace, /label: "发货台账"/, "delivery workspace section should expose delivery ledger naming");
console.log("delivery entry unification checks passed");
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const source = fs.readFileSync(path.resolve("src/views/DeliveryManagementView.vue"), "utf8");
assert.match(
source,
/const deliveryLedgerRows = computed\(\(\) =>/,
"delivery ledger should build sales-order-level aggregate rows"
);
assert.match(
source,
/v-for="group in paginatedDeliveryGroups"/,
"main delivery ledger table should render aggregated sales-order groups"
);
assert.doesNotMatch(
source,
/v-for="delivery in paginatedDeliveries"/,
"main delivery ledger table should not render one row per delivery order"
);
assert.match(
source,
/function buildDeliveryLedgerRows\(/,
"delivery ledger should have a dedicated aggregation helper"
);
assert.match(
source,
/发货单流水/,
"sales-order detail drawer should show delivery order flow"
);
assert.match(
source,
/发货明细流水/,
"sales-order detail drawer should show delivery item flow"
);
assert.match(
source,
/const activeDeliveryGroup = computed\(\(\) =>/,
"detail drawer should be driven by the selected sales-order group"
);
console.log("delivery ledger order aggregation checks passed");
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const appSource = fs.readFileSync(path.resolve("src/App.vue"), "utf8");
const routerSource = fs.readFileSync(path.resolve("src/router/index.js"), "utf8");
const deliveryWorkspaceSource = fs.readFileSync(path.resolve("src/views/DeliveryWorkspaceView.vue"), "utf8");
const scrapReworkSource = fs.readFileSync(path.resolve("src/views/ScrapReworkView.vue"), "utf8");
assert.match(appSource, /label:\s*"发货概览"/, "left menu module label should be renamed to 发货概览");
assert.match(routerSource, /title:\s*"发货概览"/, "route title should be renamed to 发货概览");
assert.match(deliveryWorkspaceSource, /eyebrow="发货概览"/, "delivery workspace eyebrow should use 发货概览");
assert.match(deliveryWorkspaceSource, /发货概览模块/, "delivery workspace description should use 发货概览");
assert.match(scrapReworkSource, /发货概览模块/, "scrap/rework guidance should use the renamed module");
for (const [file, source] of [
["src/App.vue", appSource],
["src/router/index.js", routerSource],
["src/views/DeliveryWorkspaceView.vue", deliveryWorkspaceSource],
["src/views/ScrapReworkView.vue", scrapReworkSource]
]) {
assert.doesNotMatch(source, /发货与售后/, `${file} should not keep the old delivery module name`);
}
console.log("delivery overview rename checks passed");
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import * as dictionaries from "../src/utils/dictionaries.js";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
function assertFormatter(name, samples) {
assert.equal(typeof dictionaries[name], "function", `${name} should be exported`);
samples.forEach(([value, expected]) => {
assert.equal(dictionaries[name](value), expected, `${name} should translate ${value}`);
});
}
function assertNoRawEnumFallback(name) {
const rawCode = "UNTRANSLATED_SAMPLE_ENUM";
assert.notEqual(dictionaries[name](rawCode), rawCode, `${name} must not render raw English enum fallbacks`);
assert.match(dictionaries[name](rawCode), /未识别/, `${name} fallback should be Chinese`);
}
assertFormatter("formatInventoryTxnTypeLabel", [
["OPENING_IN", "期初入库"],
["PRODUCTION_SURPLUS_IN", "生产余料入库"],
["OUTSOURCING_SURPLUS_IN", "委外余料入库"],
["WIP_IN", "产中入库"],
["WIP_OUT", "产中出库"],
["PRODUCTION_FINISHED_IN", "生产入库"],
["STOCKTAKE_GAIN", "盘库盘盈"],
["STOCKTAKE_LOSS", "盘库盘亏"],
["STOCKTAKE_NEW_LOT", "盘库新增批次"]
]);
assertNoRawEnumFallback("formatInventoryTxnTypeLabel");
assertFormatter("formatSourceDocTypeLabel", [
["OPENING_STOCK", "期初入库"],
["STOCKTAKE", "盘库"],
["PRODUCTION_OUT", "生产出库"],
["OUTSOURCING_OUT", "委外出库"],
["RETURN_OUT", "返工出库"]
]);
assertNoRawEnumFallback("formatSourceDocTypeLabel");
assertFormatter("formatWarehouseTypeLabel", [
["RAW", "原材料库"],
["SEMI", "半成品库"],
["FINISHED", "成品库"],
["AUX", "辅料库"],
["SCRAP", "废料库"],
["RETURN", "退货库"]
]);
assertNoRawEnumFallback("formatWarehouseTypeLabel");
assertFormatter("formatItemTypeLabel", [
["RAW_MATERIAL", "原材料"],
["FINISHED_GOOD", "成品"],
["PRODUCT", "产品"]
]);
assertNoRawEnumFallback("formatItemTypeLabel");
assertFormatter("formatPurchaseSourceTypeLabel", [
["SALES_ORDER", "销售订单采购"],
["STOCKING", "备料采购"]
]);
assertNoRawEnumFallback("formatPurchaseSourceTypeLabel");
assertFormatter("formatMiniappRoleLabel", [
["worker", "员工"],
["manager", "经理"],
["admin", "管理员"]
]);
assertNoRawEnumFallback("formatMiniappRoleLabel");
assertFormatter("formatMaintenanceTypeLabel", [
["PERIODIC", "周期保养"],
["REPAIR", "维修保养"],
["INSPECTION", "点检保养"]
]);
assertNoRawEnumFallback("formatMaintenanceTypeLabel");
assertFormatter("formatStocktakeRowStatusLabel", [
["READY", "可确认"],
["ERROR", "异常"],
["IGNORED", "已忽略"]
]);
assertNoRawEnumFallback("formatStocktakeRowStatusLabel");
const dictionarySource = readSource("src/utils/dictionaries.js");
assert.match(dictionarySource, /function normalizeDictionaryKey/, "dictionary lookup should normalize keys");
assert.match(dictionarySource, /function isEnumLikeValue/, "dictionary lookup should identify enum-like fallbacks");
assert.match(dictionarySource, /未识别/, "dictionary fallback should be Chinese");
const tableControlsSource = readSource("src/utils/tableControls.js");
[
"WAREHOUSE_TYPE_LABELS",
"ITEM_TYPE_LABELS",
"PURCHASE_SOURCE_TYPE_LABELS",
"MAINTENANCE_TYPE_LABELS",
"MINIAPP_ROLE_LABELS",
"STOCKTAKE_ROW_STATUS_LABELS"
].forEach((mapName) => {
assert.match(tableControlsSource, new RegExp(mapName), `TableControls search should include ${mapName}`);
});
const inventorySource = readSource("src/views/InventoryLedgerView.vue");
assert.match(inventorySource, /formatWarehouseTypeLabel/, "inventory view should use warehouse type formatter");
assert.match(inventorySource, /formatItemTypeLabel/, "inventory view should use item type formatter");
assert.doesNotMatch(inventorySource, /placeholder="如 SALES_OUT、RETURN_IN"/, "inventory ledger filter should not show English examples");
const equipmentSource = readSource("src/views/EquipmentManagementView.vue");
assert.match(equipmentSource, /formatMaintenanceTypeLabel/, "equipment view should translate maintenance types");
assert.doesNotMatch(equipmentSource, /\{\{\s*order\.maintenance_type\s*\}\}/, "equipment view must not render raw maintenance_type");
const employeeSource = readSource("src/views/EmployeeManagementView.vue");
assert.match(employeeSource, /formatPersonRoles/, "employee view should centralize role display formatting");
assert.match(employeeSource, /formatMiniappRoleLabel/, "employee view should translate miniapp role codes");
assert.doesNotMatch(employeeSource, /personnel \/ person_roles/, "employee view should not show raw table names");
const stocktakeSource = readSource("src/components/StocktakeDialog.vue");
assert.match(stocktakeSource, /formatStocktakeRowStatusLabel/, "stocktake dialog should have row status formatter available");
const purchaseSource = readSource("src/views/PurchaseOrderView.vue");
assert.match(purchaseSource, /formatPurchaseSourceTypeLabel/, "purchase order view should translate source_type");
assert.doesNotMatch(purchaseSource, /"source_type",\s*$/m, "purchase order search should not rely on raw source_type only");
console.log("dictionary localization checks passed");
@@ -0,0 +1,72 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const files = [
"src/components/documentForms/DocumentPaper.vue",
"src/components/documentForms/DocumentGrid.vue",
"src/components/documentForms/DocumentLineTable.vue",
"src/components/documentForms/DocumentArchiveActions.vue",
"src/styles/main.css"
];
const sources = new Map();
for (const file of files) {
const path = resolve(process.cwd(), file);
if (!existsSync(path)) {
throw new Error(`Missing required file: ${file}`);
}
const source = readFileSync(path, "utf8");
if (source.trim().length < 200) {
throw new Error(`Required file has unexpectedly short content: ${file}`);
}
sources.set(file, source);
}
const documentPaper = sources.get("src/components/documentForms/DocumentPaper.vue");
for (const snippet of ["ERP留存联", "document-paper-clip", "document-paper-binder-hole"]) {
if (!documentPaper.includes(snippet)) {
throw new Error(`DocumentPaper missing required snippet: ${snippet}`);
}
}
const archiveActions = sources.get("src/components/documentForms/DocumentArchiveActions.vue");
for (const snippet of ["预览PDF", "下载PDF", "重新生成"]) {
if (!archiveActions.includes(snippet)) {
throw new Error(`DocumentArchiveActions missing required label: ${snippet}`);
}
}
for (const forbiddenSnippet of ["ghost-button", "action-button"]) {
if (archiveActions.includes(forbiddenSnippet)) {
throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`);
}
}
for (const snippet of ["document-archive-button", "normalizedStatusType", ".trim()"]) {
if (!archiveActions.includes(snippet)) {
throw new Error(`DocumentArchiveActions missing archive action hardening snippet: ${snippet}`);
}
}
const css = sources.get("src/styles/main.css");
for (const selector of [".document-paper-shell", ".document-paper-sheet", ".document-line-table"]) {
if (!css.includes(selector)) {
throw new Error(`main.css missing required selector: ${selector}`);
}
}
for (const selector of [
".document-archive-button",
".document-archive-button-preview",
".document-archive-button-download",
".document-archive-button-regenerate"
]) {
if (!css.includes(selector)) {
throw new Error(`main.css missing document archive button selector: ${selector}`);
}
}
console.log("document form components static tests passed");
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const componentPath = resolve(process.cwd(), "src/components/DrawerDataTable.vue");
const source = readFileSync(componentPath, "utf8");
const requiredSnippets = [
"defineProps",
"columns",
"rows",
"rowKey",
"expandedRows",
"cellClass",
"slot :name=\"`cell-${column.key}`\"",
"drawer-data-table",
"drawer-row-detail"
];
for (const snippet of requiredSnippets) {
if (!source.includes(snippet)) {
throw new Error(`DrawerDataTable missing required snippet: ${snippet}`);
}
}
console.log("drawer data table static tests passed");
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const pages = [
{
file: "src/views/DeliveryManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedDeliveryItemRows",
"const deliveryItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedDeliveryItemRows\""
]
},
{
file: "src/views/CompletionReceiptView.vue",
snippets: [
"import DrawerDataTable",
"const expandedCompletionItemRows",
"const completionItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedCompletionItemRows\""
]
},
{
file: "src/views/WorkOrderManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedWorkOrderMaterialRows",
"const expandedWorkOrderOperationRows",
"const workOrderMaterialDrawerColumns",
"const workOrderOperationDrawerColumns",
"v-model:expanded-rows=\"expandedWorkOrderMaterialRows\"",
"v-model:expanded-rows=\"expandedWorkOrderOperationRows\""
]
},
{
file: "src/views/PurchaseReceiptView.vue",
snippets: [
"import DrawerDataTable",
"const expandedReceiptItemRows",
"const receiptItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedReceiptItemRows\""
]
},
{
file: "src/views/ReturnManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedDispositionRows",
"const dispositionDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedDispositionRows\""
]
},
{
file: "src/views/BomManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedBomItemRows",
"const bomItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedBomItemRows\""
]
},
{
file: "src/views/ProcessRouteManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedRouteOperationRows",
"const routeOperationDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedRouteOperationRows\""
]
},
{
file: "src/views/PurchaseOrderView.vue",
snippets: [
"import DrawerDataTable",
"const expandedPurchaseOrderItemRows",
"const purchaseOrderItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedPurchaseOrderItemRows\""
]
}
];
for (const page of pages) {
const source = readFileSync(resolve(process.cwd(), page.file), "utf8");
for (const snippet of page.snippets) {
assert.ok(source.includes(snippet), `${page.file} missing drawer table migration snippet: ${snippet}`);
}
}
console.log("drawer table migration static tests passed");
@@ -0,0 +1,560 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { buildGenericItemOptions, buildProductOperationOptions } from "../src/utils/inventoryOperationOptions.js";
const stockedBalances = [
{
item_id: 101,
item_code: "原材料00001",
item_name: "304不锈钢",
warehouse_id: 1,
warehouse_type: "RAW",
weight_available_kg: 100,
qty_available: 0,
avg_unit_cost: 4
},
{
item_id: 101,
item_code: "原材料00001",
item_name: "304不锈钢",
warehouse_id: 1,
warehouse_type: "RAW",
weight_available_kg: 40,
qty_available: 0,
avg_unit_cost: 5
},
{
item_id: 102,
item_code: "原材料00002",
item_name: "冷轧板",
warehouse_id: 1,
warehouse_type: "RAW",
weight_available_kg: 0,
qty_available: 0
},
{
item_id: 103,
item_code: "原材料00003",
item_name: "铝合金",
warehouse_id: 2,
warehouse_type: "RAW",
weight_available_kg: 80,
qty_available: 0
}
];
const rawOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "raw",
activeWarehouseType: "RAW",
warehouseId: 1,
balances: stockedBalances,
materials: [],
products: []
});
assert.deepEqual(
rawOutboundOptions.map((item) => ({
item_id: item.item_id,
item_code: item.item_code,
item_name: item.item_name,
weight_available_kg: item.weight_available_kg
})),
[
{
item_id: 101,
item_code: "原材料00001",
item_name: "304不锈钢",
weight_available_kg: 140
}
]
);
const rawInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "raw",
activeWarehouseType: "RAW",
warehouseId: 1,
balances: stockedBalances,
materials: [{ item_id: 201, item_code: "原材料00004", item_name: "铜板" }],
products: [{ item_id: 301, item_code: "产品00001", item_name: "支架" }]
});
assert.deepEqual(rawInboundOptions.map((item) => item.item_id), [201]);
const finishedOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "finished",
activeWarehouseType: "FINISHED",
warehouseId: 8,
balances: [
{
item_id: 401,
item_code: "产品需规00001",
item_name: "电脑支架",
warehouse_id: 8,
warehouse_type: "FINISHED",
qty_available: 12,
weight_available_kg: 30
}
],
materials: [],
products: []
});
assert.equal(finishedOutboundOptions.length, 1);
assert.equal(finishedOutboundOptions[0].qty_available, 12);
const semiOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "semi",
activeWarehouseType: "SEMI",
warehouseId: 9,
balances: [
{
item_id: 501,
item_code: "产品需规00002",
item_name: "半成品支架",
source_line_id: 9101,
option_key: "501::9101",
operation_label: "产品:半成品支架;已完成工序:第1道 冲压",
warehouse_id: 9,
warehouse_type: "SEMI",
qty_available: 6,
weight_available_kg: 12
},
{
item_id: 501,
item_code: "产品需规00002",
item_name: "半成品支架",
source_line_id: 9102,
option_key: "501::9102",
operation_label: "产品:半成品支架;已完成工序:第2道 折弯",
warehouse_id: 9,
warehouse_type: "SEMI",
qty_available: 8,
weight_available_kg: 20
}
],
materials: [],
products: []
});
assert.equal(semiOutboundOptions.length, 2);
assert.deepEqual(semiOutboundOptions.map((item) => item.option_key), ["501::9101", "501::9102"]);
const productMasterRows = [{ item_id: 601, item_code: "产品需规00003", item_name: "委外成品" }];
const productOperationRows = [
{
option_key: "601::7001",
item_id: 601,
item_code: "产品需规00003",
item_name: "委外成品",
route_id: 8001,
route_operation_id: 7001,
seq_no: 1,
operation_name: "冲压"
},
{
option_key: "601::7002",
item_id: 601,
item_code: "产品需规00003",
item_name: "委外成品",
route_id: 8001,
route_operation_id: 7002,
seq_no: 2,
operation_name: "折弯"
}
];
const routeOperationOptions = buildProductOperationOptions([
{
route_id: 8001,
product_item_id: 601,
product_code: "产品需规00003",
product_name: "委外成品",
version_no: "V1.0",
status: "ACTIVE",
operations: [
{
route_operation_id: 7001,
seq_no: 1,
operation_name: "1",
process_name: "1",
stamping_method: "切边冲孔",
status: "ACTIVE"
}
]
}
]);
assert.equal(routeOperationOptions[0].operation_label, "第1道 切边冲孔");
assert.equal(routeOperationOptions[0].source_material_summary, "产品:委外成品;已完成工序:第1道 切边冲孔");
const operationWithoutStampingMethod = buildProductOperationOptions([
{
route_id: 8002,
product_item_id: 602,
product_code: "产品需规00004",
product_name: "无冲压方式产品",
version_no: "V1.0",
status: "ACTIVE",
operations: [
{
route_operation_id: 7003,
seq_no: 1,
operation_name: "1",
process_name: "开平",
stamping_method: "",
status: "ACTIVE"
}
]
}
]);
assert.equal(operationWithoutStampingMethod[0].operation_label, "第1道");
const semiInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "semi",
activeWarehouseType: "SEMI",
warehouseId: 9,
balances: [],
materials: [],
products: productMasterRows,
productOperations: productOperationRows
});
const finishedInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "finished",
activeWarehouseType: "FINISHED",
warehouseId: 8,
balances: [],
materials: [],
products: productMasterRows
});
assert.deepEqual(semiInboundOptions.map((item) => item.option_key), ["601::7001", "601::7002"]);
assert.deepEqual(semiInboundOptions.map((item) => item.route_operation_id), [7001, 7002]);
assert.deepEqual(finishedInboundOptions.map((item) => item.item_id), [601]);
const auxiliaryOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "auxiliary",
activeWarehouseType: "AUX",
warehouseId: 10,
balances: [
{
item_id: 701,
item_code: "辅料00001",
item_name: "包装膜",
warehouse_id: 10,
warehouse_type: "AUX",
qty_available: 0,
weight_available_kg: 25
}
],
materials: [],
products: []
});
const auxiliaryInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "auxiliary",
activeWarehouseType: "AUX",
warehouseId: 10,
balances: [],
materials: [{ item_id: 702, item_code: "辅料00002", item_name: "手套" }],
products: []
});
assert.equal(auxiliaryOutboundOptions.length, 1);
assert.equal(auxiliaryOutboundOptions[0].item_id, 701);
assert.deepEqual(auxiliaryInboundOptions.map((item) => item.item_id), [702]);
const scrapInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "scrap",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [],
materials: [{ item_id: 801, item_code: "原材料00005", item_name: "冲压边角料" }],
products: [{ item_id: 901, item_code: "产品需规00004", item_name: "报废支架" }]
});
const scrapOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "scrap",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [
{
item_id: 801,
item_code: "原材料00005",
item_name: "冲压边角料",
warehouse_id: 11,
warehouse_type: "SCRAP",
qty_available: 10,
weight_available_kg: 0
},
{
item_id: 901,
item_code: "产品需规00004",
item_name: "报废支架",
warehouse_id: 11,
warehouse_type: "SCRAP",
qty_available: 0,
weight_available_kg: 42
},
{
item_id: 902,
item_code: "产品需规00005",
item_name: "异仓废料",
warehouse_id: 12,
warehouse_type: "SCRAP",
qty_available: 0,
weight_available_kg: 18
}
],
materials: [],
products: []
});
const scrapWarehouseTypeInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "raw",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [],
materials: [{ item_id: 803, item_code: "原材料00006", item_name: "废铜" }],
products: [{ item_id: 903, item_code: "产品需规00006", item_name: "报废夹具" }]
});
assert.deepEqual(scrapInboundOptions.map((item) => item.item_id), [801, 901]);
assert.deepEqual(scrapWarehouseTypeInboundOptions.map((item) => item.item_id), [803, 903]);
assert.deepEqual(
scrapOutboundOptions.map((item) => ({
item_id: item.item_id,
qty_available: item.qty_available,
weight_available_kg: item.weight_available_kg
})),
[
{
item_id: 901,
qty_available: 0,
weight_available_kg: 42
}
]
);
const inventoryLedgerSource = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
const deliveryManagementSource = readFileSync(
fileURLToPath(new URL("../src/views/DeliveryManagementView.vue", import.meta.url)),
"utf8"
);
const tableControlsSource = readFileSync(
fileURLToPath(new URL("../src/components/TableControls.vue", import.meta.url)),
"utf8"
);
const stockLotTagSelectSource = readFileSync(
fileURLToPath(new URL("../src/components/StockLotTagSelect.vue", import.meta.url)),
"utf8"
);
assert.match(inventoryLedgerSource, /key: "scrap"/);
assert.match(inventoryLedgerSource, /label: "废料库"/);
assert.match(inventoryLedgerSource, /PRODUCTION_SCRAP_IN/);
assert.match(
inventoryLedgerSource,
/const genericUnitCostLabel = computed\(\(\) =>[\s\S]*isProductionScrapInbound\.value[\s\S]*原材料成本单价\(元\/kg\)/,
"production scrap inbound should label unit cost as raw material cost unit price"
);
assert.match(
inventoryLedgerSource,
/const productionScrapToleranceRemainingWeight = computed\(\(\) => \{[\s\S]*productionScrapExpectedWeight\.value[\s\S]*1\.15[\s\S]*productionScrapInboundBeforeWeight/,
"production scrap inbound should calculate the non-settle upper limit from cumulative scrap inbound weight"
);
assert.match(
inventoryLedgerSource,
/genericForm\.settle_work_order && productionScrapSettleNeedsRemark\.value/,
"production scrap inbound should require a settle explanation when final scrap weight is outside tolerance"
);
assert.match(
inventoryLedgerSource,
/const scrapToleranceMax = Number\(productionScrapToleranceRemainingWeight\.value \|\| 0\);[\s\S]*inboundWeight > scrapToleranceMax/,
"production scrap inbound validation should compare inbound weight against cumulative upper-limit remaining amount"
);
assert.match(
inventoryLedgerSource,
/activeOperation\?\.direction !== 'out' && !isCustomerSuppliedInbound && !isSourceLotReturnInbound && !isSemiSourceLotTraceInbound && !isFinishedOutsourcingInbound && !isProductionScrapInbound && !isReworkScrapInbound && !isOutsourcingScrapInbound/,
"production scrap inbound should hide the manual batch number field"
);
assert.match(
inventoryLedgerSource,
/lot_no: isCustomerSuppliedInbound\.value \? null : isProductionScrapInbound\.value \? null[\s\S]*isOutsourcingScrapInbound\.value \? null/,
"production scrap inbound should never submit a manually-entered batch number"
);
assert.match(inventoryLedgerSource, /OUTSOURCING_SCRAP_IN/);
assert.match(inventoryLedgerSource, /SCRAP_SALE_OUT/);
assert.match(inventoryLedgerSource, /LOGISTICS_REQUIRED_BIZ_TYPES/);
assert.match(inventoryLedgerSource, /"CUSTOMER_SUPPLIED"/);
assert.match(inventoryLedgerSource, /const isCustomerSuppliedInbound = computed/);
assert.match(inventoryLedgerSource, /customerSuppliedLotNoPreview/);
assert.match(inventoryLedgerSource, /提交后自动生成/);
assert.match(inventoryLedgerSource, /activeOperation\?\.direction !== 'out' && !isCustomerSuppliedInbound/);
assert.match(inventoryLedgerSource, /lot_no: isCustomerSuppliedInbound\.value \? null/);
assert.match(inventoryLedgerSource, /source_material_sub_batch_no: isCustomerSuppliedInbound\.value\s+\?\s+null/);
assert.match(inventoryLedgerSource, /const workOrderLedgerRows = ref\(\[\]\);/);
assert.match(inventoryLedgerSource, /fetchResource\("\/production\/work-order-ledger\?limit=500", \[\]\)/);
assert.match(inventoryLedgerSource, /workOrderLedgerRows\.value = workOrderLedgerRowData;/);
assert.match(
stockLotTagSelectSource,
/issued_weight_kg:\s*normalizeSelectedIssuedWeight\(lot\)/,
"StockLotTagSelect should preserve an option's issued_weight_kg instead of overwriting it with remaining weight"
);
assert.match(
stockLotTagSelectSource,
/function displayWeight\(lot\)/,
"StockLotTagSelect should support scenario-specific selected-tag weight display"
);
assert.match(
inventoryLedgerSource,
/:display-weight-field="isProductionSurplusInbound \? 'issued_weight_kg' : 'remaining_weight_kg'"/,
"production surplus inbound should display selected source lots by work-order issued weight"
);
assert.doesNotMatch(
inventoryLedgerSource,
/<div v-if="usesGenericSourceLotSelector && !isProductTraceOnlySourceLotInbound" class="feedback-banner">/,
"source-lot return/outbound live total should not use feedback-banner because it triggers toast notifications while typing"
);
assert.doesNotMatch(
inventoryLedgerSource,
/<div v-else-if="isProductTraceOnlySourceLotInbound && selectedGenericSourceLots\.length" class="feedback-banner">/,
"source-lot trace-only live summary should not use feedback-banner because it is not an operation result"
);
assert.match(
inventoryLedgerSource,
/function computeWorkOrderSourceLotReturnLimit\(workOrder,\s*material\)/,
"production surplus inbound should calculate source-lot return limit from the work-order surplus limit"
);
assert.match(
inventoryLedgerSource,
/max_return_weight_kg:\s*returnLimit\.normal/,
"source-lot normal return weight should be capped by the work-order normal surplus amount"
);
assert.match(
inventoryLedgerSource,
/tolerance_return_weight_kg:\s*returnLimit\.tolerance/,
"source-lot tolerance return weight should be capped by the work-order 15 percent tolerance amount"
);
assert.match(
inventoryLedgerSource,
/const productionSurplusToleranceRemainingWeight = computed\(\(\) => \{[\s\S]*productionSurplusExpectedReturnWeight\.value[\s\S]*1\.15[\s\S]*productionSurplusReturnedBeforeWeight/,
"production surplus inbound should calculate the non-settle upper limit from cumulative returned weight"
);
assert.match(
inventoryLedgerSource,
/:max="productionSurplusReturnInputMax\(lot\)"/,
"production surplus inbound input should use a dynamic return max so settle mode can allow explained deviations"
);
assert.match(
inventoryLedgerSource,
/function productionSurplusReturnInputMax\(lot\)[\s\S]*genericForm\.settle_work_order[\s\S]*source_remaining_weight_kg/,
"production surplus inbound should relax the input max to source-lot remaining return weight when settling"
);
assert.match(
inventoryLedgerSource,
/const toleranceMax = Number\(productionSurplusToleranceRemainingWeight\.value \|\| 0\);[\s\S]*genericSourceLotReturnWeight\.value > toleranceMax/,
"production surplus inbound validation should compare the selected return total against the cumulative upper-limit remaining amount"
);
assert.match(
inventoryLedgerSource,
/const receivableWorkOrders = computed\(\(\) =>\s*workOrderLedgerRows\.value\.filter\([\s\S]*!isWorkOrderSettled\(item\)[\s\S]*tolerance_finished_inbound_qty[\s\S]*\)\s*\);/
);
assert.match(
inventoryLedgerSource,
/completionForm\.tolerance_receivable_qty = Number\(workOrder\.limits\?\.tolerance_finished_inbound_qty \|\| completionForm\.max_receivable_qty \|\| 0\);/
);
assert.match(
inventoryLedgerSource,
/const genericWorkOrderOptions = computed\(\(\) => \{[\s\S]*!isWorkOrderSettled\(item,\s*"PRODUCTION_SCRAP_IN"\)[\s\S]*tolerance_scrap_inbound_weight_kg[\s\S]*!isWorkOrderSettled\(item,\s*"PRODUCTION_SURPLUS"\)[\s\S]*tolerance_surplus_inbound_weight_kg[\s\S]*return workOrderLedgerRows\.value\.filter\(\(item\) => !isWorkOrderSettled\(item\)\);[\s\S]*\}\);/
);
assert.match(
inventoryLedgerSource,
/source_doc_type: isProductionSurplusInbound\.value \? "WORK_ORDER" : null[\s\S]*source_doc_id: isProductionSurplusInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : null/
);
assert.match(
inventoryLedgerSource,
/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-controls-\$\{activeInventoryTab\}`"/);
assert.match(inventoryLedgerSource, /balanceControls\.sortKey\.value === "operation_label"/);
assert.match(tableControlsSource, /hasEnhancedButton/);
assert.match(tableControlsSource, /currentHeaderLabel/);
assert.match(inventoryLedgerSource, /const isSelectedFinishedWarehouse = computed/);
assert.match(inventoryLedgerSource, /const selectedPrimaryAvailableLabel = computed/);
assert.match(inventoryLedgerSource, /const selectedPrimaryOnHandLabel = computed/);
assert.match(inventoryLedgerSource, /const selectedAuxiliaryMetricLabel = computed/);
assert.match(inventoryLedgerSource, /function formatSelectedUnitCost/);
assert.match(inventoryLedgerSource, /selectedWarehouseType\.value === "FINISHED"[\s\S]*return "\/件"/);
assert.match(inventoryLedgerSource, /import DrawerDataTable from "\.\.\/components\/DrawerDataTable\.vue"/);
assert.match(inventoryLedgerSource, /const overviewLotDrawerColumns = computed/);
assert.match(inventoryLedgerSource, /const lotDrawerColumns = computed/);
assert.match(inventoryLedgerSource, /const txnDrawerColumns = computed/);
assert.match(inventoryLedgerSource, /key: "inbound_qty"[\s\S]*label: "入库数量"/);
assert.match(inventoryLedgerSource, /key: "auxiliary_weight"[\s\S]*label: "辅助重量\(kg\)"/);
assert.match(inventoryLedgerSource, /v-model:expanded-rows="expandedLotRows"/);
assert.match(inventoryLedgerSource, /v-model:expanded-rows="expandedTxnRows"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_SURPLUS"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_IN"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_SCRAP_IN"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_OUT"/);
assert.match(inventoryLedgerSource, /"SCRAP_SALE_OUT"/);
assert.match(inventoryLedgerSource, /v-model\.trim="genericForm\.waybill_no"/);
assert.match(inventoryLedgerSource, /v-model\.number="genericForm\.freight_amount"/);
assert.match(inventoryLedgerSource, /handleLogisticsPhotoUpload\(genericForm/);
assert.match(inventoryLedgerSource, /order_photo_url: genericRequiresLogistics\.value \? genericForm\.order_photo_url/);
assert.match(inventoryLedgerSource, /v-model\.trim="deliveryForm\.waybill_no"/);
assert.match(inventoryLedgerSource, /v-model\.number="deliveryForm\.freight_amount"/);
assert.match(inventoryLedgerSource, /handleLogisticsPhotoUpload\(deliveryForm/);
assert.match(inventoryLedgerSource, /order_photo_url: deliveryForm\.order_photo_url/);
assert.match(deliveryManagementSource, /发货台账/);
assert.match(deliveryManagementSource, /交付查询/);
assert.doesNotMatch(deliveryManagementSource, /v-model\.trim="form\.waybill_no"/);
assert.doesNotMatch(deliveryManagementSource, /v-model\.number="form\.freight_amount"/);
assert.doesNotMatch(deliveryManagementSource, /handleLogisticsPhotoUpload/);
assert.doesNotMatch(deliveryManagementSource, /waybill_no: form\.waybill_no/);
assert.doesNotMatch(deliveryManagementSource, /order_photo_url: form\.order_photo_url/);
assert.doesNotMatch(deliveryManagementSource, /freight_amount: Number\(form\.freight_amount\)/);
assert.match(inventoryLedgerSource, /fetchResource\("\/master-data\/materials\?limit=200", \[\]\)/);
assert.match(inventoryLedgerSource, /fetchResource\("\/master-data\/products\?limit=200", \[\]\)/);
assert.match(inventoryLedgerSource, /fetchResource\("\/sales\/customers\?limit=200", \[\]\)/);
assert.doesNotMatch(inventoryLedgerSource, /fetchResource\("\/master-data\/materials\?limit=500", \[\]\)/);
assert.doesNotMatch(inventoryLedgerSource, /fetchResource\("\/master-data\/products\?limit=500", \[\]\)/);
assert.doesNotMatch(inventoryLedgerSource, /fetchResource\("\/sales\/customers\?limit=500", \[\]\)/);
assert.match(inventoryLedgerSource, /const isSemiSourceLotTraceInbound = computed/);
assert.match(inventoryLedgerSource, /\["WIP_IN", "OUTSOURCING_IN"\]\.includes/);
assert.match(inventoryLedgerSource, /const isSemiAutoSourceOutbound = computed/);
assert.match(inventoryLedgerSource, /\["WIP_OUT", "OUTSOURCING_OUT"\]\.includes/);
assert.match(inventoryLedgerSource, /const semiAutoSourceOutboundSourceLots = computed/);
assert.match(inventoryLedgerSource, /入库数量/);
assert.match(inventoryLedgerSource, /出库数量/);
assert.match(inventoryLedgerSource, /入库类型/);
assert.match(inventoryLedgerSource, /按件入库/);
assert.match(inventoryLedgerSource, /按重入库/);
assert.match(inventoryLedgerSource, /const selectedSemiMeasureBasis = computed/);
assert.match(inventoryLedgerSource, /function formatSemiAvailability/);
assert.match(inventoryLedgerSource, /function formatSemiMeasureValue/);
assert.match(inventoryLedgerSource, /委外方/);
assert.match(inventoryLedgerSource, /outsourcing_party_name/);
assert.match(inventoryLedgerSource, /getProductBomMaterialItemIds\(genericForm\.item_id\)/);
assert.match(inventoryLedgerSource, /:allow-unavailable="isSourceLotReturnInbound \|\| isSemiSourceLotTraceInbound \|\| isOutsourcingScrapInbound"/);
assert.match(inventoryLedgerSource, /const inboundQty = activeWarehouseType\.value === "AUX" \|\| isFinishedOutsourcingInbound\.value[\s\S]*\? Number\(genericForm\.qty \|\| 0\)[\s\S]*: isSemiSourceLotTraceInbound\.value && selectedSemiMeasureBasis\.value === "PIECE"/);
assert.match(inventoryLedgerSource, /outbound_qty: activeWarehouseType\.value === "AUX"[\s\S]*\? Number\(genericForm\.qty \|\| 0\)[\s\S]*: isSemiAutoSourceOutbound\.value && selectedSemiMeasureBasis\.value === "PIECE"/);
assert.match(inventoryLedgerSource, /const selectedTraceSourceLotIds = isProductTraceOnlySourceLotInbound\.value[\s\S]*selectedGenericSourceLots\.value\.map/);
assert.match(inventoryLedgerSource, /source_lot_ids: selectedTraceSourceLotIds/);
assert.match(inventoryLedgerSource, /semiAutoSourceOutboundSourceLotSummary/);
assert.match(inventoryLedgerSource, /仅用于来源追溯,不扣减原材料库存/);
console.log("inventory ledger option tests passed");
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const viewPath = path.join(root, "src/views/InventoryLedgerView.vue");
const dictionariesPath = path.join(root, "src/utils/dictionaries.js");
const view = fs.readFileSync(viewPath, "utf8");
const dictionaries = fs.readFileSync(dictionariesPath, "utf8");
const checks = [
["raw return operation button", view.includes("raw-return-out") && view.includes("退货出库")],
["dedicated raw return drawer", view.includes("rawReturnDrawerOpen") && view.includes("原材料退货出库")],
["purchase links endpoint", view.includes("/inventory/stock-lots/") && view.includes("/purchase-links")],
["return outbound endpoint", view.includes("/inventory/raw-material-return-outbound")],
["available weight subtracts locked weight", view.includes("rawReturnLotAvailableWeight") && view.includes("locked_weight_kg")],
["purchase link stale request guard", view.includes("rawReturnLinksRequestSeq") && view.includes("requestSeq !== rawReturnLinksRequestSeq")],
["lot change clears logistics evidence", view.includes("rawReturnForm.waybill_no = \"\"") && view.includes("rawReturnForm.order_photo_url = \"\"")],
["purchase return label", dictionaries.includes("PURCHASE_RETURN_OUT") && dictionaries.includes("采购退货出库")],
];
const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
console.error("Raw return UI checks failed:");
failed.forEach(([name]) => console.error(`- ${name}`));
process.exit(1);
}
console.log("Raw return UI checks passed");
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const inventoryPath = path.join(root, "src/views/InventoryLedgerView.vue");
const appPath = path.join(root, "src/App.vue");
const routerPath = path.join(root, "src/router/index.js");
const deliveryWorkspacePath = path.join(root, "src/views/DeliveryWorkspaceView.vue");
const dictionariesPath = path.join(root, "src/utils/dictionaries.js");
const inventory = fs.readFileSync(inventoryPath, "utf8");
const app = fs.readFileSync(appPath, "utf8");
const router = fs.readFileSync(routerPath, "utf8");
const deliveryWorkspace = fs.readFileSync(deliveryWorkspacePath, "utf8");
const dictionaries = fs.readFileSync(dictionariesPath, "utf8");
const checks = [
["return warehouse tab", inventory.includes('key: "return"') && inventory.includes('label: "退货库"')],
["return warehouse type mapping", inventory.includes('return: "RETURN"') && inventory.includes('return: "退货库"')],
["return inbound operation", inventory.includes('RETURN_IN') && inventory.includes('returnInboundDrawerOpen') && inventory.includes('/returns/orders')],
["return scrap inbound operation", inventory.includes('RETURN_SCRAP_IN') && inventory.includes('returnScrapInboundDrawerOpen') && inventory.includes('/returns/scrap-inbound')],
["return rework outbound operation", inventory.includes('RETURN_OUT') && inventory.includes('returnReworkDrawerOpen') && inventory.includes('/returns/rework-outbound')],
["delivery-line trace loaded", inventory.includes('/sales/delivery-items?limit=500') && inventory.includes('formatReturnTraceSummary')],
["return items loaded", inventory.includes('/returns/items?limit=500') && inventory.includes('pendingReturnItems')],
["old delivery return child hidden", !app.includes('label: "退货处理"') && !deliveryWorkspace.includes('ReturnManagementView')],
["old route redirects to warehouse", router.includes('path: "/return-management"') && router.includes('redirect: { name: "inventory-ledger" }')],
["dictionary labels", dictionaries.includes('RETURN_IN: "退货入库"') && dictionaries.includes('RETURN_SCRAP_IN: "退货废料入库"') && dictionaries.includes('RETURN_OUT: "返工出库"')]
];
const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
console.error("Return warehouse UI checks failed:");
failed.forEach(([name]) => console.error(`- ${name}`));
process.exit(1);
}
console.log("Return warehouse UI checks passed");
@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const viewPath = path.join(root, "src/views/InventoryLedgerView.vue");
const view = fs.readFileSync(viewPath, "utf8");
const checks = [
["ledger button exists", view.includes("openWarehouseLedgerDrawer") && view.includes("查看当前仓库出入库流水")],
["ledger drawer exists", view.includes("warehouseLedgerDrawerOpen") && view.includes("出入库流水")],
["ledger endpoint used", view.includes("/inventory/transaction-ledger")],
["warehouse tab drives ledger", view.includes("activeWarehouseType") && view.includes("activeInventoryLabel")],
["server pagination state exists", view.includes("warehouseLedgerPage") && view.includes("warehouseLedgerPageSize") && view.includes("warehouseLedgerTotal")],
["existing material stock detail remains", view.includes("openStockDetail(balance)") && view.includes("selectedTransactions")],
["six warehouse tabs still present", ["raw", "semi", "finished", "auxiliary", "scrap", "return"].every((key) => view.includes(`key: "${key}"`))]
];
const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
console.error("Warehouse transaction ledger UI checks failed:");
failed.forEach(([name]) => console.error(`- ${name}`));
process.exit(1);
}
console.log("Warehouse transaction ledger UI checks passed");
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const sourceFiles = [
"src/views/InventoryLedgerView.vue",
"src/views/PurchaseReceiptView.vue",
"src/components/StocktakeDialog.vue",
"src/views/MasterDataImportView.vue",
"src/views/SupplierManagementView.vue",
"src/views/SupplierMaterialLiteView.vue",
"src/views/SalesPlanningView.vue"
];
for (const relativeFile of sourceFiles) {
const source = fs.readFileSync(path.join(root, relativeFile), "utf8");
const fileInputs = source.match(/<input\b[\s\S]*?>/g)?.filter((input) => /type=["']file["']/.test(input)) || [];
for (const input of fileInputs) {
assert.match(
input,
/class=["'][^"']*(hidden-file-input|modern-upload-input)[^"']*["']/,
`${relativeFile} 的文件上传不能暴露浏览器原生“选择文件”控件:${input}`
);
}
}
const inventorySource = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8");
assert.match(
inventorySource,
/class="workflow-mode-switch delivery-mode-switch"/,
"成品库销售出库方式必须使用统一分段切换控件"
);
assert.doesNotMatch(
inventorySource,
/purchase-choice-card|purchase-choice-grid|choice-check|choice-main/,
"仓库抽屉不能复用采购订单 scoped 样式类,否则会退化成老式裸按钮"
);
const cssSource = fs.readFileSync(path.join(root, "src/styles/main.css"), "utf8");
assert.match(cssSource, /\.modern-upload-pill/, "需要保留现代上传胶囊样式");
assert.match(cssSource, /\.workflow-mode-switch/, "需要保留统一分段切换样式");
assert.match(cssSource, /\.import-dropzone input\[type="file"\]/, "导入 dropzone 需要兜底隐藏原生 file input");
console.log("legacy native UI cleanup checks passed");
@@ -0,0 +1,21 @@
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/views/OperationReportView.vue"), "utf8");
assert.match(source, /RECENT_REPORT_DAYS\s*=\s*15/, "operation reports should default to recent 15 days");
assert.match(source, /buildDefaultReportDateRange/, "operation reports should centralize default date range");
assert.match(source, /reportDateFilters/, "operation reports should keep user date filter state");
assert.match(source, /v-model="reportDateFilters\.start_date"/, "operation report page should expose start date picker");
assert.match(source, /v-model="reportDateFilters\.end_date"/, "operation report page should expose end date picker");
assert.match(source, /params\.set\("start_date", reportDateFilters\.start_date\)/, "operation report request should send start_date");
assert.match(source, /params\.set\("end_date", reportDateFilters\.end_date\)/, "operation report request should send end_date");
assert.match(source, /\/production\/miniapp-operation-reports\?\$\{params\.toString\(\)\}/, "operation report request should use query params");
assert.match(source, /最近15天/, "operation report UI should explain the default date range");
assert.match(source, /开始日期不能晚于结束日期/, "operation report page should reject invalid date ranges before requesting");
assert.match(source, /sync_to_erp:\s*"false"/, "operation report refresh should be read-only by default");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "operation report refresh must not sync ERP implicitly");
console.log("operation report date filter checks passed");
@@ -0,0 +1,20 @@
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/views/OperationReportView.vue"), "utf8");
assert.match(source, /syncReportsToErp/, "operation report page should define an explicit ERP sync action");
assert.match(source, /loadSyncState/, "operation report page should load sync state on entry");
assert.match(source, /fetchResource\("\/production\/miniapp-operation-reports\/sync-state"/, "page should read the last successful sync time");
assert.match(source, /postResource\("\/production\/miniapp-operation-reports\/sync"/, "sync action should call the dedicated sync endpoint");
assert.match(source, /syncingReports\.value\s*=\s*true/, "sync action should enable loading state");
assert.match(source, /:disabled="syncingReports"/, "sync button should be disabled while syncing");
assert.match(source, /同步中/, "sync button should show a syncing label");
assert.match(source, /lastSyncedAt/, "page should display the last successful sync time");
assert.match(source, /await loadReports\(\)/, "page should refresh the list after sync succeeds");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "list refresh should not sync ERP implicitly");
assert.match(source, /sync_to_erp:\s*"false"/, "list refresh should explicitly request read-only data");
console.log("operation report sync checks passed");
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const source = fs.readFileSync(path.resolve("src/views/InventoryLedgerView.vue"), "utf8");
const backendInventorySource = fs.readFileSync(path.resolve("../backend/app/api/routes/inventory.py"), "utf8");
assert.match(
source,
/const isOutsourcingScrapInbound = computed\(\(\) =>[\s\S]*activeWarehouseType\.value === "SCRAP"[\s\S]*activeOperation\.value\?\.bizType === "OUTSOURCING_SCRAP_IN"/,
"outsourcing scrap inbound should have an explicit state flag"
);
assert.match(
source,
/isProductTraceOnlySourceLotInbound\.value \|\| isOutsourcingScrapInbound\.value/,
"outsourcing scrap inbound should use the product-to-raw-material source lot selector"
);
assert.match(
source,
/!isFinishedOutsourcingInbound && !isProductionScrapInbound && !isOutsourcingScrapInbound/,
"outsourcing scrap inbound should hide the manual lot number field"
);
assert.match(
source,
/:allow-unavailable="isSourceLotReturnInbound \|\| isSemiSourceLotTraceInbound \|\| isOutsourcingScrapInbound"/,
"outsourcing scrap inbound should show both available and unavailable raw-material source lots"
);
assert.match(
source,
/isProductTraceOnlySourceLotInbound\.value \|\| isOutsourcingScrapInbound\.value[\s\S]*\? selectedTraceSourceLotNos/,
"outsourcing scrap inbound should submit source lot numbers through source material trace fields"
);
assert.match(
backendInventorySource,
/is_outsourcing_scrap_source_lot_trace_inbound\s*=\s*warehouse_type == "SCRAP" and biz_type == "OUTSOURCING_SCRAP_IN"/,
"backend should classify outsourcing scrap inbound as product raw-material source lot trace inbound"
);
assert.match(
backendInventorySource,
/is_product_raw_source_lot_trace_inbound = \([\s\S]*is_outsourcing_scrap_source_lot_trace_inbound[\s\S]*\)/,
"backend should validate selected raw-material source lots for outsourcing scrap inbound"
);
console.log("outsourcing scrap inbound source lot checks passed");
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
function readSource(relativePath) {
return fs.readFileSync(path.resolve(relativePath), "utf8");
}
function assertContains(source, expected, message) {
assert.ok(source.includes(expected), message);
}
function assertNotContains(source, unexpected, message) {
assert.ok(!source.includes(unexpected), message);
}
const serviceSource = readSource("src/services/employeeOptions.js");
assertContains(
serviceSource,
"PERSONNEL_PERMISSION_CODES",
"employee option service should define PERSONNEL_PERMISSION_CODES"
);
assertContains(
serviceSource,
"/master-data/employee-options?",
"employee option service should call the permission-filtered employee options endpoint"
);
const salesSource = readSource("src/views/SalesPlanningView.vue");
assertContains(salesSource, "销售人员", "sales order form should include a sales employee label");
assertContains(salesSource, "sales_employee_id", "sales order form should submit sales_employee_id");
const permissionSelectorViews = [
{
path: "src/views/PurchaseOrderView.vue",
constant: "PURCHASE_ORDER"
},
{
path: "src/views/PurchaseReceiptView.vue",
constant: "PURCHASE_RECEIPT"
},
{
path: "src/views/InventoryLedgerView.vue",
constant: "INVENTORY_LEDGER"
},
{
path: "src/views/CompletionReceiptView.vue",
constant: "INVENTORY_LEDGER"
},
{
path: "src/views/ReturnManagementView.vue",
constant: "INVENTORY_LEDGER"
},
{
path: "src/views/EquipmentManagementView.vue",
constant: "EQUIPMENT_MANAGEMENT"
}
];
for (const view of permissionSelectorViews) {
const source = readSource(view.path);
assertContains(
source,
"fetchPermissionEmployeeOptions",
`${view.path} should load personnel options through fetchPermissionEmployeeOptions`
);
assertContains(
source,
`PERSONNEL_PERMISSION_CODES.${view.constant}`,
`${view.path} should use PERSONNEL_PERMISSION_CODES.${view.constant}`
);
assertNotContains(
source,
"/master-data/employees",
`${view.path} should not call the unrestricted employee endpoint`
);
}
console.log("permission employee selector checks passed");
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const productSpecSource = readFileSync(new URL("../src/views/ProductSpecLiteView.vue", import.meta.url), "utf8");
const masterDataImportSource = readFileSync(new URL("../src/views/MasterDataImportView.vue", import.meta.url), "utf8");
const masterDataRouteSource = readFileSync(new URL("../../backend/app/api/routes/master_data.py", import.meta.url), "utf8");
assert.doesNotMatch(productSpecSource, />\s*标准工作量\s*</, "产品需规表单不应再展示标准工作量列");
assert.doesNotMatch(
productSpecSource,
/v-model\.number="processRow\.standard_workload"/,
"产品需规表单不应再让用户手填标准工作量"
);
assert.doesNotMatch(
productSpecSource,
/processRow\.standard_workload\s*\|\|\s*\(standardBeat > 0 \? 28800 \/ standardBeat : 0\)/,
"产品需规保存时不应再用手填标准工作量兜底"
);
assert.match(
productSpecSource,
/standard_workload:\s*0/,
"产品需规同步小程序时应保留兼容字段并统一写入 0"
);
assert.doesNotMatch(masterDataImportSource, /标准工作量/, "基础资料导入导出说明不应再提示用户维护标准工作量");
assert.doesNotMatch(
masterDataRouteSource,
/"标准工作量":\s*"standard_workload"/,
"后端导入字段映射不应再接收标准工作量列"
);
assert.doesNotMatch(
masterDataRouteSource,
/_import_number\(data\.get\("standard_workload"\)\)/,
"后端导入不应再读取 Excel 中的标准工作量"
);
console.log("product spec standard workload hidden checks passed");
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8");
const router = read("src/router/index.js");
const app = read("src/App.vue");
const productionLedger = read("src/views/ProductionLedgerView.vue");
const inventory = read("src/views/InventoryLedgerView.vue");
assert.match(router, /ProductionLedgerView/, "router should import ProductionLedgerView");
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(app, /生产台账/, "sidebar should show 生产台账");
assert.doesNotMatch(app, /工单台账/, "sidebar should no longer show 工单台账");
assert.match(productionLedger, /材料库存批次号/, "production ledger page should use material stock lot number");
assert.match(productionLedger, /产品/, "production ledger page should show product");
assert.match(productionLedger, /库外未闭环重量/, "production ledger page should show outside weight");
assert.match(productionLedger, /该批材料结单/, "production ledger page should expose material batch lock action");
assert.match(inventory, /生产台账入库/, "inventory unified inbound should be renamed to production ledger inbound");
assert.match(inventory, /该批材料结单/, "inventory close action should use material batch close wording");
assert.match(inventory, /production-ledger-inbounds\/preview/, "inventory preview should use production ledger inbound endpoint");
assert.match(inventory, /production-ledger-inbounds/, "inventory submit should use production ledger inbound endpoint");
assert.match(inventory, /source_doc_type:\s*isProductionSurplusInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/, "production surplus should link production ledger source");
assert.match(inventory, /source_doc_type:\s*isProductionScrapInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/, "production scrap should link production ledger source");
console.log("production batch ledger UI static checks passed");
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
const app = readSource("src/App.vue");
const router = readSource("src/router/index.js");
const workspace = readSource("src/views/ProductionWorkspaceView.vue");
const operationReports = readSource("src/views/OperationReportView.vue");
const inventoryLedger = readSource("src/views/InventoryLedgerView.vue");
assert.doesNotMatch(
app,
/key:\s*"scrap-rework"[\s\S]*?label:\s*"生产报废"/,
"production side menu should not expose the production scrap entry"
);
assert.doesNotMatch(
app,
/routeNames:\s*\[[\s\S]*?"scrap-rework"[\s\S]*?\]/,
"production module route activation should not include scrap-rework"
);
assert.doesNotMatch(
app,
/permissionsAny:\s*\[[\s\S]*?"MENU_SCRAP_REWORK"[\s\S]*?\]/,
"production module permissions should not depend on the hidden scrap entry"
);
assert.doesNotMatch(
router,
/const productionPermissionOrder = \[[\s\S]*?routeName:\s*"scrap-rework"/,
"production permission order should not choose the production scrap route"
);
assert.doesNotMatch(workspace, /ScrapReworkView/, "production workspace should not import the scrap page");
assert.doesNotMatch(workspace, /label:\s*"生产报废"/, "production workspace should not show a production scrap card");
assert.match(workspace, /title="生产台账与小程序报工"/, "production workspace title should describe the remaining production entries");
assert.match(workspace, /生产台账/, "production workspace should keep the production ledger wording");
assert.match(operationReports, /报废数量/, "operation report page should still keep reported scrap quantity");
assert.match(inventoryLedger, /生产废料入库/, "scrap warehouse production scrap inbound should remain available");
console.log("production scrap entry hidden checks passed");
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
const source = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
const functionStart = source.indexOf("function computeWorkOrderSourceLotReturnLimit");
const nextFunctionStart = source.indexOf("function productionSurplusReturnInputMax", functionStart);
assert.ok(functionStart >= 0 && nextFunctionStart > functionStart, "should find source lot return limit function");
const inputMaxStart = nextFunctionStart;
const nextApplyFunctionStart = source.indexOf("function applyGenericWorkOrder", inputMaxStart);
assert.ok(nextApplyFunctionStart > inputMaxStart, "should find production surplus input max function");
const functionSource = source.slice(functionStart, nextFunctionStart);
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(`${functionSource}; globalThis.computeWorkOrderSourceLotReturnLimit = computeWorkOrderSourceLotReturnLimit;`, sandbox);
const workOrder = {
limits: {
issued_weight_kg: 200,
used_material_weight_kg: 61.8,
returned_material_weight_kg: 0,
max_surplus_inbound_weight_kg: 138.2,
tolerance_surplus_inbound_weight_kg: 158.93
}
};
const material = {
issued_weight_kg: 100,
returned_weight_kg: 0
};
const limit = sandbox.computeWorkOrderSourceLotReturnLimit(workOrder, material);
assert.equal(limit.normal, 69.1, "single lot normal return limit should subtract its proportional used material weight");
assert.equal(limit.tolerance, 79.465, "single lot tolerance limit should also use its proportional remaining weight");
const inputMaxSource = source.slice(inputMaxStart, nextApplyFunctionStart);
assert.doesNotMatch(
inputMaxSource,
/Math\.min\([\s\S]*tolerance_return_weight_kg/,
"single lot input max should not block uneven returns with the proportional suggested return weight"
);
assert.match(
inputMaxSource,
/source_remaining_weight_kg/,
"single lot input max should only cap by the source lot's unreturned issued weight"
);
assert.match(
source,
/:disabled="productionSurplusReturnInputDisabled\(lot\)"/,
"fully returned production surplus source lots should disable the return input instead of showing a min/max browser warning"
);
assert.match(
source,
/function productionSurplusReturnInputDisabled\(lot\)/,
"production surplus inbound should have an explicit disabled helper for fully returned source lots"
);
assert.match(
source,
/const sourceLotsToSubmit = selectedGenericSourceLots\.value\.filter/,
"production surplus inbound should submit only source lots with an actual positive return weight"
);
console.log("production surplus source lot return limit checks passed");
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const inventoryView = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const stockLotSelect = readFileSync(new URL("../src/components/StockLotTagSelect.vue", import.meta.url), "utf8");
const systemExtension = readFileSync(new URL("../src/views/SystemExtensionView.vue", import.meta.url), "utf8");
assert.match(stockLotSelect, /maxSelections/);
assert.match(inventoryView, /生产台账入库/);
assert.match(inventoryView, /一次生产出库只能选择一个材料库存批次号/);
assert.match(inventoryView, /production-ledger-inbounds\/preview/);
assert.match(inventoryView, /production-ledger-inbounds/);
assert.match(inventoryView, /本次入库后该批材料结单/);
assert.match(
inventoryView,
/<input[\s\S]*?v-model\.number="productionWorkOrderInboundForm\.finished_qty"[\s\S]*?@input="handleProductionWorkOrderInboundFinishedQtyInput"/,
"生产台账入库的成品数量必须在输入时即时刷新理论余料和理论废料"
);
assert.doesNotMatch(
inventoryView,
/<input[\s\S]*?v-model\.number="productionWorkOrderInboundForm\.finished_qty"[\s\S]*?@change="loadProductionWorkOrderInboundPreview\(true\)"/,
"生产台账入库的成品数量不能依赖 change/失焦后才刷新理论指标"
);
assert.match(inventoryView, /参考成品数量/);
assert.match(inventoryView, /reference_finished_qty/);
assert.match(inventoryView, /以下指标超过15%导致异常/);
assert.match(inventoryView, /成品入库数量超过本生产台账参考生产数量上限15%/);
assert.match(inventoryView, /余料入库重量与理论值偏差超过上下15%/);
assert.match(inventoryView, /废料重量与理论值偏差超过上下15%/);
assert.doesNotMatch(
inventoryView,
/<label class="form-field">\s*<span>偏差说明<\/span>\s*<textarea[\s\S]*?productionWorkOrderInboundForm\.deviation_remark/,
"生产台账入库偏差说明不能常驻显示,必须在异常确认后弹出填写"
);
assert.match(
inventoryView,
/const confirmed = window\.confirm\(buildProductionWorkOrderInboundConfirmMessage\([\s\S]*?if \(!ensureProductionWorkOrderInboundDeviationRemark\(\)\)/,
"生产台账入库必须先弹入库明细确认,再在异常时弹出偏差说明"
);
assert.match(systemExtension, /原材料库存批次号前缀/);
assert.match(systemExtension, /raw-material-lot-prefix/);
console.log("production work-order inbound UI checks passed");
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
const router = readSource("src/router/index.js");
const app = readSource("src/App.vue");
const productionLedgerView = readSource("src/views/ProductionLedgerView.vue");
const inventoryView = readSource("src/views/InventoryLedgerView.vue");
assert.match(
router,
/import\s+ProductionLedgerView\s+from\s+"..\/views\/ProductionLedgerView\.vue";/,
"router should import ProductionLedgerView"
);
assert.match(
router,
/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"
);
assert.match(
router,
/path:\s*"\/work-order-ledger"[\s\S]*?redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/,
"legacy work-order-ledger route should redirect to production-ledger"
);
assert.match(
app,
/label:\s*"生产台账"[\s\S]*?to:\s*\{\s*name:\s*"production-ledger"\s*\}[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
"App.vue production menu should expose 生产台账 linked to production-ledger"
);
assert.match(
productionLedgerView,
/fetchResource\("\/production\/production-ledger\?limit=500"/,
"ProductionLedgerView should load the production ledger endpoint with limit=500"
);
assert.match(productionLedgerView, /材料库存批次号/, "ProductionLedgerView should show material stock lot number");
assert.match(productionLedgerView, /库外未闭环重量/, "ProductionLedgerView should show outside unclosed weight");
assert.match(productionLedgerView, /该批材料结单/, "ProductionLedgerView should provide material batch lock action");
assert.doesNotMatch(productionLedgerView, /容差上限/, "ProductionLedgerView should not expose tolerance limit wording to users");
assert.doesNotMatch(inventoryView, /容差上限/, "InventoryLedgerView should not expose tolerance limit wording to users");
assert.match(inventoryView, /该批材料余料结单/, "InventoryLedgerView should provide a production-surplus material batch settle option");
assert.match(inventoryView, /该批材料废料结单/, "InventoryLedgerView should provide a production-scrap material batch settle option");
assert.match(
inventoryView,
/source_doc_type:\s*isProductionSurplusInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/,
"InventoryLedgerView production surplus inbound payload should link production ledger source document"
);
assert.match(
inventoryView,
/source_doc_type:\s*isProductionScrapInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/,
"InventoryLedgerView production scrap inbound payload should link production ledger source document"
);
console.log("production ledger static checks passed");
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseOrderView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+formatMaterialOptionLabel\s*\(\s*material\s*,\s*item\s*\)/,
"purchase order view should format material dropdown option labels through a helper"
);
assert.match(
source,
/formatMaterialOptionLabel\(material,\s*item\)/,
"purchase order material dropdown should use the formatted label"
);
assert.match(
source,
/form\.target_warehouse_type\s*!==\s*"AUX"[\s\S]*性能要求:\$\{performance\}/,
"raw-material purchase option labels should append the performance requirement at the end"
);
assert.match(
source,
/materialPerformanceRequirement\(material\)/,
"material option label should read performance requirement from the material master data"
);
console.log("purchase order material option performance checks passed");
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseOrderView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+scheduleSalesOrderPurchaseAutoFill\s*\(/,
"purchase order view should schedule automatic sales-order purchase detail generation"
);
assert.match(
source,
/watch\(\s*\(\)\s*=>\s*normalizedSalesOrderIds\(form\.sales_order_ids\)\.join\(","\)[\s\S]*scheduleSalesOrderPurchaseAutoFill\(\)/,
"sales order selection changes should trigger automatic purchase detail generation"
);
assert.match(
source,
/function\s+applySalesOrderPurchase\s*\(\s*\{\s*automatic\s*=\s*false\s*}\s*=\s*\{\}\s*\)/,
"sales order purchase generation should support automatic and manual refresh modes"
);
assert.match(
source,
/重新带出采购明细/,
"manual button should be kept as a refresh action instead of the required first step"
);
assert.match(
source,
/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"
);
assert.match(
source,
/@click\.stop="toggleSalesOrder\(order\.order_id\)"/,
"sales order option clicks should be handled by the picker itself"
);
assert.match(
source,
/function\s+toggleSalesOrder\s*\(orderId\)[\s\S]*salesOrderPickerOpen\.value\s*=\s*false/,
"selecting or unselecting one sales order should close the picker; selecting another requires focusing the search box again"
);
assert.doesNotMatch(
source,
/function\s+toggleSalesOrder\s*\(orderId\)[\s\S]*salesOrderPickerOpen\.value\s*=\s*true/,
"sales order selection should not force the picker to stay open after choosing one row"
);
assert.doesNotMatch(
source,
/请重新按销售订单带出采购建议/,
"selection changes should no longer ask the user to manually bring out purchase suggestions"
);
console.log("purchase order sales auto-fill checks passed");
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseOrderView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+formatUnitPriceTonHint\s*\(/,
"purchase order view should provide a unit-price ton hint formatter"
);
assert.match(
source,
/formatUnitPriceTonHint\(item\.unit_price\)/,
"purchase order item editor should show 元/kg converted to 元/t"
);
assert.match(
source,
/formatUnitPriceDisplay\(item\.unit_price\)/,
"purchase order detail rows should show both 元/kg and 元/t"
);
assert.match(
source,
/materialChipSubtitle[\s\S]*formatUnitPriceDisplay\(item\.unit_price\)/,
"purchase order material chips should show both 元/kg and 元/t"
);
console.log("purchase order ton price hint checks passed");
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseReceiptView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.doesNotMatch(source, /合并到已有批次|入库批次处理|merge_to_lot_id|merge_mode/, "purchase receipt UI must not expose legacy lot merge controls");
assert.doesNotMatch(source, /YL-NB|buildDefaultLotNo/, "purchase receipt UI must not generate legacy YL-NB lot numbers");
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, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
assert.match(source, /selectedPurchaseTargetType\s*===\s*["']RAW["'][\s\S]*运单号[\s\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, /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, /freight_amount:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*Number\(form\.freight_amount\)/, "purchase receipt payload should include raw freight amount");
assert.match(source, /function\s+formatReceiptOrderOption\s*\(order\)/, "purchase receipt order dropdown should use a formatter");
assert.match(source, /formatReceiptOrderMaterialNames\s*\(order\.purchase_order_id\)/, "purchase receipt order option should append material names from purchase order items");
assert.match(source, /<option v-for="order in receivableOrders"[\s\S]*{{ formatReceiptOrderOption\(order\) }}/, "purchase receipt order dropdown should render formatted options with material names");
console.log("purchase receipt inventory lot UI checks passed");
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import {
formatReturnDeliveryLineLabel,
formatReturnReworkItemLabel,
formatReturnTraceSummary
} from "../src/utils/returnTraceFormatters.js";
const deliveryLine = {
delivery_no: "DLV-NB-20260603-02600003-001",
customer_name: "比得金属有限公司",
product_code: "产品需规00091",
product_name: "钢制碗",
lot_no: "FGL-NB-20260602-产品需规00091-需规00091001-L01",
source_lot_no: "FGL-NB-20260602-产品需规00091-需规00091001-L01",
source_material_code: "原材料00002",
source_material_name: "5系钢板",
source_material_lot_no: "JH_5系钢板00002_0001",
source_material_sub_batch_no: "JH_5系钢板00002_0001",
returnable_qty: 150,
returnable_weight_kg: 75
};
const label = formatReturnDeliveryLineLabel(deliveryLine);
assert.equal(
label,
"比得金属有限公司 · 钢制碗 · 原材料00002 · 5系钢板 · JH_5系钢板00002_0001 · 可退 150 / 75 kg"
);
assert.doesNotMatch(label, /DLV/i, "delivery line option should not expose delivery document numbers");
assert.doesNotMatch(label, /FGL/i, "delivery line option should not expose finished stock lot numbers");
const summary = formatReturnTraceSummary(deliveryLine);
assert.equal(
summary,
"客户:比得金属有限公司;产品:钢制碗;原材料库存批次:原材料00002 · 5系钢板 · JH_5系钢板00002_0001;可退:150 / 75 kg"
);
assert.doesNotMatch(summary, /DLV/i, "return trace summary should not expose delivery document numbers");
assert.doesNotMatch(summary, /FGL/i, "return trace summary should not expose finished stock lot numbers");
console.log("return delivery line label checks passed");
const reworkItem = {
return_no: "RTN-NB-20260603-比得-001",
delivery_no: "DLV-NB-20260603-02600003-002",
product_code: "产品需规00091",
product_name: "钢制碗",
source_lot_no: "RTL-NB-20260603-0260603比得001-001",
source_material_code: "原材料00002",
source_material_name: "5系钢板",
source_material_lot_no: "JH_5系钢板00002_0001",
source_material_sub_batch_no: "JH_5系钢板00002_0001",
return_qty: 20,
return_weight_kg: 24
};
const reworkLabel = formatReturnReworkItemLabel(reworkItem);
assert.equal(
reworkLabel,
"钢制碗 · 原材料00002 · 5系钢板 · JH_5系钢板00002_0001 · 可返工 20 / 24 kg"
);
assert.doesNotMatch(reworkLabel, /RTN/i, "rework item option should not expose return document numbers");
assert.doesNotMatch(reworkLabel, /DLV/i, "rework item option should not expose delivery document numbers");
assert.doesNotMatch(reworkLabel, /RTL/i, "rework item option should not expose return warehouse internal lot numbers");
assert.match(reworkLabel, /JH_5系钢板00002_0001/, "rework item option should show raw-material stock lot number");
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
const inventory = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const dictionaries = readFileSync(new URL("../src/utils/dictionaries.js", import.meta.url), "utf8");
const requiredInventorySnippets = [
"REWORK_FINISHED_IN",
"返工入库",
"reworkCompletion",
"REWORK_SCRAP_IN",
"返工废料入库",
"reworkScrapInbound",
"isReworkScrapInbound",
"source_doc_type: isProductionScrapInbound.value || isReworkScrapInbound.value ? \"WORK_ORDER\"",
"inbound_qty: inboundQty"
];
const requiredDictionarySnippets = [
"REWORK_FINISHED_IN: \"返工入库\"",
"REWORK_SCRAP_IN: \"返工废料入库\"",
"REWORK_RECEIPT: \"返工入库\""
];
for (const snippet of requiredInventorySnippets) {
if (!inventory.includes(snippet)) {
throw new Error(`InventoryLedgerView.vue missing ${snippet}`);
}
}
for (const snippet of requiredDictionarySnippets) {
if (!dictionaries.includes(snippet)) {
throw new Error(`dictionaries.js missing ${snippet}`);
}
}
console.log("return rework closed-loop UI guard passed");
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const inventorySource = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
assert.match(
inventorySource,
/function shouldDisplaySourceMaterialLotNo\(warehouseType\) \{[\s\S]*\["SEMI", "FINISHED", "SCRAP", "RETURN"\]\.includes/,
"return warehouse should display raw-material source stock lot numbers in lot and transaction tables"
);
assert.match(
inventorySource,
/formatSelectedDetailLotNo\(row\)[\s\S]*formatLotSourceLots\(row\)/,
"return warehouse detail drawer should use the shared source-lot formatter once RETURN is enabled"
);
assert.match(
inventorySource,
/formatWarehouseLedgerLotNo\(row\)[\s\S]*formatLotSourceLots\(row\)/,
"return warehouse ledger should use the shared source-lot formatter once RETURN is enabled"
);
console.log("return warehouse source lot display checks passed");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/SalesPlanningView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/promised_date_touched:\s*false/,
"new sales order lines should track whether line promised date was manually edited"
);
assert.match(
source,
/@input="markSalesOrderItemPromisedDateTouched\(item\)"/,
"line promised date input should mark the row as user-edited"
);
assert.match(
source,
/syncSalesOrderItemPromisedDatesFromHeader/,
"header promised date changes should synchronize eligible line dates"
);
assert.match(
source,
/!item\.promised_date_touched/,
"sync should only update untouched line promised dates"
);
assert.match(
source,
/promised_date:\s*item\.promised_date\s*\|\|\s*salesOrderForm\.promised_date\s*\|\|\s*null/,
"submit should fall back empty line dates to the sales order promised date"
);
console.log("sales order line promised date default checks passed");
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/InventoryLedgerView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+orderPendingProductSummary\s*\(/,
"sales order dropdown should build a pending product summary"
);
assert.match(
source,
/orderPendingProductSummary\(order\.order_id\)/,
"sales order dropdown should show pending product names next to the pending quantity"
);
assert.doesNotMatch(
source,
/:disabled="[^"]*totalAllocatedQty\s*<\s*Number\(deliveryForm\.delivery_qty\s*\|\|\s*0\)[^"]*"/,
"sales out submit button should not be disabled by insufficient stock, otherwise the error message cannot show"
);
assert.match(
source,
/本次要发[\s\S]*可发[\s\S]*缺/,
"sales out validation should explain requested quantity, available quantity, and shortage"
);
console.log("sales out drawer feedback checks passed");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/InventoryLedgerView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(source, /<span>包装备注<\/span>/, "sales out drawer should show the packaging remark field");
assert.match(
source,
/placeholder="有无提供篮子等"/,
"packaging remark field should guide the user with the requested placeholder"
);
assert.match(
source,
/remark:\s*""/,
"delivery form state should include a remark field so the packaging remark is reset with the drawer"
);
assert.match(
source,
/remark:\s*deliveryForm\.remark\s*\|\|\s*null,[\s\S]*items:\s*allocationRows\.value\.map/,
"sales out payload should persist the packaging remark on the delivery header"
);
assert.match(
source,
/unit_price:\s*Number\(selectedSalesOrderItem\.value\?\.unit_price\s*\|\|\s*0\),\s*remark:\s*deliveryForm\.remark\s*\|\|\s*null/,
"sales out payload should copy the packaging remark onto generated delivery detail rows"
);
console.log("sales out packaging remark checks passed");
@@ -0,0 +1,103 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
function readRequiredFile(file) {
const path = resolve(process.cwd(), file);
if (!existsSync(path)) {
throw new Error(`Missing required file: ${file}`);
}
return readFileSync(path, "utf8");
}
function assertIncludes(source, snippet, label) {
if (!source.includes(snippet)) {
throw new Error(`${label} missing required snippet: ${snippet}`);
}
}
const api = readRequiredFile("src/services/api.js");
const sales = readRequiredFile("src/views/SalesPlanningView.vue");
const purchase = readRequiredFile("src/views/PurchaseOrderView.vue");
assertIncludes(api, "export async function postDownloadResource", "api.js");
assertIncludes(api, "method: \"POST\"", "postDownloadResource");
assertIncludes(api, "JSON.stringify(payload)", "postDownloadResource");
for (const snippet of [
"DocumentPaper",
"DocumentGrid",
"DocumentLineTable",
"DocumentArchiveActions",
"salesDocumentHeaderFields",
"salesDocumentLineColumns",
"title=\"销售订单\"",
"销售确认",
"客户确认",
"财务复核",
"制单人",
"保存并生成PDF归档",
"保存并归档中...",
"previewDocumentArchive",
"downloadDocumentArchive",
"regenerateDocumentArchive",
"batchDownloadSalesArchives",
"postDownloadResource",
"archive_status",
"sales-archive-cell",
"sales-archive-actions-compact",
"sales-order",
"归档"
]) {
assertIncludes(sales, snippet, "SalesPlanningView.vue");
}
for (const snippet of [
"openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`)",
"downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`",
"postResource(`/document-archives/${typeKey}/${businessId}/generate`, {})",
"postDownloadResource(\"/document-archives/batch-download\"",
"document_type: \"sales-order\""
]) {
assertIncludes(sales, snippet, "Sales archive actions");
}
for (const snippet of [
"DocumentPaper",
"DocumentGrid",
"DocumentLineTable",
"DocumentArchiveActions",
"purchaseDocumentHeaderFields",
"purchaseDocumentLineColumns",
"title=\"采购订单\"",
"采购确认",
"供应商确认",
"仓库接收",
"制单人",
"保存并生成PDF归档",
"更新并生成PDF归档",
"保存并归档中...",
"previewDocumentArchive",
"downloadDocumentArchive",
"regenerateDocumentArchive",
"batchDownloadPurchaseArchives",
"postDownloadResource",
"archive_status",
"purchase-archive-cell",
"purchase-archive-actions-compact",
"purchase-order",
"归档"
]) {
assertIncludes(purchase, snippet, "PurchaseOrderView.vue Task 8 assertions");
}
for (const snippet of [
"openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`)",
"downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`",
"postResource(`/document-archives/${typeKey}/${businessId}/generate`, {})",
"postDownloadResource(\"/document-archives/batch-download\"",
"document_type: \"purchase-order\""
]) {
assertIncludes(purchase, snippet, "Purchase archive actions");
}
console.log("sales/purchase document archive UI static tests passed");
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const inventoryViewSource = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
const backendInventorySource = readFileSync(
fileURLToPath(new URL("../../backend/app/api/routes/inventory.py", import.meta.url)),
"utf8"
);
assert.match(
inventoryViewSource,
/const isScrapSaleOutbound = computed\(\(\) =>[\s\S]*activeWarehouseType\.value === "SCRAP"[\s\S]*activeOperation\.value\?\.bizType === "SCRAP_SALE_OUT"/,
"scrap sale outbound should have an explicit state flag"
);
assert.match(
inventoryViewSource,
/if \(isScrapSaleOutbound\.value\) \{[\s\S]*return "废料";[\s\S]*\}/,
"scrap sale outbound should label the generic item field as 废料"
);
assert.match(
inventoryViewSource,
/usesGenericSourceLotSelector[\s\S]*isScrapSaleOutbound\.value/,
"scrap sale outbound should use the stock-lot selector instead of a manual source-lot input"
);
assert.match(
inventoryViewSource,
/function buildScrapSaleSourceLotOptions\([\s\S]*source_material_sub_batch_no[\s\S]*lot_no: sourceLotNo[\s\S]*source_stock_lot_no/,
"scrap sale outbound should build selectable options from scrap lots while displaying raw-material source lot numbers"
);
assert.match(
inventoryViewSource,
/isScrapSaleOutbound\.value[\s\S]*buildScrapSaleSourceLotOptions/,
"scrap sale outbound source lot options should come from selected scrap stock"
);
assert.match(
backendInventorySource,
/_SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES = \{[\s\S]*"SCRAP_SALE_OUT"[\s\S]*\}/,
"backend should allow scrap sale outbound to specify selected stock lots"
);
console.log("scrap sale outbound source lot checks passed");
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const appVuePath = resolve("frontend/src/App.vue");
const source = readFileSync(appVuePath, "utf8");
const navStart = source.indexOf("const navStages = [");
const navEnd = source.indexOf("\n];", navStart);
assert.notEqual(navStart, -1, "未找到 navStages 菜单配置");
assert.notEqual(navEnd, -1, "未找到 navStages 结束位置");
const navSource = source.slice(navStart, navEnd);
const iconEntries = [];
const entryPattern = /label:\s*"([^"]+)"[\s\S]*?iconPaths:\s*\[([\s\S]*?)\]/g;
let match;
while ((match = entryPattern.exec(navSource))) {
const [, label, iconSource] = match;
const paths = [...iconSource.matchAll(/"([^"]+)"/g)].map((pathMatch) => pathMatch[1].trim());
if (paths.length) {
iconEntries.push({
label,
signature: paths.join("|")
});
}
}
assert.ok(iconEntries.length > 0, "未解析到任何侧边栏图标配置");
const labelsBySignature = new Map();
for (const entry of iconEntries) {
const labels = labelsBySignature.get(entry.signature) || [];
labels.push(entry.label);
labelsBySignature.set(entry.signature, labels);
}
const duplicates = [...labelsBySignature.values()].filter((labels) => labels.length > 1);
assert.deepEqual(
duplicates,
[],
`侧边栏存在重复图标:${duplicates.map((labels) => labels.join(" / ")).join(";")}`
);
console.log(`侧边栏图标唯一性检查通过,共检查 ${iconEntries.length} 个菜单图标。`);
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8");
const featureService = read("src/services/systemFeatures.js");
const router = read("src/router/index.js");
const app = read("src/App.vue");
const systemExtension = read("src/views/SystemExtensionView.vue");
const productionLedger = read("src/views/ProductionLedgerView.vue");
assert.match(featureService, /smart-operation-report/, "feature service should load switch config");
assert.match(router, /operation-reports/, "router should still define operation report route");
assert.match(router, /loadSmartOperationReportConfig/, "router should guard operation-reports by switch");
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(systemExtension, /对接智能报工小程序/, "system extension should show switch");
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail wording when enabled");
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode");
console.log("smart operation report toggle static checks passed");
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
specialAdjustmentDirectionLabel,
specialAdjustmentMeasureMode,
specialAdjustmentOperationKeys
} from "../src/utils/specialAdjustmentRules.js";
const viewPath = fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url));
const operationKeys = specialAdjustmentOperationKeys();
assert.equal(specialAdjustmentDirectionLabel("OUT"), "特殊出库");
assert.equal(specialAdjustmentDirectionLabel("out"), "特殊出库");
assert.equal(specialAdjustmentDirectionLabel("IN"), "特殊入库");
assert.equal(specialAdjustmentDirectionLabel(""), "特殊入库");
assert.equal(specialAdjustmentMeasureMode("RAW"), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SCRAP"), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("AUX"), "QTY");
assert.equal(specialAdjustmentMeasureMode("FINISHED"), "QTY_WITH_WEIGHT");
assert.equal(specialAdjustmentMeasureMode("RETURN"), "QTY_WITH_WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 8, remaining_weight_kg: 0 }), "QTY");
assert.equal(specialAdjustmentMeasureMode("SEMI", { qty_available: 3, weight_available_kg: 0 }), "QTY");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 0, remaining_weight_kg: 12 }), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { qty_available: 0, weight_available_kg: 4 }), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 2, remaining_weight_kg: 7 }), "SEMI_SELECT");
assert.equal(specialAdjustmentMeasureMode("SEMI", {}), "SEMI_SELECT");
assert.deepEqual(operationKeys, {
raw: {
in: "raw-special-in",
out: "raw-special-out"
},
semi: {
in: "semi-special-in",
out: "semi-special-out"
},
finished: {
in: "finished-special-in",
out: "finished-special-out"
},
auxiliary: {
in: "aux-special-in",
out: "aux-special-out"
},
scrap: {
in: "scrap-special-in",
out: "scrap-special-out"
},
return: {
in: "return-special-in",
out: "return-special-out"
}
});
const viewSource = readFileSync(viewPath, "utf8");
const flattenedOperationKeys = Object.values(operationKeys).flatMap((entry) => [entry.in, entry.out]);
for (const operationKey of flattenedOperationKeys) {
assert.ok(viewSource.includes(operationKey), `InventoryLedgerView.vue should include ${operationKey}`);
}
assert.ok(viewSource.includes("特殊入库"), "InventoryLedgerView.vue should include the special inbound label");
assert.ok(viewSource.includes("特殊出库"), "InventoryLedgerView.vue should include the special outbound label");
assert.ok(viewSource.includes("入库方式"), "InventoryLedgerView.vue should label the special inbound line mode in Chinese");
assert.ok(viewSource.includes("补增已有明细"), "InventoryLedgerView.vue should allow adding to an existing special inbound stock detail");
assert.ok(viewSource.includes("新增特殊明细"), "InventoryLedgerView.vue should allow creating a new special inbound stock detail");
assert.ok(viewSource.includes("特殊出库必须选择库存明细"), "InventoryLedgerView.vue should keep lot selection required for special outbound");
assert.ok(viewSource.includes("specialAdjustmentLineLotId(line)"), "InventoryLedgerView.vue should route special adjustment lot_id through conditional logic");
assert.ok(viewSource.includes("lot_id: null"), "InventoryLedgerView.vue should submit lot_id null when special inbound creates a new detail");
assert.ok(viewSource.includes("/inventory/special-adjustments"), "InventoryLedgerView.vue should call the special adjustments API");
console.log("special adjustment UI static assertions passed");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const source = readFileSync(
fileURLToPath(new URL("../src/components/StockLotTagSelect.vue", import.meta.url)),
"utf8"
);
const selectLotStart = source.indexOf("function selectLot(lot)");
const removeLotStart = source.indexOf("function removeLot(lot)");
assert.ok(selectLotStart >= 0 && removeLotStart > selectLotStart, "StockLotTagSelect should define selectLot before removeLot");
const selectLotSource = source.slice(selectLotStart, removeLotStart);
assert.match(
selectLotSource,
/dropdownOpen\.value\s*=\s*false/,
"choosing one stock lot should close the dropdown"
);
assert.doesNotMatch(
selectLotSource,
/dropdownOpen\.value\s*=\s*true/,
"choosing one stock lot should not keep the dropdown open"
);
assert.match(
source,
/@focus="dropdownOpen = true"/,
"clicking back into the search input should open the dropdown again"
);
console.log("stock lot tag select dropdown behavior checks passed");
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
canContinueStocktake,
diffTone,
filterStocktakesForWarehouse,
findActiveStocktakeForWarehouse,
findOnlyActiveStocktake,
hasBlockingDiffErrors,
isEmptyStocktakeRead,
nextStocktakePanel,
splitDiffLines,
stocktakeBelongsToWarehouse,
stocktakeSummaryLineCount,
warehouseStocktakeStatus
} from "../src/utils/stocktakeWorkflow.js";
const emptyExported = {
id: 10,
status: "EXPORTED",
summary: {
match_count: 0,
gain_count: 0,
loss_count: 0,
new_lot_count: 0,
missing_count: 0,
error_count: 0
},
warehouses: [{ warehouse_id: 4, warehouse_name: "辅料库" }]
};
const importedWithDiff = {
id: 11,
status: "IMPORTED",
summary: {
match_count: 2,
gain_count: 1,
loss_count: 1,
new_lot_count: 0,
missing_count: 0,
error_count: 0
},
warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }]
};
const lockedRaw = {
id: 12,
status: "LOCKED",
stocktake_no: "PK-20260530-012",
warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }]
};
assert.equal(stocktakeSummaryLineCount(emptyExported.summary), 0);
assert.equal(isEmptyStocktakeRead(emptyExported), true);
assert.equal(isEmptyStocktakeRead(importedWithDiff), false);
assert.equal(isEmptyStocktakeRead({ status: "EXPORTED" }), false);
assert.equal(nextStocktakePanel(null), "overview");
assert.equal(nextStocktakePanel({ status: "LOCKED" }), "export");
assert.equal(nextStocktakePanel({ status: "EXPORTED" }), "import");
assert.equal(nextStocktakePanel(emptyExported), "diff");
assert.equal(nextStocktakePanel({ ...emptyExported, summary: { match_count: 1 } }), "import");
assert.equal(nextStocktakePanel(importedWithDiff), "diff");
assert.equal(nextStocktakePanel({ status: "CONFIRMED" }), "history");
assert.equal(canContinueStocktake({ status: "LOCKED" }), true);
assert.equal(canContinueStocktake({ status: "EXPORTED" }), true);
assert.equal(canContinueStocktake({ status: "IMPORTED" }), true);
assert.equal(canContinueStocktake({ status: " imported " }), true);
assert.equal(canContinueStocktake({ status: "CONFIRMED" }), false);
assert.equal(findActiveStocktakeForWarehouse([importedWithDiff, lockedRaw], 1).id, 11);
assert.equal(findActiveStocktakeForWarehouse([emptyExported], 4).id, 10);
assert.equal(findActiveStocktakeForWarehouse([emptyExported], 999), null);
assert.equal(findActiveStocktakeForWarehouse([{ status: "LOCKED", warehouses: null }], 1), null);
assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 1), true);
assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 4), false);
assert.deepEqual(filterStocktakesForWarehouse([lockedRaw, emptyExported], 1).map((row) => row.id), [12]);
assert.equal(findOnlyActiveStocktake([lockedRaw]).id, 12);
assert.equal(findOnlyActiveStocktake([lockedRaw, importedWithDiff]), null);
assert.equal(findOnlyActiveStocktake([{ status: "CONFIRMED" }]), null);
const lines = [
{ id: 1, diff_type: "MATCH", row_status: "READY" },
{ id: 2, diff_type: "LOSS", row_status: "READY" },
{ id: 3, diff_type: "GAIN", row_status: "READY" },
{ id: 4, diff_type: "MATCH", row_status: "ERROR" }
];
assert.deepEqual(splitDiffLines(lines, "ALL").map((line) => line.id), [4, 2, 3, 1]);
assert.deepEqual(splitDiffLines(lines, "DIFF").map((line) => line.id), [4, 2, 3]);
assert.deepEqual(splitDiffLines(lines, "ERROR").map((line) => line.id), [4]);
assert.deepEqual(lines.map((line) => line.id), [1, 2, 3, 4]);
assert.equal(hasBlockingDiffErrors(lines), true);
assert.equal(diffTone({ diff_type: "GAIN", row_status: "READY" }), "gain");
assert.equal(diffTone({ diff_type: "LOSS", row_status: "READY" }), "loss");
assert.equal(diffTone({ diff_type: "NEW_LOT", row_status: "READY" }), "new");
assert.equal(diffTone({ diff_type: "MISSING", row_status: "READY" }), "missing");
assert.equal(diffTone({ diff_type: "MATCH", row_status: "ERROR" }), "error");
assert.equal(diffTone({ diff_type: "MATCH", row_status: "READY" }), "match");
const warehouseStatus = warehouseStocktakeStatus(
{ id: 1, warehouse_name: "原材料库", warehouse_type: "RAW" },
[lockedRaw]
);
assert.equal(warehouseStatus.locked, true);
assert.equal(warehouseStatus.activeStocktake.stocktake_no, "PK-20260530-012");
assert.equal(warehouseStatus.label, "盘库中");
const componentSource = readFileSync(
fileURLToPath(new URL("../src/components/StocktakeDialog.vue", import.meta.url)),
"utf8"
);
assert.match(componentSource, /async function applyLoadedStocktake\(stocktake, options = \{\}\)/);
assert.match(componentSource, /await applyLoadedStocktake\(stocktake, options\);/);
assert.match(componentSource, /await applyLoadedStocktake\(exportedStocktake, \{ silent: true \}\);/);
assert.doesNotMatch(componentSource, /findOnlyActiveStocktake\(historyRows\.value\)/);
assert.match(componentSource, /findActiveStocktakeForWarehouse\(historyRows\.value, currentWarehouseId\.value\)/);
assert.match(componentSource, /\/inventory\/stocktakes\?warehouse_id=\$\{currentWarehouseId\.value\}/);
assert.match(componentSource, /import PaginationBar from "\.\/PaginationBar\.vue";/);
assert.match(componentSource, /import \{ usePagination \} from "\.\.\/utils\/pagination";/);
assert.match(componentSource, /usePagination\(currentWarehouseHistoryRows, 6\)/);
assert.match(componentSource, /class="stocktake-main"/);
assert.match(componentSource, /class="stocktake-flow-column"/);
assert.match(componentSource, /:page-size-options="\[6\]"/);
assert.match(componentSource, /v-for="row in paginatedHistoryRows"/);
assert.doesNotMatch(componentSource, /v-for="row in currentWarehouseHistoryRows"/);
assert.doesNotMatch(componentSource, /<th>操作<\/th>/);
assert.doesNotMatch(componentSource, /继续处理\s*<\/button>/);
assert.doesNotMatch(componentSource, /v-if="canContinueStocktake\(row\)"/);
assert.match(componentSource, /hasBlockingDiffErrors\(previewLines\.value\)/);
assert.match(componentSource, /盘点清单已导入:发现/);
const styleSource = readFileSync(
fileURLToPath(new URL("../src/styles/main.css", import.meta.url)),
"utf8"
);
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-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[\s\S]*height: min\(820px, 100%\);/);
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*max-height: 100%;/);
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-confirm-mask[\s\S]*z-index: 4;/);
assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/);
assert.match(styleSource, /\.stocktake-diff-cell span,[\s\S]*line-height: 1\.55;/);
const applyLoadedStart = componentSource.indexOf("async function applyLoadedStocktake(stocktake, options = {})");
const activeStocktakeAssignment = componentSource.indexOf("activeStocktake.value = stocktake;", applyLoadedStart);
const clearConfirmDialogAssignment = componentSource.indexOf("confirmDialogOpen.value = false;", applyLoadedStart);
const clearPreviewLinesAssignment = componentSource.indexOf("previewLines.value = [];", applyLoadedStart);
const clearActiveStocktakeAssignment = componentSource.indexOf("activeStocktake.value = null;", applyLoadedStart);
const clearPreviewSummaryAssignment = componentSource.indexOf("previewSummary.value = {};", applyLoadedStart);
const lineFetchAssignment = componentSource.indexOf(
"fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null)",
applyLoadedStart
);
const lineFetchGuard = componentSource.indexOf('throw new Error("读取盘库明细失败,请重新打开盘库单");', applyLoadedStart);
const activePanelAssignment = componentSource.indexOf(
'activePanel.value = nextPanel === "diff" || nextPanel === "history" ? "import" : nextPanel;',
applyLoadedStart
);
assert.notEqual(applyLoadedStart, -1);
assert.notEqual(activeStocktakeAssignment, -1);
assert.notEqual(clearConfirmDialogAssignment, -1);
assert.notEqual(clearPreviewLinesAssignment, -1);
assert.notEqual(clearActiveStocktakeAssignment, -1);
assert.notEqual(clearPreviewSummaryAssignment, -1);
assert.notEqual(lineFetchAssignment, -1);
assert.notEqual(lineFetchGuard, -1);
assert.notEqual(activePanelAssignment, -1);
assert.ok(clearConfirmDialogAssignment < lineFetchAssignment);
assert.ok(clearPreviewLinesAssignment < lineFetchAssignment);
assert.ok(clearActiveStocktakeAssignment < lineFetchAssignment);
assert.ok(clearPreviewSummaryAssignment < lineFetchAssignment);
assert.ok(lineFetchAssignment < activeStocktakeAssignment);
assert.ok(lineFetchAssignment < activePanelAssignment);
console.log("stocktake workflow tests passed");
@@ -0,0 +1,354 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const appSource = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8");
const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8");
const systemPermissionSource = readFileSync(new URL("../src/views/SystemPermissionView.vue", import.meta.url), "utf8");
const orgMindMapSource = readFileSync(new URL("../src/components/OrgMindMap.vue", import.meta.url), "utf8");
const orgPermissionTreeSource = readFileSync(new URL("../src/components/OrgPermissionTree.vue", import.meta.url), "utf8");
assert.match(appSource, /系统管理/, "App.vue should show the system-management parent menu");
assert.match(appSource, /系统权限管理/, "App.vue should show the system permission child menu");
assert.match(appSource, /系统拓展/, "App.vue should keep the system extension child menu");
assert.match(
appSource,
/permissionsAny:\s*\[[\s\S]*"MENU_SYSTEM_PERMISSION"[\s\S]*"MENU_SYSTEM_EXTENSION"[\s\S]*\]/,
"App.vue system menu should allow either system permission"
);
assert.match(
routerSource,
/import SystemPermissionView from "\.\.\/views\/SystemPermissionView\.vue";/,
"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-permissions"/, "router should add /system-permissions route");
assert.match(
routerSource,
/name:\s*"system-permissions"[\s\S]*permission:\s*"MENU_SYSTEM_PERMISSION"/,
"system-permissions route should require MENU_SYSTEM_PERMISSION"
);
assert.match(systemPermissionSource, /系统权限管理/, "SystemPermissionView should show page title");
assert.match(systemPermissionSource, /组织架构/, "SystemPermissionView should expose organization structure tab");
assert.match(systemPermissionSource, /角色维护/, "SystemPermissionView should expose role-only maintenance tab");
assert.doesNotMatch(systemPermissionSource, /人员与角色维护/, "personnel and role maintenance should be renamed");
assert.doesNotMatch(systemPermissionSource, /maintenanceSubTab/, "role maintenance should not keep nested personnel tabs");
assert.doesNotMatch(systemPermissionSource, /system-permission-sub-tabs/, "role maintenance should not render nested sub tabs");
assert.doesNotMatch(
systemPermissionSource,
/v-if="maintenanceSubTab === 'users'"/,
"standalone system users table should be removed from maintenance page"
);
assert.doesNotMatch(
systemPermissionSource,
/v-if="maintenanceSubTab === 'roles'"/,
"roles should render directly in the role maintenance tab"
);
assert.match(
systemPermissionSource,
/import PaginationBar from "\.\.\/components\/PaginationBar\.vue";/,
"page should use global PaginationBar"
);
assert.match(
systemPermissionSource,
/import \{ usePagination \} from "\.\.\/utils\/pagination";/,
"page should use global pagination helper"
);
assert.match(systemPermissionSource, /paginatedRoles/, "roles table should use paginated rows");
assert.match(systemPermissionSource, /<table class="data-table compact-table">/, "role maintenance should use global table styling");
assert.doesNotMatch(
systemPermissionSource,
/class="maintenance-grid"/,
"maintenance page should not use the old side-by-side card grid"
);
assert.doesNotMatch(
systemPermissionSource,
/<ul class="permission-list">[\s\S]*v-for="role in roles"/,
"roles should not render as old card list"
);
assert.doesNotMatch(
systemPermissionSource,
/<ul class="permission-list">[\s\S]*v-for="user in users"/,
"users should not render as old card list"
);
assert.match(systemPermissionSource, /permissionMainTab/, "SystemPermissionView should keep top-level tabs separate");
assert.match(
systemPermissionSource,
/v-if="permissionMainTab === 'org'"/,
"organization mind map should live in its own top-level tab"
);
assert.match(
systemPermissionSource,
/v-if="permissionMainTab === 'maintenance'"/,
"role maintenance should live in a separate top-level tab"
);
assert.match(systemPermissionSource, /orgViewMode/, "organization tab should keep organization view mode state");
assert.match(systemPermissionSource, /脑图模式/, "organization tab should expose mind-map mode switch");
assert.match(systemPermissionSource, /组织树模式/, "organization tab should expose organization-tree mode switch");
assert.match(
systemPermissionSource,
/<OrgPermissionTree/,
"SystemPermissionView should render organization tree mode through OrgPermissionTree"
);
assert.match(
systemPermissionSource,
/showAuthorizeDrawer/,
"SystemPermissionView should expose a personnel authorization drawer"
);
assert.match(systemPermissionSource, /openAuthorizeDrawer/, "personnel authorization should open from org personnel nodes");
assert.match(systemPermissionSource, /submitAuthorizeUser/, "authorization drawer should save account and roles");
assert.match(
systemPermissionSource,
/putResource\(`\/system-permissions\/users\/\$\{[^`]+\.user_id\}\/roles`/,
"existing users should update roles"
);
assert.match(systemPermissionSource, /postResource\("\/system-permissions\/users"/, "missing users should be created from authorization drawer");
assert.doesNotMatch(systemPermissionSource, /function openUserDrawer/, "standalone add-user drawer should be removed");
assert.doesNotMatch(systemPermissionSource, /function submitCreateUser/, "standalone add-user submit should be removed");
assert.doesNotMatch(systemPermissionSource, /paginatedUsers/, "standalone users table pagination should be removed");
assert.doesNotMatch(systemPermissionSource, /reset-password/, "standalone password reset action should not be exposed on this page");
assert.match(systemPermissionSource, /角色维护/, "SystemPermissionView should expose role maintenance");
assert.match(systemPermissionSource, /人员[\s\S]*电话号码[\s\S]*账号/, "SystemPermissionView should explain employee phone username mapping");
assert.match(systemPermissionSource, /密码/, "SystemPermissionView should mention password");
assert.match(systemPermissionSource, /最少6位/, "SystemPermissionView should mention the password length rule");
assert.match(systemPermissionSource, /employee-options/, "page must load employee options from backend");
assert.match(systemPermissionSource, /role_ids/, "page must submit role ids");
assert.match(systemPermissionSource, /\/system-permissions\/users/, "page must create system users through backend");
assert.match(systemPermissionSource, /menu-permission-tree/, "page must load menu permission tree");
assert.match(systemPermissionSource, /permission_codes/, "role form must submit permission codes");
assert.match(systemPermissionSource, /function collectPermissionCodes/, "role form must collect parent and child permission codes");
assert.match(systemPermissionSource, /function collectPersistablePermissionCodes/, "role form must collect only persistable menu permission codes");
assert.match(systemPermissionSource, /function togglePermissionNode/, "role form must toggle permission tree nodes");
assert.match(systemPermissionSource, /function isPermissionIndeterminate/, "role form must show partial permission tree selection");
assert.match(systemPermissionSource, /openOrgDrawer/, "page must support adding or editing org nodes");
assert.match(systemPermissionSource, /\/system-permissions\/org-nodes/, "page must persist org nodes through backend");
assert.match(systemPermissionSource, /node_type/, "org form must capture node type");
assert.match(systemPermissionSource, /主管/, "org form must support manager binding");
assert.match(systemPermissionSource, /manager_employee_ids/, "org form must submit multiple manager ids");
assert.match(systemPermissionSource, /manager-option-grid/, "manager selector should use clickable cards instead of a native multi select");
assert.match(systemPermissionSource, /selected-manager-tags/, "manager selector should show selected managers as tags");
assert.match(systemPermissionSource, /function toggleOrgManager/, "manager selector should toggle managers without keyboard modifiers");
assert.match(systemPermissionSource, /managerSearchKeyword/, "manager selector should keep a search keyword");
assert.match(systemPermissionSource, /filteredManagerOptions/, "manager selector should filter employees before rendering options");
assert.match(systemPermissionSource, /搜索主管姓名或手机号/, "manager selector should expose a Chinese search placeholder");
assert.match(
systemPermissionSource,
/v-for="employee in filteredManagerOptions"/,
"manager selector cards should render filtered manager options"
);
assert.doesNotMatch(
systemPermissionSource,
/<select v-model="orgForm\.manager_employee_ids" multiple>/,
"manager selector should not rely on native multi-select keyboard behavior"
);
assert.match(systemPermissionSource, /多位主管/, "manager drawer should explain multiple manager binding");
assert.match(systemPermissionSource, /contextMenu/, "page must expose a right-click context menu for mind-map nodes");
assert.match(systemPermissionSource, /@node-contextmenu/, "page must listen for node context menu events");
assert.match(systemPermissionSource, /重命名/, "context menu should support renaming organization nodes");
assert.match(systemPermissionSource, /新增分公司节点/, "company context menu should support adding branch nodes");
assert.match(systemPermissionSource, /新增部门节点/, "branch context menu should support adding department nodes");
assert.match(systemPermissionSource, /新增小组节点/, "department context menu should support adding group nodes");
assert.match(systemPermissionSource, /新增人员/, "department and group context menu should support adding employee leaves");
assert.match(systemPermissionSource, /添加该节点的主管/, "context menu should support manager binding");
assert.match(systemPermissionSource, /deleteResource/, "page must call backend delete endpoints for org actions");
assert.match(systemPermissionSource, /\/employees/, "page must call backend employee binding endpoints");
assert.match(systemPermissionSource, /员工节点默认收拢/, "page should document collapsed employee leaves");
assert.doesNotMatch(systemPermissionSource, /class="org-node-detail"/, "mind-map node operations must not stay in a persistent bottom bar");
assert.match(systemPermissionSource, /mindmap-toolbar/, "mind-map should expose a top-right toolbar");
assert.match(systemPermissionSource, /放大/, "mind-map toolbar should support zooming in");
assert.match(systemPermissionSource, /缩小/, "mind-map toolbar should support zooming out");
assert.match(systemPermissionSource, /刷新/, "mind-map toolbar should support refreshing data");
assert.match(systemPermissionSource, /一屏展示/, "mind-map toolbar should expose a separate one-screen fit button");
assert.match(systemPermissionSource, /mindMapScale/, "page should keep mind-map zoom state");
assert.match(systemPermissionSource, /refreshMindMap/, "page should refresh mind-map data from toolbar");
assert.match(systemPermissionSource, /fitMindMapToScreen/, "page should separate one-screen fitting from refreshing");
assert.match(systemPermissionSource, /mindMapFitRequestKey/, "page should request one-screen mind-map fitting");
assert.match(
systemPermissionSource,
/:fit-request-key="mindMapFitRequestKey"/,
"SystemPermissionView should pass fit request key to OrgMindMap"
);
assert.doesNotMatch(
systemPermissionSource,
/@zoom-scale=/,
"SystemPermissionView should not accept mouse-wheel zoom from OrgMindMap"
);
assert.match(
systemPermissionSource,
/@fit-scale="applyMindMapFitScale"/,
"SystemPermissionView should accept fit scale from OrgMindMap"
);
assert.match(
systemPermissionSource,
/function applyMindMapFitScale/,
"SystemPermissionView should apply one-screen fit scale"
);
assert.doesNotMatch(
systemPermissionSource,
/:key="mindMapRenderKey"/,
"mind-map refresh must not recreate OrgMindMap because that collapses expanded employees"
);
const refreshMindMapMatch = systemPermissionSource.match(/async function refreshMindMap\(\)\s*\{([\s\S]*?)\n\}/);
assert.ok(refreshMindMapMatch, "refreshMindMap function block must be parseable");
assert.doesNotMatch(
refreshMindMapMatch[1],
/mindMapFitRequestKey\.value \+= 1/,
"refresh must not request one-screen fit; one-screen fit is a separate user action"
);
assert.doesNotMatch(
refreshMindMapMatch[1],
/mindMapRenderKey\.value \+= 1/,
"refresh must not recreate mind-map component"
);
assert.match(
systemPermissionSource,
/function fitMindMapToScreen\(\)[\s\S]*mindMapFitRequestKey\.value \+= 1/,
"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 createPayload\s*=/, "org create must build a create payload");
const updatePayloadMatch = systemPermissionSource.match(/const updatePayload\s*=\s*\{([\s\S]*?)\n\s*\};/);
assert.ok(updatePayloadMatch, "org edit updatePayload block must be parseable");
assert.doesNotMatch(
updatePayloadMatch[1],
/parent_id|node_type|dept_code/,
"org edit updatePayload must exclude create-only fields"
);
assert.match(
systemPermissionSource,
/putResource\(`\/system-permissions\/org-nodes\/\$\{editingOrgNode\.value\.node_id\}`,\s*updatePayload\)/,
"org edit PUT must only send the update payload"
);
assert.match(
systemPermissionSource,
/postResource\("\/system-permissions\/org-nodes",\s*createPayload\)/,
"org create POST must send the create payload"
);
assert.match(
systemPermissionSource,
/@change="togglePermissionNode\(group, \$event\.target\.checked\)"/,
"parent permission checkbox must toggle descendants"
);
const persistablePermissionCodesMatch = systemPermissionSource.match(/function collectPersistablePermissionCodes\(node\)\s*\{([\s\S]*?)\n\}/);
assert.ok(persistablePermissionCodesMatch, "collectPersistablePermissionCodes function block must be parseable");
assert.match(
persistablePermissionCodesMatch[1],
/if \(children\.length\)[\s\S]*return children\.flatMap\(\(child\) => collectPersistablePermissionCodes\(child\)\)/,
"group-only parent permission codes must be skipped in favor of child menu permissions"
);
assert.match(
systemPermissionSource,
/for \(const code of collectPersistablePermissionCodes\(node\)\)/,
"permission toggling must persist only real menu permission codes"
);
assert.match(
systemPermissionSource,
/if \(!authorizingEmployee\.value\?\.employee_id\)[\s\S]*notifyError\("请选择有效人员"\)/,
"authorization submit must reject missing employee context"
);
assert.match(
systemPermissionSource,
/if \(!authorizingEmployeePhone\.value\)[\s\S]*notifyError\("该人员没有电话号码,不能作为账号"\)/,
"authorization submit must reject employees without a phone account"
);
assert.doesNotMatch(
systemPermissionSource,
/window\.alert\("新增系统使用人员抽屉将在实现任务中接入/,
"temporary user alert must be removed"
);
assert.doesNotMatch(
systemPermissionSource,
/window\.alert\("新增角色抽屉将在实现任务中接入/,
"temporary role alert must be removed"
);
assert.match(orgMindMapSource, /defineProps/, "OrgMindMap should use defineProps");
assert.match(orgMindMapSource, /orthogonal/i, "OrgMindMap should name orthogonal connector logic");
assert.match(orgMindMapSource, /collapsedEmployeeNodeIds/, "OrgMindMap should default employee leaves to collapsed");
assert.match(orgMindMapSource, /toggle-employee-leaves/, "OrgMindMap should emit employee leaf expand or collapse events");
assert.match(orgMindMapSource, /employee_count/, "OrgMindMap should display employee count on parent org cards");
assert.match(orgMindMapSource, /node-contextmenu/, "OrgMindMap should emit right-click context menu events");
assert.match(orgMindMapSource, /manager_names/, "OrgMindMap should display multiple manager names");
assert.match(orgMindMapSource, /zoomScale/, "OrgMindMap should accept a zoom scale prop");
assert.match(orgMindMapSource, /fitRequestKey/, "OrgMindMap should accept fit request key");
assert.match(orgMindMapSource, /fit-scale/, "OrgMindMap should emit fit scale");
assert.match(orgMindMapSource, /mindMapViewport/, "OrgMindMap should keep a viewport ref for canvas panning");
assert.match(orgMindMapSource, /isCanvasPanning/, "OrgMindMap should track canvas panning state");
assert.match(orgMindMapSource, /startCanvasPan/, "OrgMindMap should start canvas panning from empty canvas space");
assert.match(orgMindMapSource, /moveCanvasPan/, "OrgMindMap should move the viewport while panning");
assert.match(orgMindMapSource, /endCanvasPan/, "OrgMindMap should stop canvas panning");
assert.doesNotMatch(orgMindMapSource, /@wheel/, "OrgMindMap should not bind mouse-wheel zooming");
assert.doesNotMatch(orgMindMapSource, /function handleCanvasWheel/, "OrgMindMap should remove mouse-wheel zoom handling");
assert.doesNotMatch(orgMindMapSource, /WHEEL_ZOOM_STEP/, "OrgMindMap should not keep mouse-wheel zoom step configuration");
assert.doesNotMatch(orgMindMapSource, /emit\("zoom-scale"/, "OrgMindMap should not emit wheel zoom scale to parent");
assert.match(orgMindMapSource, /fitCanvasToViewport/, "OrgMindMap should fit canvas to visible viewport");
assert.match(
orgMindMapSource,
/window\.addEventListener\("mousemove", moveCanvasPan\)/,
"OrgMindMap should pan canvas through window mousemove listener"
);
assert.match(
orgMindMapSource,
/viewport\.scrollTop = canvasPan\.value\.scrollTop - deltaY/,
"OrgMindMap should support vertical canvas panning in addition to horizontal panning"
);
assert.match(
orgMindMapSource,
/window\.removeEventListener\("mousemove", moveCanvasPan\)/,
"OrgMindMap should clean up canvas pan listener"
);
assert.match(
orgMindMapSource,
/closest\?\.\("\.org-mind-map-node"\)/,
"canvas panning should not start from node cards"
);
assert.match(
orgMindMapSource,
/defineExpose/,
"OrgMindMap should expose fit helper for explicit refresh handling if needed"
);
assert.match(orgMindMapSource, /scaledCanvasStyle/, "OrgMindMap should size the canvas according to zoom scale");
assert.match(orgMindMapSource, /overscroll-behavior: contain;/, "OrgMindMap should keep drag interaction inside the canvas viewport");
assert.match(orgMindMapSource, /NODE_MIN_WIDTH/, "OrgMindMap should have a minimum node width");
assert.match(orgMindMapSource, /NODE_MAX_WIDTH/, "OrgMindMap should have a maximum node width");
assert.match(orgMindMapSource, /function estimateTextWidth/, "OrgMindMap should estimate text width before laying out cards");
assert.match(orgMindMapSource, /function computeNodeWidth/, "OrgMindMap should compute node width from text length");
assert.match(orgMindMapSource, /function fitTextToWidth/, "OrgMindMap should safely fit very long text into cards");
assert.match(orgMindMapSource, /:width="node\.width"/, "OrgMindMap node card width should use computed per-node width");
assert.match(
orgMindMapSource,
/parent\.x \+ parent\.width/,
"connector layout should use each node's computed width"
);
assert.match(orgMindMapSource, /function measureSubtree/, "mind map must measure subtree leaf spans before layout");
assert.match(orgMindMapSource, /function layoutTree/, "mind map must use deterministic tree layout");
assert.match(orgMindMapSource, /connectorSegments/, "mind map must deduplicate connector segments");
assert.match(orgMindMapSource, /COLUMN_GAP/, "mind map must reserve a column gutter for connector lines");
assert.match(orgMindMapSource, /NODE_MAX_WIDTH/, "mind map must know maximum node width for line avoidance");
assert.match(
orgMindMapSource,
/H \$\{connector\.midX\} V \$\{connector\.toY\} H \$\{connector\.toX\}/,
"OrgMindMap should render H/V/H orthogonal connector paths"
);
assert.doesNotMatch(orgMindMapSource, /draggable="true"/, "OrgMindMap should not mark cards draggable");
assert.doesNotMatch(
orgMindMapSource,
/@(drag|dragstart|dragend|pointermove|mousemove)\b/,
"OrgMindMap should not bind free-drag handlers"
);
assert.doesNotMatch(
orgMindMapSource,
/<path[^>]*\sd="[^"]*[CQ]/i,
"OrgMindMap should not render curved SVG path commands"
);
assert.match(orgPermissionTreeSource, /组织树/, "OrgPermissionTree should render organization tree UI");
assert.match(orgPermissionTreeSource, /onContextmenu/, "OrgPermissionTree should support right-click org node actions");
assert.match(orgPermissionTreeSource, /授权/, "OrgPermissionTree should expose personnel authorization action");
assert.match(orgPermissionTreeSource, /移除/, "OrgPermissionTree should expose personnel removal action");
assert.match(orgPermissionTreeSource, /新增人员/, "OrgPermissionTree should expose add-person action for selected org node");
assert.match(orgPermissionTreeSource, /PaginationBar/, "OrgPermissionTree should use global pagination bar");
assert.match(orgPermissionTreeSource, /搜索人员姓名或手机号/, "OrgPermissionTree should support personnel search");
console.log("system permission management UI static assertions passed");
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const checks = [
{
label: "仓库页面",
file: path.join(root, "src/views/InventoryLedgerView.vue")
},
{
label: "发货台账页面",
file: path.join(root, "src/views/DeliveryManagementView.vue")
},
{
label: "物流校验服务",
file: path.join(root, "../backend/app/services/logistics.py")
},
{
label: "仓库照片上传接口",
file: path.join(root, "../backend/app/api/routes/inventory.py")
}
];
for (const check of checks) {
const source = fs.readFileSync(check.file, "utf8");
assert.doesNotMatch(source, /订单照片/, `${check.label}不应再显示“订单照片”`);
assert.match(source, /辅助照片/, `${check.label}应显示“辅助照片”`);
}
for (const relativePath of [
"src",
"../backend/app"
]) {
const findCommand = `rg -n "订单照片" ${JSON.stringify(path.join(root, relativePath))} --glob '!dist/**' --glob '!**/node_modules/**'`;
const { execSync } = await import("node:child_process");
let output = "";
try {
output = execSync(findCommand, { encoding: "utf8" });
} catch (error) {
output = error.status === 1 ? "" : String(error.stdout || error.message || "");
}
assert.equal(output.trim(), "", `系统源码不应再残留“订单照片”:${output}`);
}
console.log("auxiliary photo label checks passed");
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const styles = fs.readFileSync(path.join(root, "src/styles/main.css"), "utf8");
const inventory = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8");
function blockFor(selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = styles.match(new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`));
assert.ok(match, `missing style block for ${selector}`);
return match[1];
}
const workflowTableWrap = blockFor(".workflow-drawer .table-wrap");
assert.match(
workflowTableWrap,
/overflow-x:\s*auto/,
"drawer table wrappers must allow horizontal scrolling instead of clipping overflow"
);
assert.doesNotMatch(
workflowTableWrap,
/overflow-x:\s*hidden/,
"drawer table wrappers must not hide overflowing columns"
);
const drawerDataTableWrap = blockFor(".drawer-data-table-wrap");
assert.match(
drawerDataTableWrap,
/overflow-x:\s*auto/,
"DrawerDataTable wrapper must allow horizontal scrolling"
);
assert.doesNotMatch(
drawerDataTableWrap,
/overflow:\s*hidden|overflow-x:\s*hidden/,
"DrawerDataTable wrapper must not clip columns"
);
const workflowTable = blockFor(".workflow-drawer .data-table");
assert.match(
workflowTable,
/width:\s*max-content/,
"drawer tables should size to their columns when wider than the drawer"
);
assert.match(
workflowTable,
/min-width:\s*100%/,
"drawer tables should still fill the drawer when columns are narrow"
);
assert.match(
workflowTable,
/table-layout:\s*auto/,
"drawer tables should not use fixed layout that squeezes headers and cells"
);
const drawerDataTable = blockFor(".drawer-data-table");
assert.match(
drawerDataTable,
/width:\s*max-content/,
"DrawerDataTable should size to column widths when needed"
);
assert.match(
drawerDataTable,
/min-width:\s*100%/,
"DrawerDataTable should still fill the available drawer width"
);
assert.match(
drawerDataTable,
/table-layout:\s*auto/,
"DrawerDataTable should not use fixed layout that cuts off trailing columns"
);
assert.match(
inventory,
/<DrawerDataTable[\s\S]*:columns="lotDrawerColumns"/,
"warehouse lot details should use DrawerDataTable and benefit from the overflow fix"
);
assert.match(
inventory,
/<DrawerDataTable[\s\S]*:columns="txnDrawerColumns"/,
"warehouse transaction details should use DrawerDataTable and benefit from the overflow fix"
);
console.log("warehouse drawer table overflow checks passed");