diff --git a/client/tests/e2e/assets-flow.spec.ts b/client/tests/e2e/assets-flow.spec.ts new file mode 100644 index 00000000..c5949c7d --- /dev/null +++ b/client/tests/e2e/assets-flow.spec.ts @@ -0,0 +1,143 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +const FINANCE_MGR = { identifier: "finance_mgr@hyinda.com", password: "FinanceMgrPassword1234", name: "Finance Manager" }; +const ADMINISTRATION = { identifier: "administration@hyinda.com", password: "AdministrationPassword1234", name: "Administration" }; + +async function login(page: Page, user: { identifier: string; password: string }) { + await page.goto("/login"); + await page.getByTestId("login-identifier").fill(user.identifier); + await page.getByTestId("login-password").fill(user.password); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/dashboard/); +} + +async function logout(page: Page, userDisplayName: string) { + await page.getByRole("button", { name: userDisplayName }).click(); + await page.getByRole("menuitem", { name: "退出登录" }).click(); + await expect(page).toHaveURL(/\/login/); +} + + +async function selectOptionByText(locator: Locator, pattern: RegExp) { + await expect + .poll(async () => { + return locator.locator("option").evaluateAll((options, rawPattern) => { + const regexp = new RegExp(rawPattern as string); + return options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + label: (option as HTMLOptionElement).label, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled && regexp.test(option.label)) + .map((option) => option.value)[0] ?? ""; + }, pattern.source); + }) + .not.toBe(""); + + const optionValue = await locator.locator("option").evaluateAll((options, rawPattern) => { + const regexp = new RegExp(rawPattern as string); + return options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + label: (option as HTMLOptionElement).label, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled && regexp.test(option.label)) + .map((option) => option.value)[0] ?? ""; + }, pattern.source); + + await locator.selectOption(optionValue); +} + +async function selectFirstBusinessOption(locator: Locator) { + await expect + .poll(async () => { + const optionValues = await locator.locator("option").evaluateAll((options) => + options + .map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled })) + .filter((option) => option.value && !option.disabled) + .map((option) => option.value), + ); + return optionValues[0] ?? ""; + }) + .not.toBe(""); + + const optionValues = await locator.locator("option").evaluateAll((options) => + options + .map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled })) + .filter((option) => option.value && !option.disabled) + .map((option) => option.value), + ); + + await locator.selectOption(optionValues[0]); +} + +test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", async ({ page }) => { + const suffix = Date.now(); + const categoryCode = `PW_ASSET_${suffix}`; + const categoryName = `Playwright资产分类${suffix}`; + const assetName = `Playwright资产卡片${suffix}`; + + await login(page, FINANCE_MGR); + await page.getByRole("link", { name: "资产台账" }).click(); + + await page.getByTestId("asset-category-reset").click(); + await page.getByTestId("asset-category-code").fill(categoryCode); + await page.getByTestId("asset-category-name").fill(categoryName); + await page.getByTestId("asset-category-save").click(); + + await page.getByTestId("asset-create").click(); + await page.getByTestId("asset-name").fill(assetName); + await page.getByTestId("asset-category").selectOption({ label: categoryName }); + await selectFirstBusinessOption(page.getByTestId("asset-legal-entity")); + await selectFirstBusinessOption(page.getByTestId("asset-cost-center")); + await page.getByTestId("asset-location").fill("深圳办公室 A-01"); + await page.getByTestId("asset-save").click(); + + const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first(); + await expect(assetRow).toBeVisible({ timeout: 30_000 }); + + await logout(page, FINANCE_MGR.name); + + await login(page, ADMINISTRATION); + await page.getByRole("link", { name: "资产台账" }).click(); + await page.getByTestId("asset-row").filter({ hasText: assetName }).first().click(); + + await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/); + await page.getByTestId("asset-movement-note").fill("Playwright 首次领用"); + await page.getByTestId("asset-assign-submit").click(); + await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId("asset-return-submit").click(); + await expect(page.getByText(/当前归属人:未分配/)).toBeVisible({ timeout: 30_000 }); + + await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/); + await page.getByTestId("asset-assign-submit").click(); + await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 }); + + await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Finance Manager/); + await page.getByTestId("asset-movement-location").fill("深圳办公室 B-02"); + await page.getByTestId("asset-transfer-submit").click(); + await expect(page.getByText(/当前归属人:Finance Manager/)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/位置:深圳办公室 B-02/)).toBeVisible({ timeout: 30_000 }); + + await logout(page, ADMINISTRATION.name); + + await login(page, FINANCE_MGR); + await page.getByRole("link", { name: "资产盘点" }).click(); + await page.getByTestId("inventory-create").click(); + + const inventoryRow = page.getByTestId("inventory-row").first(); + await expect(inventoryRow).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("inventory-start").click(); + await selectOptionByText(page.getByTestId("inventory-item-asset"), new RegExp(assetName)); + await page.getByTestId("inventory-item-result").selectOption("matched"); + await page.getByTestId("inventory-item-note").fill("Playwright 盘点一致"); + await page.getByTestId("inventory-item-save").click(); + await expect(page.getByRole("row").filter({ hasText: assetName }).last()).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("inventory-complete").click(); + await expect(page.locator(".card-std").nth(1).getByText("已完成", { exact: true })).toBeVisible({ timeout: 30_000 }); + + await logout(page, FINANCE_MGR.name); +}); diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 1fd271c6..95900e93 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -1,8 +1,8 @@ -# hyb-oa 实现状态矩阵(P5-P18A) +# hyb-oa 实现状态矩阵(P5-P18B) **快照日期**:2026-03-07 -**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6`,共 178 项。 -**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv`(执行合同) +**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6` + `P18B-0-P18B-6`,共 185 项。 +**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv`(执行合同) **说明**: - 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。 @@ -205,3 +205,12 @@ | P18A-4 | 资产台账前端入口与页面 | N/A | 2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv | 2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv | 2026-03-07 | DONE | client:typecheck/build | `AssetRegistryPage`、菜单、路由与资产分类/卡片编辑界面可用 | | P18A-5 | OpenAPI与矩阵同步 | N/A | 2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv | 2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv | 2026-03-07 | DONE | node --check + validate-feature-matrix | 资产接口文档标签、合同锚点与矩阵事实源同步完成 | | P18A-6 | P18A回归与验收收口 | N/A | 2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv | 2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv | 2026-03-07 | DONE | regression-all + client:typecheck/build | 资产分类、资产卡片与既有报销/请假/采购/主数据回归全部通过 | + +| P18B-0 | P18B执行合同建档 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | CSV 校验 | 资产流转与盘点执行合同建档并冻结不引入资产审批流 | +| P18B-1 | 资产领用归还模型与接口 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | server:test + regression-assets | `AssetAssignment` 与 `/api/assets/:id/assign|return` 落地,支持领用归还再领用并保留不可变历史 | +| P18B-2 | 资产调拨模型与接口 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | server:test + regression-assets | `AssetTransfer` 与 `/api/assets/:id/transfer` 落地,调拨时同步归属人与位置并维护 assignment 开闭 | +| P18B-3 | 资产盘点模型与接口 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | server:test + regression-assets | `InventoryCheck`/`InventoryCheckItem` 与创建启动录入完成接口可用,盘点结果按资产 upsert | +| P18B-4 | 资产流转前端与盘点页 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | client:typecheck/build + playwright | `AssetRegistryPage` 增加领用归还调拨入口,新增 `InventoryChecksPage`、菜单与路由 | +| P18B-5 | 安全文档与审计动作同步 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | validate-feature-matrix + regression-assets | 审计动作码 `ASSET_*` 与 `INVENTORY_*` 链路补齐,矩阵事实源与资产回归脚本同步完成 | +| P18B-6 | P18B回归与安全收口 | N/A | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | 2026-03-07 | DONE | regression-all + client:typecheck/build + playwright | 资产领用归还调拨盘点闭环通过,且报销/请假/主数据/采购/资产台账零回归 | + diff --git a/issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv b/issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv index 14836336..3ab45a45 100644 --- a/issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv +++ b/issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv @@ -1,8 +1,8 @@ -ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes -P18B-0,P18B执行合同建档,创建并校验P18B资产流转与盘点执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv,manual,TODO,TODO,TODO,issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv,[],P18B仅交付资产领用归还调拨与盘点不引入资产审批流 -P18B-1,资产领用归还模型与接口,新增AssetAssignment并实现资产领用归还接口与审计记录,可完成领用归还再领用且历史记录可追踪,cd server && npm run test,manual,TODO,TODO,TODO,server/prisma/schema.prisma | server/src/routes/assets.js | server/tests,"[""P18B-0""]",assign与return均需校验asset.transfer权限并维护资产当前持有人 -P18B-2,资产调拨模型与接口,新增AssetTransfer并实现员工与位置调拨接口和状态同步,调拨后归属人与位置正确更新且调拨历史可审计,cd server && npm run test,manual,TODO,TODO,TODO,server/prisma/schema.prisma | server/src/routes/assets.js | server/tests,"[""P18B-1""]",仅改位置时不创建新assignment 改归属人时自动关闭旧assignment并新建open assignment -P18B-3,资产盘点模型与接口,新增InventoryCheck与InventoryCheckItem并实现创建启动记录完成接口,盘点可按范围启动 录入结果 并完成状态流转,cd server && npm run test,manual,TODO,TODO,TODO,server/prisma/schema.prisma | server/src/routes/inventoryChecks.js | server/tests,"[""P18B-2""]",盘点start不预生成空items 结果通过POST items按asset upsert -P18B-4,资产流转前端与盘点页,扩展AssetRegistryPage动作入口并新增InventoryChecksPage路由菜单,资产可在前端执行领用归还调拨且盘点页面可创建启动录入完成,cd client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,client/src/pages/AssetRegistryPage.tsx | client/src/pages/InventoryChecksPage.tsx | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""P18B-3""]",不新增独立审批页面 资产流转保持受控操作 -P18B-5,安全文档与审计动作同步,补齐资产流转盘点审计动作码 OpenAPI标签 合同锚点与矩阵事实源,审计动作码可见且矩阵事实源与合同锚点一致,cd server && npm run test && python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,server/src/routes/assets.js | server/src/routes/inventoryChecks.js | docs/feature-matrix.md | issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv,"[""P18B-4""]",固定动作码ASSET_ASSIGNED ASSET_RETURNED ASSET_TRANSFERRED INVENTORY_CHECK_CREATED INVENTORY_CHECK_STARTED INVENTORY_ITEM_RECORDED INVENTORY_CHECK_COMPLETED -P18B-6,P18B回归与安全收口,执行后端前端回归 E2E与对象越权校验确认资产流转盘点和既有主流程零回归,资产领用归还调拨盘点闭环可用且报销请假采购主数据资产台账零回归,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | server/src/routes/assets.js | server/src/routes/inventoryChecks.js | client/src/pages/InventoryChecksPage.tsx,"[""P18B-5""]",P18B合并前需完成资产对象越权与盘点范围越权安全复核 +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +P18B-0,P18B执行合同建档,创建并校验P18B资产流转与盘点执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv,manual,DONE,DONE,DONE,issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv,[],P18B仅交付资产领用归还调拨与盘点不引入资产审批流 +P18B-1,资产领用归还模型与接口,新增AssetAssignment并实现资产领用归还接口与审计记录,可完成领用归还再领用且历史记录可追踪,cd server && npm run test,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/src/routes/assets.js | server/tests,"[""P18B-0""]",assign与return均需校验asset.transfer权限并维护资产当前持有人 +P18B-2,资产调拨模型与接口,新增AssetTransfer并实现员工与位置调拨接口和状态同步,调拨后归属人与位置正确更新且调拨历史可审计,cd server && npm run test,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/src/routes/assets.js | server/tests,"[""P18B-1""]",仅改位置时不创建新assignment 改归属人时自动关闭旧assignment并新建open assignment +P18B-3,资产盘点模型与接口,新增InventoryCheck与InventoryCheckItem并实现创建启动记录完成接口,盘点可按范围启动 录入结果 并完成状态流转,cd server && npm run test,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/src/routes/inventoryChecks.js | server/tests,"[""P18B-2""]",盘点start不预生成空items 结果通过POST items按asset upsert +P18B-4,资产流转前端与盘点页,扩展AssetRegistryPage动作入口并新增InventoryChecksPage路由菜单,资产可在前端执行领用归还调拨且盘点页面可创建启动录入完成,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/AssetRegistryPage.tsx | client/src/pages/InventoryChecksPage.tsx | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""P18B-3""]",不新增独立审批页面 资产流转保持受控操作 +P18B-5,安全文档与审计动作同步,补齐资产流转盘点审计动作码 OpenAPI标签 合同锚点与矩阵事实源,审计动作码可见且矩阵事实源与合同锚点一致,cd server && npm run test && python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,server/src/routes/assets.js | server/src/routes/inventoryChecks.js | docs/feature-matrix.md | issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv,"[""P18B-4""]",固定动作码ASSET_ASSIGNED ASSET_RETURNED ASSET_TRANSFERRED INVENTORY_CHECK_CREATED INVENTORY_CHECK_STARTED INVENTORY_ITEM_RECORDED INVENTORY_CHECK_COMPLETED +P18B-6,P18B回归与安全收口,执行后端前端回归 E2E与对象越权校验确认资产流转盘点和既有主流程零回归,资产领用归还调拨盘点闭环可用且报销请假采购主数据资产台账零回归,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv | server/src/routes/assets.js | server/src/routes/inventoryChecks.js | client/src/pages/InventoryChecksPage.tsx,"[""P18B-5""]",P18B合并前需完成资产对象越权与盘点范围越权安全复核 diff --git a/scripts/validate-feature-matrix.py b/scripts/validate-feature-matrix.py index c6837f4e..b0b8da34 100755 --- a/scripts/validate-feature-matrix.py +++ b/scripts/validate-feature-matrix.py @@ -36,6 +36,7 @@ CSV_FACT_SOURCES = [ ROOT / "issues" / "2026-03-07_19-33-09-p17a-l5-procurement-core.csv", ROOT / "issues" / "2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv", ROOT / "issues" / "2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv", + ROOT / "issues" / "2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv", ] ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$") diff --git a/server/scripts/regression-assets.sh b/server/scripts/regression-assets.sh index 6f912df5..75649f2f 100755 --- a/server/scripts/regression-assets.sh +++ b/server/scripts/regression-assets.sh @@ -7,6 +7,8 @@ EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" +ADMINISTRATION_IDENTIFIER="${ADMINISTRATION_IDENTIFIER:-administration@hyinda.com}" +ADMINISTRATION_PASSWORD="${ADMINISTRATION_PASSWORD:-AdministrationPassword1234}" if ! command -v curl >/dev/null 2>&1; then echo "[ERROR] curl not found" >&2 @@ -110,6 +112,14 @@ const { createPrismaClient } = require('./src/prismaClient'); where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? 'employee@hyinda.com' }, select: { id: true, legal_entity_id: true }, }); + const financeMgr = await prisma.employee.findUnique({ + where: { email: process.env.SEED_FINANCE_MGR_EMAIL ?? 'finance_mgr@hyinda.com' }, + select: { id: true }, + }); + const administration = await prisma.employee.findUnique({ + where: { email: process.env.SEED_ADMINISTRATION_EMAIL ?? 'administration@hyinda.com' }, + select: { id: true }, + }); const costCenter = await prisma.costCenter.findFirst({ where: { legal_entity_id: employee.legal_entity_id, @@ -119,24 +129,6 @@ const { createPrismaClient } = require('./src/prismaClient'); orderBy: { created_at: 'asc' }, select: { id: true }, }); - const documentCategory = await prisma.documentCategory.findFirst({ - where: { - entity_type: 'asset_card', - enabled: true, - archived_at: null, - code: 'ASSET_CARD_DEFAULT', - }, - orderBy: { created_at: 'asc' }, - select: { id: true }, - }) ?? await prisma.documentCategory.findFirst({ - where: { - entity_type: 'asset_card', - enabled: true, - archived_at: null, - }, - orderBy: { created_at: 'asc' }, - select: { id: true }, - }); const approvedPurchase = await prisma.purchaseRequest.findFirst({ where: { status: 'approved' }, orderBy: { updated_at: 'desc' }, @@ -145,9 +137,10 @@ const { createPrismaClient } = require('./src/prismaClient'); process.stdout.write([ employee.id, + financeMgr.id, + administration.id, employee.legal_entity_id ?? '', costCenter?.id ?? '', - documentCategory?.id ?? '', approvedPurchase?.id ?? '', ].join('\n')); } finally { @@ -164,11 +157,11 @@ NODE assert_asset_state() { local asset_id="$1" local category_id="$2" - local employee_id="$3" - local purchase_request_id="$4" + local finance_mgr_id="$3" + local inventory_check_id="$4" - DATABASE_URL="$DATABASE_URL" node - "$asset_id" "$category_id" "$employee_id" "$purchase_request_id" <<'NODE' -const [assetId, categoryId, employeeId, purchaseRequestId] = process.argv.slice(2); + DATABASE_URL="$DATABASE_URL" node - "$asset_id" "$category_id" "$finance_mgr_id" "$inventory_check_id" <<'NODE' +const [assetId, categoryId, financeMgrId, inventoryCheckId] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { @@ -177,7 +170,7 @@ const { createPrismaClient } = require('./src/prismaClient'); try { const category = await prisma.assetCategory.findUnique({ where: { id: categoryId }, - select: { name: true, document_category: { select: { code: true } } }, + select: { name: true }, }); const asset = await prisma.assetCard.findUnique({ where: { id: assetId }, @@ -185,30 +178,88 @@ const { createPrismaClient } = require('./src/prismaClient'); asset_no: true, status: true, assignee_employee_id: true, - purchase_request_id: true, location: true, }, }); + const assignments = await prisma.assetAssignment.findMany({ + where: { asset_id: assetId }, + orderBy: { assigned_at: 'asc' }, + select: { employee_id: true, returned_at: true }, + }); + const transfers = await prisma.assetTransfer.findMany({ + where: { asset_id: assetId }, + select: { to_employee_id: true, to_location: true }, + }); + const inventoryCheck = await prisma.inventoryCheck.findUnique({ + where: { id: inventoryCheckId }, + select: { + status: true, + items: { + select: { asset_id: true, result: true }, + }, + }, + }); + const auditActions = await prisma.auditLog.findMany({ + where: { + target_id: { in: [assetId, inventoryCheckId] }, + action: { + in: [ + 'ASSET_ASSIGNED', + 'ASSET_RETURNED', + 'ASSET_TRANSFERRED', + 'INVENTORY_CHECK_CREATED', + 'INVENTORY_CHECK_STARTED', + 'INVENTORY_ITEM_RECORDED', + 'INVENTORY_CHECK_COMPLETED', + ], + }, + }, + select: { action: true }, + }); - if (!category || category.name !== '回归资产分类-已更新' || category.document_category?.code !== 'ASSET_CARD_DEFAULT') { + const openAssignments = assignments.filter((item) => item.returned_at == null); + const actionSet = new Set(auditActions.map((item) => item.action)); + + if (!category || category.name !== '回归资产分类-已更新') { console.error('[ERROR] asset category state mismatch', category); process.exit(1); } - if (!asset || !asset.asset_no.startsWith('ZC-') || asset.status !== 'in_use' || asset.assignee_employee_id !== employeeId) { + if (!asset || !asset.asset_no.startsWith('ZC-') || asset.status !== 'in_use' || asset.assignee_employee_id !== financeMgrId || asset.location !== '深圳办公室 B-02') { console.error('[ERROR] asset card state mismatch', asset); process.exit(1); } - if (purchaseRequestId && asset.purchase_request_id !== purchaseRequestId) { - console.error('[ERROR] asset purchase source mismatch', asset); + if (assignments.length < 3 || openAssignments.length !== 1 || openAssignments[0].employee_id !== financeMgrId) { + console.error('[ERROR] asset assignments state mismatch', assignments); process.exit(1); } - if (asset.location !== '深圳办公室 A-01') { - console.error('[ERROR] asset location mismatch', asset); + if (!transfers.length || transfers[0].to_employee_id !== financeMgrId || transfers[0].to_location !== '深圳办公室 B-02') { + console.error('[ERROR] asset transfer state mismatch', transfers); process.exit(1); } + + if (!inventoryCheck || inventoryCheck.status !== 'completed' || inventoryCheck.items.length !== 1 || inventoryCheck.items[0].asset_id !== assetId || inventoryCheck.items[0].result !== 'matched') { + console.error('[ERROR] inventory check state mismatch', inventoryCheck); + process.exit(1); + } + + const requiredActions = [ + 'ASSET_ASSIGNED', + 'ASSET_RETURNED', + 'ASSET_TRANSFERRED', + 'INVENTORY_CHECK_CREATED', + 'INVENTORY_CHECK_STARTED', + 'INVENTORY_ITEM_RECORDED', + 'INVENTORY_CHECK_COMPLETED', + ]; + for (const action of requiredActions) { + if (!actionSet.has(action)) { + console.error('[ERROR] missing audit action', action, auditActions); + process.exit(1); + } + } } finally { await prisma.$disconnect(); await pool.end(); @@ -220,64 +271,103 @@ const { createPrismaClient } = require('./src/prismaClient'); NODE } -echo "[1/8] login finance manager and employee" -FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" +echo "[1/10] login employee finance manager and administration" EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")" - +FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" +ADMINISTRATION_TOKEN="$(login_user "$ADMINISTRATION_IDENTIFIER" "$ADMINISTRATION_PASSWORD" "$TMP_DIR/administration-login.json")" mapfile -t FIXTURE_IDS < <(resolve_fixture_ids) EMPLOYEE_ID="${FIXTURE_IDS[0]}" -LEGAL_ENTITY_ID="${FIXTURE_IDS[1]}" -COST_CENTER_ID="${FIXTURE_IDS[2]}" -DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" -APPROVED_PURCHASE_ID="${FIXTURE_IDS[4]}" +FINANCE_MGR_ID="${FIXTURE_IDS[1]}" +ADMINISTRATION_ID="${FIXTURE_IDS[2]}" +LEGAL_ENTITY_ID="${FIXTURE_IDS[3]}" +COST_CENTER_ID="${FIXTURE_IDS[4]}" +APPROVED_PURCHASE_ID="${FIXTURE_IDS[5]}" -if [[ -z "$LEGAL_ENTITY_ID" || -z "$COST_CENTER_ID" || -z "$DOCUMENT_CATEGORY_ID" ]]; then - echo "[ERROR] missing asset fixtures" >&2 - exit 1 -fi +echo "[2/10] finance manager create and update asset category" +CATEGORY_CREATE_BODY="$TMP_DIR/category-create.json" +CATEGORY_CREATE_STATUS="$(api_json_auth POST "$BASE_URL/api/asset-categories" "$FINANCE_MGR_TOKEN" "$CATEGORY_CREATE_BODY" '{"code":"REG_ASSET_CAT_'"$(date +%s)'"'","name":"回归资产分类","enabled":true}')" +assert_status "$CATEGORY_CREATE_STATUS" "200" "create asset category" "$CATEGORY_CREATE_BODY" +CATEGORY_ID="$(jq -er '.data.assetCategory.id' "$CATEGORY_CREATE_BODY")" +CATEGORY_PATCH_BODY="$TMP_DIR/category-patch.json" +CATEGORY_PATCH_STATUS="$(api_json_auth PATCH "$BASE_URL/api/asset-categories/$CATEGORY_ID" "$FINANCE_MGR_TOKEN" "$CATEGORY_PATCH_BODY" '{"name":"回归资产分类-已更新"}')" +assert_status "$CATEGORY_PATCH_STATUS" "200" "patch asset category" "$CATEGORY_PATCH_BODY" -echo "[2/8] create asset category" -CATEGORY_CODE="ASSET_REG_$(date +%s)" -CREATE_CATEGORY_BODY="$(jq -nc --arg code "$CATEGORY_CODE" --arg dc "$DOCUMENT_CATEGORY_ID" '{code:$code,name:"回归资产分类",documentCategoryId:$dc,enabled:true}')" -create_category_status="$(api_json_auth POST "$BASE_URL/api/asset-categories" "$FINANCE_MGR_TOKEN" "$TMP_DIR/create-category.json" "$CREATE_CATEGORY_BODY")" -assert_status "$create_category_status" "200" "create asset category" "$TMP_DIR/create-category.json" -CATEGORY_ID="$(jq -er '.data.assetCategory.id' "$TMP_DIR/create-category.json")" -assert_jq "$TMP_DIR/create-category.json" '.data.assetCategory.documentCategory.code == "ASSET_CARD_DEFAULT"' "asset category document category" +echo "[3/10] finance manager creates asset card" +ASSET_CREATE_PAYLOAD="$(jq -n \ + --arg name "回归资产卡片 $(date +%s)" \ + --arg categoryId "$CATEGORY_ID" \ + --arg legalEntityId "$LEGAL_ENTITY_ID" \ + --arg costCenterId "$COST_CENTER_ID" \ + --arg purchaseRequestId "$APPROVED_PURCHASE_ID" \ + '{name:$name, categoryId:$categoryId, legalEntityId:$legalEntityId, costCenterId:$costCenterId, purchaseRequestId:($purchaseRequestId|select(length>0)), location:"深圳办公室 A-01", status:"in_stock"}')" +ASSET_CREATE_BODY="$TMP_DIR/asset-create.json" +ASSET_CREATE_STATUS="$(api_json_auth POST "$BASE_URL/api/assets" "$FINANCE_MGR_TOKEN" "$ASSET_CREATE_BODY" "$ASSET_CREATE_PAYLOAD")" +assert_status "$ASSET_CREATE_STATUS" "200" "create asset card" "$ASSET_CREATE_BODY" +ASSET_ID="$(jq -er '.data.asset.id' "$ASSET_CREATE_BODY")" -echo "[3/8] update asset category" -PATCH_CATEGORY_BODY='{"name":"回归资产分类-已更新","enabled":true}' -patch_category_status="$(api_json_auth PATCH "$BASE_URL/api/asset-categories/$CATEGORY_ID" "$FINANCE_MGR_TOKEN" "$TMP_DIR/patch-category.json" "$PATCH_CATEGORY_BODY")" -assert_status "$patch_category_status" "200" "patch asset category" "$TMP_DIR/patch-category.json" -assert_jq "$TMP_DIR/patch-category.json" '.data.assetCategory.name == "回归资产分类-已更新"' "patched asset category name" -echo "[4/8] create asset card with approved purchase source" -CREATE_ASSET_BODY="$(jq -nc --arg name "回归资产卡片" --arg categoryId "$CATEGORY_ID" --arg legalEntityId "$LEGAL_ENTITY_ID" --arg costCenterId "$COST_CENTER_ID" --arg purchaseRequestId "$APPROVED_PURCHASE_ID" --arg assigneeEmployeeId "$EMPLOYEE_ID" '{name:$name,categoryId:$categoryId,legalEntityId:$legalEntityId,costCenterId:$costCenterId,purchaseRequestId:($purchaseRequestId|select(length>0)),status:"in_stock",assigneeEmployeeId:$assigneeEmployeeId}')" -create_asset_status="$(api_json_auth POST "$BASE_URL/api/assets" "$FINANCE_MGR_TOKEN" "$TMP_DIR/create-asset.json" "$CREATE_ASSET_BODY")" -assert_status "$create_asset_status" "200" "create asset" "$TMP_DIR/create-asset.json" -ASSET_ID="$(jq -er '.data.asset.id' "$TMP_DIR/create-asset.json")" -assert_jq "$TMP_DIR/create-asset.json" '.data.asset.assetNo | test("^ZC-")' "asset no generated" -assert_jq "$TMP_DIR/create-asset.json" '.data.asset.assignee.employeeNo == "EMP001" or .data.asset.assignee.name == "Employee"' "asset assignee set" +echo "[4/10] direct assignee patch blocked to enforce controlled transfer" +ASSET_PATCH_BODY="$TMP_DIR/asset-patch-invalid.json" +ASSET_PATCH_STATUS="$(api_json_auth PATCH "$BASE_URL/api/assets/$ASSET_ID" "$FINANCE_MGR_TOKEN" "$ASSET_PATCH_BODY" '{"assigneeEmployeeId":"'"$EMPLOYEE_ID"'"}')" +assert_status "$ASSET_PATCH_STATUS" "400" "patch asset assignee invalid" "$ASSET_PATCH_BODY" +assert_jq "$ASSET_PATCH_BODY" '.error.code == "VALID_001"' "patch asset assignee invalid code" -echo "[5/8] employee can only see own assigned asset" -list_own_status="$(api_json_auth GET "$BASE_URL/api/assets?mine=1" "$EMPLOYEE_TOKEN" "$TMP_DIR/list-own.json")" -assert_status "$list_own_status" "200" "employee list own assets" "$TMP_DIR/list-own.json" -assert_jq "$TMP_DIR/list-own.json" ".data.mine == true and (.data.items | map(.id) | index(\"$ASSET_ID\") != null)" "employee owns asset" +echo "[5/10] administration assign asset to employee" +ASSET_ASSIGN_BODY="$TMP_DIR/asset-assign.json" +ASSET_ASSIGN_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/assign" "$ADMINISTRATION_TOKEN" "$ASSET_ASSIGN_BODY" '{"employeeId":"'"$EMPLOYEE_ID"'","note":"首次领用"}')" +assert_status "$ASSET_ASSIGN_STATUS" "200" "assign asset" "$ASSET_ASSIGN_BODY" +assert_jq "$ASSET_ASSIGN_BODY" '.data.assigneeEmployeeId == "'"$EMPLOYEE_ID"'"' "assign asset assignee" -echo "[6/8] finance manager updates asset status and location" -PATCH_ASSET_BODY='{"status":"in_use","location":"深圳办公室 A-01"}' -patch_asset_status="$(api_json_auth PATCH "$BASE_URL/api/assets/$ASSET_ID" "$FINANCE_MGR_TOKEN" "$TMP_DIR/patch-asset.json" "$PATCH_ASSET_BODY")" -assert_status "$patch_asset_status" "200" "patch asset" "$TMP_DIR/patch-asset.json" -assert_jq "$TMP_DIR/patch-asset.json" '.data.asset.status == "in_use" and .data.asset.location == "深圳办公室 A-01"' "asset status/location updated" +echo "[6/10] employee only sees own asset and cannot transfer" +EMPLOYEE_ASSETS_BODY="$TMP_DIR/employee-assets.json" +EMPLOYEE_ASSETS_STATUS="$(api_json_auth GET "$BASE_URL/api/assets?mine=0" "$EMPLOYEE_TOKEN" "$EMPLOYEE_ASSETS_BODY")" +assert_status "$EMPLOYEE_ASSETS_STATUS" "200" "employee assets mine forced" "$EMPLOYEE_ASSETS_BODY" +assert_jq "$EMPLOYEE_ASSETS_BODY" '.data.mine == true' "employee mine forced" +assert_jq "$EMPLOYEE_ASSETS_BODY" '.data.items[] | select(.id == "'"$ASSET_ID"'") | .assigneeEmployeeId == "'"$EMPLOYEE_ID"'"' "employee sees owned asset" +EMPLOYEE_TRANSFER_BODY="$TMP_DIR/employee-transfer.json" +EMPLOYEE_TRANSFER_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/transfer" "$EMPLOYEE_TOKEN" "$EMPLOYEE_TRANSFER_BODY" '{"toLocation":"深圳办公室 B-01"}')" +assert_status "$EMPLOYEE_TRANSFER_STATUS" "403" "employee transfer forbidden" "$EMPLOYEE_TRANSFER_BODY" +assert_jq "$EMPLOYEE_TRANSFER_BODY" '.error.code == "PERM_001"' "employee transfer forbidden code" -echo "[7/8] purchase source is immutable after create" -INVALID_PATCH_BODY="$(jq -nc --arg purchaseRequestId "$APPROVED_PURCHASE_ID" '{purchaseRequestId:$purchaseRequestId}')" -invalid_patch_status="$(api_json_auth PATCH "$BASE_URL/api/assets/$ASSET_ID" "$FINANCE_MGR_TOKEN" "$TMP_DIR/invalid-patch.json" "$INVALID_PATCH_BODY")" -assert_status "$invalid_patch_status" "400" "patch asset purchase source forbidden" "$TMP_DIR/invalid-patch.json" -assert_jq "$TMP_DIR/invalid-patch.json" '.error.code == "VALID_001"' "invalid purchase source patch code" +echo "[7/10] administration return then assign asset again" +ASSET_RETURN_BODY="$TMP_DIR/asset-return.json" +ASSET_RETURN_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/return" "$ADMINISTRATION_TOKEN" "$ASSET_RETURN_BODY" '{"note":"归还入库"}')" +assert_status "$ASSET_RETURN_STATUS" "200" "return asset" "$ASSET_RETURN_BODY" +assert_jq "$ASSET_RETURN_BODY" '.data.assigneeEmployeeId == null' "return clears assignee" +ASSET_REASSIGN_BODY="$TMP_DIR/asset-reassign.json" +ASSET_REASSIGN_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/assign" "$ADMINISTRATION_TOKEN" "$ASSET_REASSIGN_BODY" '{"employeeId":"'"$EMPLOYEE_ID"'","note":"再次领用"}')" +assert_status "$ASSET_REASSIGN_STATUS" "200" "reassign asset" "$ASSET_REASSIGN_BODY" -echo "[8/8] verify category and asset db state" -assert_asset_state "$ASSET_ID" "$CATEGORY_ID" "$EMPLOYEE_ID" "$APPROVED_PURCHASE_ID" +echo "[8/10] administration transfers asset to finance manager and new location" +ASSET_TRANSFER_BODY="$TMP_DIR/asset-transfer.json" +ASSET_TRANSFER_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/transfer" "$ADMINISTRATION_TOKEN" "$ASSET_TRANSFER_BODY" '{"toEmployeeId":"'"$FINANCE_MGR_ID"'","toLocation":"深圳办公室 B-02","note":"部门间调拨"}')" +assert_status "$ASSET_TRANSFER_STATUS" "200" "transfer asset" "$ASSET_TRANSFER_BODY" +assert_jq "$ASSET_TRANSFER_BODY" '.data.assigneeEmployeeId == "'"$FINANCE_MGR_ID"'"' "transfer asset assignee" +assert_jq "$ASSET_TRANSFER_BODY" '.data.location == "深圳办公室 B-02"' "transfer asset location" + +echo "[9/10] finance manager creates completes inventory check; employee forbidden" +INVENTORY_CREATE_BODY="$TMP_DIR/inventory-create.json" +INVENTORY_CREATE_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks" "$FINANCE_MGR_TOKEN" "$INVENTORY_CREATE_BODY" '{"scopeType":"cost_center","scopeRefId":"'"$COST_CENTER_ID"'"}')" +assert_status "$INVENTORY_CREATE_STATUS" "200" "create inventory check" "$INVENTORY_CREATE_BODY" +INVENTORY_CHECK_ID="$(jq -er '.data.inventoryCheck.id' "$INVENTORY_CREATE_BODY")" +INVENTORY_START_BODY="$TMP_DIR/inventory-start.json" +INVENTORY_START_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks/$INVENTORY_CHECK_ID/start" "$FINANCE_MGR_TOKEN" "$INVENTORY_START_BODY")" +assert_status "$INVENTORY_START_STATUS" "200" "start inventory check" "$INVENTORY_START_BODY" +INVENTORY_ITEM_BODY="$TMP_DIR/inventory-item.json" +INVENTORY_ITEM_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks/$INVENTORY_CHECK_ID/items" "$FINANCE_MGR_TOKEN" "$INVENTORY_ITEM_BODY" '{"items":[{"assetId":"'"$ASSET_ID"'","result":"matched","note":"盘点一致"}]}')" +assert_status "$INVENTORY_ITEM_STATUS" "200" "record inventory item" "$INVENTORY_ITEM_BODY" +INVENTORY_COMPLETE_BODY="$TMP_DIR/inventory-complete.json" +INVENTORY_COMPLETE_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks/$INVENTORY_CHECK_ID/complete" "$FINANCE_MGR_TOKEN" "$INVENTORY_COMPLETE_BODY")" +assert_status "$INVENTORY_COMPLETE_STATUS" "200" "complete inventory check" "$INVENTORY_COMPLETE_BODY" +EMPLOYEE_INVENTORY_BODY="$TMP_DIR/employee-inventory.json" +EMPLOYEE_INVENTORY_STATUS="$(api_json_auth GET "$BASE_URL/api/inventory-checks" "$EMPLOYEE_TOKEN" "$EMPLOYEE_INVENTORY_BODY")" +assert_status "$EMPLOYEE_INVENTORY_STATUS" "403" "employee inventory forbidden" "$EMPLOYEE_INVENTORY_BODY" +assert_jq "$EMPLOYEE_INVENTORY_BODY" '.error.code == "PERM_001"' "employee inventory forbidden code" + +echo "[10/10] verify asset movement inventory db state and audit logs" +assert_asset_state "$ASSET_ID" "$CATEGORY_ID" "$FINANCE_MGR_ID" "$INVENTORY_CHECK_ID" echo "[OK] Asset regression passed" echo "- assetCategoryId: $CATEGORY_ID" echo "- assetId: $ASSET_ID" +echo "- inventoryCheckId: $INVENTORY_CHECK_ID"