From 71e746b5f5ae1a8fb3d1db465c7708907d35c51f Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 26 Aug 2026 16:23:46 +0800 Subject: [PATCH 01/13] fix(intake): isolate basic info confirmation by batch --- ...tegratedDocumentActionControllers.test.tsx | 6 + ...NonIntegratedDocumentActionControllers.tsx | 1 + ...edWorkflowConfirmActionController.test.tsx | 132 ++++++++++++++++++ ...tegratedWorkflowConfirmActionController.ts | 7 +- 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.test.tsx index 3dbff7e0..f5124fa3 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.test.tsx @@ -83,6 +83,7 @@ describe("useNonIntegratedDocumentActionControllers", () => { }, documentState: { activeDocumentWorkspaceCategory: "basicInfo", + activeWorkflowBatchNo: 2, documentWorkspaceApprovedAt: {}, documentWorkspaceConfirmations: {}, selectedUploadFileIds: new Set(), @@ -148,6 +149,11 @@ describe("useNonIntegratedDocumentActionControllers", () => { }); expect(result.current.basicInfoConfirmationAction).toBeTruthy(); expect(result.current.documentWorkspaceConfirmationAction).toBeTruthy(); + expect( + jest.mocked(useNonIntegratedWorkflowConfirmActionController), + ).toHaveBeenCalledWith( + expect.objectContaining({ activeWorkflowBatchNo: 2 }), + ); act(() => { expect( diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.tsx b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.tsx index 63e2f639..575b2f65 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedDocumentActionControllers.tsx @@ -185,6 +185,7 @@ export function useNonIntegratedDocumentActionControllers({ }); const workflowConfirmActions = useNonIntegratedWorkflowConfirmActionController({ + activeWorkflowBatchNo: documentState.activeWorkflowBatchNo, basicInfoHasContent: documentDerived.basicInfoHasContent, basicInfoWorkspaceCategory: NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx new file mode 100644 index 00000000..d13c0703 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx @@ -0,0 +1,132 @@ +import { act, renderHook } from "@testing-library/react"; + +import { NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY } from "./core/nonIntegratedStorage"; +import type { NonIntegratedSkuItem } from "./core/nonIntegratedStorage"; +import { useNonIntegratedWorkflowConfirmActionController } from "./useNonIntegratedWorkflowConfirmActionController"; + +function createSkuItem( + overrides: Partial, +): NonIntegratedSkuItem { + return { + id: "sku-1", + model: "XG0012K01", + skuCode: "XG001254", + barcode: "XG001254N160V01", + setCount: "1", + specName: "款式一 100/17/130 珍珠白色", + size: "100/17/130", + color: "珍珠白色", + material: "12+15密度板", + boardMaterialType: "非ENF级", + versionNo: "V01", + attributeCode: "A", + drawingId: "XG0012K01AV01", + installMethod: "整装", + executeStandard: "T/CZFA 0001-2025", + nationalStandardCompliant: "否", + batchNo: 2, + ...overrides, + } as NonIntegratedSkuItem; +} + +function createControllerParams({ + activeWorkflowBatchNo = 2, + derivedSkuItems, +}: { + activeWorkflowBatchNo?: number; + derivedSkuItems: NonIntegratedSkuItem[]; +}) { + return { + activeWorkflowBatchNo, + basicInfoHasContent: true, + basicInfoWorkspaceCategory: NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + derivedSkuItems, + documentWorkspaceApprovedAt: {}, + documentWorkspaceConfirmations: {}, + documentWorkspaceCountByCategory: {}, + documentWorkspaceRevisionSignatureRef: { current: null }, + effectiveSelectedReviewRoundId: "", + isReadOnly: false, + isWorkflowNodeConfirmed: jest.fn(() => false), + persistEditedProduct: jest.fn(async () => true), + reviewRounds: [], + setDocumentWorkspaceApprovedAt: jest.fn(), + setDocumentWorkspaceConfirmations: jest.fn(), + setFinishedSampleShipmentRows: jest.fn(), + setReviewRounds: jest.fn(), + setSaveMessage: jest.fn(), + setSaveMessageType: jest.fn(), + uploadFiles: [], + } as unknown as Parameters< + typeof useNonIntegratedWorkflowConfirmActionController + >[0]; +} + +describe("useNonIntegratedWorkflowConfirmActionController", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("confirms the active batch without revalidating a completed historical batch", async () => { + const params = createControllerParams({ + derivedSkuItems: [ + createSkuItem({ + id: "sku-batch-1", + skuCode: "XG001251", + barcode: "XG001251N160V01", + batchNo: 1, + installMethod: "", + }), + createSkuItem({ id: "sku-batch-2" }), + ], + }); + jest.spyOn(window, "confirm").mockReturnValue(true); + + const { result } = renderHook(() => + useNonIntegratedWorkflowConfirmActionController(params), + ); + + await act(async () => { + await result.current.handleConfirmWorkflowNode( + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ); + }); + + expect(params.persistEditedProduct).toHaveBeenCalledTimes(1); + expect(params.setSaveMessage).not.toHaveBeenCalledWith( + expect.stringContaining("XG001251"), + ); + }); + + it("still blocks confirmation when the active batch itself is incomplete", async () => { + const params = createControllerParams({ + derivedSkuItems: [ + createSkuItem({ + id: "sku-batch-1", + skuCode: "XG001251", + barcode: "XG001251N160V01", + batchNo: 1, + }), + createSkuItem({ + id: "sku-batch-2", + installMethod: "", + }), + ], + }); + + const { result } = renderHook(() => + useNonIntegratedWorkflowConfirmActionController(params), + ); + + await act(async () => { + await result.current.handleConfirmWorkflowNode( + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ); + }); + + expect(params.persistEditedProduct).not.toHaveBeenCalled(); + expect(params.setSaveMessage).toHaveBeenCalledWith( + "基础信息未填写完整,SKU XG001254 缺少:安装方式。请补全后再确认。", + ); + }); +}); diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.ts index 0043850c..d9fe922e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.ts @@ -51,6 +51,7 @@ type PersistEditedProduct = (params: { }) => Promise; type UseNonIntegratedWorkflowConfirmActionControllerParams = { + activeWorkflowBatchNo: number; basicInfoHasContent: boolean; basicInfoWorkspaceCategory: WorkflowNodeCategory; derivedSkuItems: NonIntegratedSkuItem[]; @@ -84,6 +85,7 @@ type UseNonIntegratedWorkflowConfirmActionControllerParams = { }; export function useNonIntegratedWorkflowConfirmActionController({ + activeWorkflowBatchNo, basicInfoHasContent, basicInfoWorkspaceCategory, derivedSkuItems, @@ -128,8 +130,11 @@ export function useNonIntegratedWorkflowConfirmActionController({ } if (category === basicInfoWorkspaceCategory) { + const activeBatchSkuItems = derivedSkuItems.filter( + (item) => (item.batchNo || 1) === activeWorkflowBatchNo, + ); const validationMessage = getBasicInfoConfirmationValidationMessage( - derivedSkuItems, + activeBatchSkuItems, ); if (validationMessage) { setSaveMessage(validationMessage); From 3f5cf79cc2951b58a91d347a6a111b8f08fb770b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 26 Aug 2026 16:48:22 +0800 Subject: [PATCH 02/13] fix(intake): preserve sku identities on delete --- .../basic-info/skuBatchStateHelpers.test.ts | 97 +++++++++++++++++++ .../basic-info/skuBatchStateHelpers.ts | 12 +-- .../basic-info/useSkuStructureController.ts | 4 +- 3 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.test.ts diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.test.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.test.ts new file mode 100644 index 00000000..9f9fc9b2 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.test.ts @@ -0,0 +1,97 @@ +import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage"; +import { deleteSkuItemPreservingIdentities } from "./skuBatchStateHelpers"; +import { createEmptySkuItem } from "./skuBrandHelpers"; + +function createSkuItem( + id: string, + batchNo: number, + skuSequence: number, + overrides: Partial = {}, +) { + return { + ...createEmptySkuItem(), + id, + batchNo, + skuSequence, + skuCode: `XG0012${skuSequence}`, + barcode: `XG0012${skuSequence}N160V01`, + ...overrides, + }; +} + +describe("SKU deletion identity preservation", () => { + it("keeps batch 1 sequence 51 and batch 2 sequence 54 after deleting 52", () => { + const batchOne = createSkuItem("batch-1-51", 1, 51); + const batchTwo52 = createSkuItem("batch-2-52", 2, 52); + const batchTwo54 = createSkuItem("batch-2-54", 2, 54); + + const result = deleteSkuItemPreservingIdentities( + [batchOne, batchTwo52, batchTwo54], + batchTwo52.id, + ); + + expect(result).toEqual({ + nextItems: [batchOne, batchTwo54], + nextBatchCount: 2, + }); + expect(result.nextItems[0]).toBe(batchOne); + expect(result.nextItems[1]).toBe(batchTwo54); + }); + + it("keeps sequences 51 and 52 after deleting imported sequence 54", () => { + const batchOne = createSkuItem("batch-1-51", 1, 51); + const batchTwo52 = createSkuItem("batch-2-52", 2, 52); + const batchTwo54 = createSkuItem("batch-2-54", 2, 54); + + const result = deleteSkuItemPreservingIdentities( + [batchOne, batchTwo52, batchTwo54], + batchTwo54.id, + ); + + expect(result.nextItems).toEqual([batchOne, batchTwo52]); + expect(result.nextBatchCount).toBe(2); + }); + + it("does not renumber batch 1 when the last item in batch 2 is deleted", () => { + const batchOne = createSkuItem("batch-1-51", 1, 51); + const batchTwo = createSkuItem("batch-2-52", 2, 52); + + const result = deleteSkuItemPreservingIdentities( + [batchOne, batchTwo], + batchTwo.id, + ); + + expect(result.nextItems).toEqual([batchOne]); + expect(result.nextItems[0]).toMatchObject({ + id: "batch-1-51", + batchNo: 1, + skuSequence: 51, + skuCode: "XG001251", + barcode: "XG001251N160V01", + }); + expect(result.nextBatchCount).toBe(1); + }); + + it("removes a package group without changing unrelated SKU identities", () => { + const batchOne = createSkuItem("batch-1-51", 1, 51); + const packageA = createSkuItem("batch-2-54-a", 2, 54, { + packageGroupId: "package-54", + packageSuffix: "1/2", + }); + const packageB = createSkuItem("batch-2-54-b", 2, 54, { + packageGroupId: "package-54", + packageSuffix: "2/2", + }); + const batchTwo56 = createSkuItem("batch-2-56", 2, 56); + + const result = deleteSkuItemPreservingIdentities( + [batchOne, packageA, packageB, batchTwo56], + packageA.id, + ); + + expect(result.nextItems).toEqual([batchOne, batchTwo56]); + expect(result.nextItems[0]).toBe(batchOne); + expect(result.nextItems[1]).toBe(batchTwo56); + expect(result.nextBatchCount).toBe(2); + }); +}); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.ts index c60ed790..1fbc20c3 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBatchStateHelpers.ts @@ -1,5 +1,4 @@ import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage"; -import { resequenceSkuItems } from "./skuAutoHelpers"; import { createEmptySkuItem } from "./skuBrandHelpers"; export type SkuBatchSelection = number | "all"; @@ -56,7 +55,7 @@ export function normalizeSkuBatchSelectionAfterDelete( return current >= targetBatchNo ? nextBatchCount : current; } -export function deleteSkuItemAndResequence( +export function deleteSkuItemPreservingIdentities( skuItems: NonIntegratedSkuItem[], targetId: string, ) { @@ -76,16 +75,15 @@ export function deleteSkuItemAndResequence( ? item.packageGroupId !== targetItem.packageGroupId : item.id !== targetId, ); - const normalizedNextItems = resequenceSkuItems( - nextItems.length > 0 ? nextItems : [createEmptySkuItem()], - ); + const remainingItems = + nextItems.length > 0 ? nextItems : [createEmptySkuItem()]; const nextBatchCount = Math.max( 1, - ...normalizedNextItems.map((item) => item.batchNo || 1), + ...remainingItems.map((item) => item.batchNo || 1), ); return { - nextItems: normalizedNextItems, + nextItems: remainingItems, nextBatchCount, }; } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuStructureController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuStructureController.ts index aa76add5..532a2ba8 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuStructureController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuStructureController.ts @@ -5,7 +5,7 @@ import type { import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage"; import { - deleteSkuItemAndResequence, + deleteSkuItemPreservingIdentities, type SkuBatchSelection, } from "./skuBatchStateHelpers"; import { buildSkuItemsAfterSetCountCommit } from "./skuSetCountCommitHelpers"; @@ -65,7 +65,7 @@ export function useSkuStructureController({ return; } - const { nextItems, nextBatchCount } = deleteSkuItemAndResequence( + const { nextItems, nextBatchCount } = deleteSkuItemPreservingIdentities( skuItems, id, ); From a34dee68258d36c2b020c2dd8f8e536fa4e19661 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 26 Aug 2026 18:18:41 +0800 Subject: [PATCH 03/13] feat(supply-chain): add product, supplier, and store management --- .../migration.sql | 3 + .../migration.sql | 55 +++ .../migration.sql | 2 + furniture-3d-system/prisma/schema.prisma | 73 +++- .../product-archive/image-export/route.ts | 52 +++ .../api/supply-chain/product-archive/route.ts | 40 +-- .../[id]/password/route.ts | 14 + .../store-account-management/[id]/route.ts | 38 +++ .../store-account-management/batch/route.ts | 29 ++ .../store-account-management/import/route.ts | 28 ++ .../store-account-management/logs/route.ts | 9 + .../permissions/route.ts | 26 ++ .../store-account-management/route.ts | 40 +++ .../supplier-information/route.test.ts | 73 ++++ .../supplier-information/route.ts | 43 +++ .../warehouse-config/[id]/route.ts | 1 + .../supply-chain/warehouse-config/route.ts | 4 +- .../components/SupplyChainSidebar.tsx | 2 + .../components/SupplyChainTabBar.tsx | 5 +- .../components/supply-chain-tabs.tsx | 11 +- .../config/supply-chain-menu.test.ts | 41 ++- .../supply-chain/config/supply-chain-menu.ts | 13 +- .../ProductArchiveMaterialImageCell.tsx | 54 +-- .../product-archive/ProductArchiveView.tsx | 43 ++- .../database/product-archive/columns.ts | 3 +- .../database/product-archive/page.tsx | 2 +- .../productArchiveImagePriority.test.ts | 31 ++ .../productArchiveImagePriority.ts | 14 +- .../product-archive/useColumnSettings.test.ts | 94 ++++++ .../product-archive/useColumnSettings.ts | 52 ++- .../useProductArchiveImageExport.ts | 57 ++++ .../useProductArchivePageImages.ts | 3 +- .../StoreAccountBatchUpdateDialog.tsx | 247 ++++++++++++++ .../StoreAccountLogDialog.tsx | 92 +++++ .../StoreAccountManagementView.tsx | 313 +++++++++++++++++ .../StoreAccountPermissionDialog.tsx | 309 +++++++++++++++++ .../store-account-management/page.tsx | 23 ++ .../storeAccountPermissionScope.test.ts | 37 ++ .../storeAccountPermissionScope.ts | 58 ++++ .../warehouse-config/WarehouseConfigView.tsx | 27 +- .../src/app/supply-chain/management/page.tsx | 23 ++ .../SupplierInformationEditDialog.tsx | 280 ++++++++++++++++ .../SupplierInformationFilters.tsx | 167 +++++++++ .../SupplierInformationTable.tsx | 192 +++++++++++ .../SupplierInformationView.tsx | 245 ++++++++++++++ .../purchase/supplier-information/page.tsx | 53 +++ .../src/lib/store-account-crypto.ts | 32 ++ .../src/lib/store-account-management.test.ts | 85 +++++ .../src/lib/store-account-management.ts | 229 +++++++++++++ .../supply-chain-permission-settings.test.ts | 12 + .../lib/supply-chain-permission-settings.ts | 18 + ...supply-chain-product-archive-change-log.ts | 2 + ...-chain-product-archive-warehouse-config.ts | 26 ++ .../src/lib/supply-chain-product-archive.ts | 1 + .../lib/supply-chain-supplier-information.ts | 77 +++++ ...rchive-image-cache-sync.repository.test.ts | 63 ++-- ...in-product-archive-sync.repository.test.ts | 1 + ...in-supplier-information.repository.test.ts | 101 ++++++ .../store-account-management.repository.ts | 197 +++++++++++ ...product-archive-image-export.repository.ts | 46 +++ ...uct-archive-warehouse-config.repository.ts | 6 + ...supply-chain-product-archive.repository.ts | 8 + ...y-chain-supplier-information.repository.ts | 192 +++++++++++ ...uct-archive-material-image.service.test.ts | 35 ++ .../store-account-management.service.test.ts | 119 +++++++ ...ct-archive-enrichment-sync.service.test.ts | 32 +- ...oduct-archive-image-export.service.test.ts | 102 ++++++ ...supply-chain-product-archive-query.test.ts | 26 ++ ...chain-product-archive-sync.service.test.ts | 7 +- ...t-archive-warehouse-config.service.test.ts | 24 ++ ...pply-chain-product-archive.service.test.ts | 52 ++- ...chain-supplier-information.service.test.ts | 195 +++++++++++ .../product-archive-material-image.service.ts | 1 + .../store-account-management.service.ts | 317 ++++++++++++++++++ ...upply-chain-permission-settings.service.ts | 6 + ...-product-archive-enrichment-sync.mapper.ts | 10 +- ...in-product-archive-image-export.service.ts | 126 +++++++ .../supply-chain-product-archive-query.ts | 41 +++ ...roduct-archive-warehouse-config.service.ts | 21 ++ .../supply-chain-product-archive.mapper.ts | 1 + .../supply-chain-product-archive.service.ts | 70 +++- ...pply-chain-supplier-information.service.ts | 136 ++++++++ 82 files changed, 5291 insertions(+), 147 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260825181500_add_supplier_information_maintenance_fields/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20260826120000_add_store_account_management/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20260826140000_add_warehouse_ownership/migration.sql create mode 100644 furniture-3d-system/src/app/api/supply-chain/product-archive/image-export/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/password/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/batch/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/logs/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/permissions/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-information/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-information/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.test.ts create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveImageExport.ts create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountBatchUpdateDialog.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountLogDialog.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountPermissionDialog.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.test.ts create mode 100644 furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.ts create mode 100644 furniture-3d-system/src/app/supply-chain/management/page.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationEditDialog.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationFilters.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationTable.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationView.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-information/page.tsx create mode 100644 furniture-3d-system/src/lib/store-account-crypto.ts create mode 100644 furniture-3d-system/src/lib/store-account-management.test.ts create mode 100644 furniture-3d-system/src/lib/store-account-management.ts create mode 100644 furniture-3d-system/src/lib/supply-chain-supplier-information.ts create mode 100644 furniture-3d-system/src/server/repositories/__tests__/supply-chain-supplier-information.repository.test.ts create mode 100644 furniture-3d-system/src/server/repositories/store-account-management.repository.ts create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-export.repository.ts create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-supplier-information.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-image-export.service.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-query.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-supplier-information.service.test.ts create mode 100644 furniture-3d-system/src/server/services/store-account-management.service.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-image-export.service.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-supplier-information.service.ts diff --git a/furniture-3d-system/prisma/migrations/20260825181500_add_supplier_information_maintenance_fields/migration.sql b/furniture-3d-system/prisma/migrations/20260825181500_add_supplier_information_maintenance_fields/migration.sql new file mode 100644 index 00000000..fa0c41f5 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260825181500_add_supplier_information_maintenance_fields/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "SupplyChainProductArchive" +ADD COLUMN "stableLeadTime" TEXT NOT NULL DEFAULT '', +ADD COLUMN "backupSupplier" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/migrations/20260826120000_add_store_account_management/migration.sql b/furniture-3d-system/prisma/migrations/20260826120000_add_store_account_management/migration.sql new file mode 100644 index 00000000..1ef0aacd --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260826120000_add_store_account_management/migration.sql @@ -0,0 +1,55 @@ +CREATE TABLE "StoreAccount" ( + "id" TEXT NOT NULL, + "storeName" TEXT NOT NULL, + "operationOwner" TEXT NOT NULL DEFAULT '', + "operationDepartmentHead" TEXT NOT NULL DEFAULT '', + "operationDirector" TEXT NOT NULL DEFAULT '', + "profitCenter" TEXT NOT NULL DEFAULT '', + "accountType" TEXT NOT NULL, + "account" TEXT NOT NULL, + "passwordCiphertext" TEXT NOT NULL DEFAULT '', + "boundPhone" TEXT NOT NULL DEFAULT '', + "verificationContact" TEXT NOT NULL DEFAULT '', + "jstOrderService" TEXT NOT NULL DEFAULT '', + "accountGroupName" TEXT NOT NULL DEFAULT '', + "createdById" TEXT, + "updatedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "StoreAccount_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "StoreAccount_storeName_accountType_account_key" ON "StoreAccount"("storeName", "accountType", "account"); +CREATE INDEX "StoreAccount_storeName_idx" ON "StoreAccount"("storeName"); +CREATE INDEX "StoreAccount_account_idx" ON "StoreAccount"("account"); +CREATE INDEX "StoreAccount_accountType_idx" ON "StoreAccount"("accountType"); +CREATE INDEX "StoreAccount_updatedAt_idx" ON "StoreAccount"("updatedAt"); + +CREATE TABLE "StoreAccountChangeLog" ( + "id" TEXT NOT NULL, + "storeAccountId" TEXT, + "storeName" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "actorId" TEXT, + "actorName" TEXT NOT NULL, + "changes" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "StoreAccountChangeLog_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "StoreAccountChangeLog_storeAccountId_createdAt_idx" ON "StoreAccountChangeLog"("storeAccountId", "createdAt"); +CREATE INDEX "StoreAccountChangeLog_createdAt_idx" ON "StoreAccountChangeLog"("createdAt"); +CREATE INDEX "StoreAccountChangeLog_storeName_createdAt_idx" ON "StoreAccountChangeLog"("storeName", "createdAt"); + +CREATE TABLE "StoreAccountFieldPermission" ( + "id" TEXT NOT NULL, + "fieldKey" TEXT NOT NULL, + "subjectKey" TEXT NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'READONLY', + "updatedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "StoreAccountFieldPermission_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "StoreAccountFieldPermission_fieldKey_subjectKey_key" ON "StoreAccountFieldPermission"("fieldKey", "subjectKey"); +CREATE INDEX "StoreAccountFieldPermission_fieldKey_idx" ON "StoreAccountFieldPermission"("fieldKey"); +CREATE INDEX "StoreAccountFieldPermission_subjectKey_idx" ON "StoreAccountFieldPermission"("subjectKey"); diff --git a/furniture-3d-system/prisma/migrations/20260826140000_add_warehouse_ownership/migration.sql b/furniture-3d-system/prisma/migrations/20260826140000_add_warehouse_ownership/migration.sql new file mode 100644 index 00000000..fca99c7d --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260826140000_add_warehouse_ownership/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "SupplyChainProductArchiveWarehouseConfig" +ADD COLUMN "warehouseOwnership" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index c12c520e..34378c06 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -556,7 +556,9 @@ model SupplyChainProductArchive { warehouseHead String @default("") firstSourceCode String @default("PRODUCT_DEVELOPMENT") firstLeadTime String @default("") + stableLeadTime String @default("") palletSpec String @default("") + backupSupplier String @default("") dailySales String @default("") sales3d String @default("") sales7d String @default("") @@ -665,13 +667,14 @@ model SupplyChainProductArchiveCategoryConfig { } model SupplyChainProductArchiveWarehouseConfig { - id String @id @default(cuid()) - warehouseName String @unique - warehouseHead String @default("") - storageType String - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + warehouseName String @unique + warehouseOwnership String @default("") + warehouseHead String @default("") + storageType String + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([storageType]) @@index([status]) @@ -3351,6 +3354,62 @@ model SupplyChainPermissionOverride { @@index([subjectKey]) } +// Store account data is maintained manually in this system. +model StoreAccount { + id String @id @default(cuid()) + storeName String + operationOwner String @default("") + operationDepartmentHead String @default("") + operationDirector String @default("") + profitCenter String @default("") + accountType String + account String + passwordCiphertext String @default("") @db.Text + boundPhone String @default("") + verificationContact String @default("") + jstOrderService String @default("") + accountGroupName String @default("") + createdById String? + updatedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([storeName, accountType, account]) + @@index([storeName]) + @@index([account]) + @@index([accountType]) + @@index([updatedAt]) +} + +model StoreAccountChangeLog { + id String @id @default(cuid()) + storeAccountId String? + storeName String + operation String + actorId String? + actorName String + changes Json + createdAt DateTime @default(now()) + + @@index([storeAccountId, createdAt]) + @@index([createdAt]) + @@index([storeName, createdAt]) +} + +model StoreAccountFieldPermission { + id String @id @default(cuid()) + fieldKey String + subjectKey String + visibility String @default("READONLY") + updatedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([fieldKey, subjectKey]) + @@index([fieldKey]) + @@index([subjectKey]) +} + model NewProductTerminationFeedback { id String @id @default(cuid()) diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/image-export/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/image-export/route.ts new file mode 100644 index 00000000..a9e1ee56 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/image-export/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { supplyChainProductArchiveImageExportService } from '@/server/services/supply-chain-product-archive-image-export.service' +import { parseProductArchiveFilters } from '@/server/services/supply-chain-product-archive-query' + +function buildContentDisposition(fileName: string) { + return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}` +} + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess( + ctx.user, + 'productArchive', + ) + if (!access.download) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '当前账号没有导出权限', + 403, + ctx.requestId, + ) + } + + const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters')) + if (!filters.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '筛选条件格式不正确', + 400, + ctx.requestId, + ) + } + + const payload = await supplyChainProductArchiveImageExportService.createWorkbook({ + filters: filters.data, + siteOrigin: request.nextUrl.origin, + }) + return new NextResponse(payload.bytes, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': buildContentDisposition(payload.fileName), + 'Cache-Control': 'no-store', + 'X-Exported-Row-Count': String(payload.total), + 'X-Request-Id': ctx.requestId, + }, + }) + }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/route.ts index 32a1b4e7..014778ca 100644 --- a/furniture-3d-system/src/app/api/supply-chain/product-archive/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/route.ts @@ -3,49 +3,13 @@ import { z } from 'zod' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { supplyChainProductArchiveService } from '@/server/services/supply-chain-product-archive.service' -import { PRODUCT_ARCHIVE_SALES_STATUSES, type ProductArchiveFilters } from '@/lib/supply-chain-product-archive' +import { parseProductArchiveFilters } from '@/server/services/supply-chain-product-archive-query' const productArchiveQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(200).default(50), }) -const archiveDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) - -const productArchiveFiltersSchema = z.object({ - keyword: z.string().trim().min(1).max(160).optional(), - categoryL1: z.array(z.string().trim().min(1).max(80)).max(20).optional(), - categoryL2: z.array(z.string().trim().min(1).max(160)).max(200).optional(), - spu: z.array(z.string().trim().min(1).max(120)).max(200).optional(), - sku: z.array(z.string().trim().min(1).max(120)).max(200).optional(), - barcode: z.array(z.string().trim().min(1).max(120)).max(200).optional(), - productName: z.array(z.string().trim().min(1).max(160)).max(200).optional(), - designer: z.array(z.string().trim().min(1).max(80)).max(200).optional(), - productStatus: z.array(z.enum(PRODUCT_ARCHIVE_SALES_STATUSES)).max(3).optional(), - material: z.array(z.string().trim().min(1).max(160)).max(200).optional(), - color: z.array(z.string().trim().min(1).max(80)).max(200).optional(), - brand: z.array(z.string().trim().min(1).max(120)).max(200).optional(), - supplier: z.array(z.string().trim().min(1).max(160)).max(200).optional(), - purchaser: z.array(z.string().trim().min(1).max(120)).max(200).optional(), - inOutWarehouse: z.array(z.string().trim().min(1).max(160)).max(200).optional(), - createTimeFrom: archiveDateSchema.optional(), - createTimeTo: archiveDateSchema.optional(), - updateTimeFrom: archiveDateSchema.optional(), - updateTimeTo: archiveDateSchema.optional(), -}).strict() - -function parseFilters(raw: string | null) { - if (!raw) return { success: true as const, data: {} as ProductArchiveFilters } - - try { - const parsed = productArchiveFiltersSchema.safeParse(JSON.parse(raw)) - if (!parsed.success) return { success: false as const } - return { success: true as const, data: parsed.data } - } catch { - return { success: false as const } - } -} - export async function GET(request: NextRequest) { return handleRequest( request, @@ -64,7 +28,7 @@ export async function GET(request: NextRequest) { ) } - const filters = parseFilters(request.nextUrl.searchParams.get('filters')) + const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters')) if (!filters.success) { return jsonError( ErrorCode.VALIDATION_INVALID_FORMAT, diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/password/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/password/route.ts new file mode 100644 index 00000000..cb6a4dff --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/password/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +type RouteProps = { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, props: RouteProps) { + return handleRequest(request, async (ctx) => { + const { id } = await props.params + const response = jsonSuccess(await storeAccountManagementService.revealPassword(id, ctx.user), 200, ctx.requestId) + response.headers.set('Cache-Control', 'no-store') + return response + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts new file mode 100644 index 00000000..e83551cc --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +const updateSchema = z.object({ + storeName: z.string().trim().max(240).optional(), + operationOwner: z.string().trim().max(240).optional(), + operationDepartmentHead: z.string().trim().max(240).optional(), + operationDirector: z.string().trim().max(240).optional(), + profitCenter: z.string().trim().max(240).optional(), + accountType: z.string().trim().max(20).optional(), + account: z.string().trim().max(240).optional(), + password: z.string().max(500).optional(), + boundPhone: z.string().trim().max(80).optional(), + verificationContact: z.string().trim().max(240).optional(), + jstOrderService: z.string().trim().max(240).optional(), + accountGroupName: z.string().trim().max(240).optional(), +}).strict() + +type RouteProps = { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, props: RouteProps) { + return handleRequest(request, async (ctx) => { + const { id } = await props.params + return jsonSuccess(await storeAccountManagementService.getOne(id, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} +export async function PATCH(request: NextRequest, props: RouteProps) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = updateSchema.safeParse(body) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺账号修改内容不正确', 400, ctx.requestId) + const { id } = await props.params + return jsonSuccess(await storeAccountManagementService.update(id, parsed.data, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/batch/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/batch/route.ts new file mode 100644 index 00000000..c5254f58 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/batch/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { isStoreAccountBatchEditableFieldKey } from '@/lib/store-account-management' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +const schema = z.object({ ids: z.array(z.string().trim().min(1)).min(1).max(500), field: z.string(), value: z.string().max(500) }).strict() +const previewSchema = z.object({ ids: z.array(z.string().trim().min(1)).min(1).max(500), field: z.string() }).strict() + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = previewSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success || !isStoreAccountBatchEditableFieldKey(parsed.data.field)) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量预览内容不正确', 400, ctx.requestId) + } + return jsonSuccess(await storeAccountManagementService.batchPreview({ ...parsed.data, field: parsed.data.field, user: ctx.user }), 200, ctx.requestId) + }, { requireAuth: true }) +} + +export async function PATCH(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = schema.safeParse(await request.json().catch(() => null)) + if (!parsed.success || !isStoreAccountBatchEditableFieldKey(parsed.data.field)) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量修改内容不正确', 400, ctx.requestId) + } + return jsonSuccess(await storeAccountManagementService.batchUpdate({ ...parsed.data, field: parsed.data.field, user: ctx.user }), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts new file mode 100644 index 00000000..bc3afec4 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from 'next/server' +import * as XLSX from 'xlsx' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +const HEADER_MAP: Record = { + 店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName', + storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', accountType: 'accountType', account: 'account', password: 'password', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName', +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const form = await request.formData() + const file = form.get('file') + if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId) + if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId) + const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false }) + const sheet = workbook.Sheets[workbook.SheetNames[0]] + if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId) + const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: '' }) + const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => { + const key = HEADER_MAP[header.trim()] + return key ? [[key, value]] : [] + }))) + return jsonSuccess(await storeAccountManagementService.importRows(rows, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/logs/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/logs/route.ts new file mode 100644 index 00000000..5be5cc54 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/logs/route.ts @@ -0,0 +1,9 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + return jsonSuccess(await storeAccountManagementService.listLogs({ id: request.nextUrl.searchParams.get('id') ?? undefined, storeName: request.nextUrl.searchParams.get('storeName') ?? undefined, user: ctx.user }), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/permissions/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/permissions/route.ts new file mode 100644 index 00000000..38b1b61f --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/permissions/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { isStoreAccountFieldKey } from '@/lib/store-account-management' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +const schema = z.object({ + updates: z.array(z.object({ + fieldKey: z.string(), + subjectKey: z.string().trim().min(1).max(120), + visibility: z.enum(['HIDDEN', 'READONLY', 'EDITABLE']).nullable(), + }).strict()).min(1).max(500), +}).strict() + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => jsonSuccess(await storeAccountManagementService.getFieldPermissionSettings(ctx.user), 200, ctx.requestId), { requireAuth: true }) +} + +export async function PUT(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = schema.safeParse(await request.json().catch(() => null)) + if (!parsed.success || parsed.data.updates.some((item) => !isStoreAccountFieldKey(item.fieldKey))) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '字段权限内容不正确', 400, ctx.requestId) + return jsonSuccess(await storeAccountManagementService.saveFieldPermissions({ updates: parsed.data.updates as never, user: ctx.user }), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts new file mode 100644 index 00000000..4a3e2854 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS } from '@/lib/store-account-management' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' + +const createSchema = z.object({ + storeName: z.string().trim().min(1).max(240), + operationOwner: z.string().trim().max(240).optional(), + operationDepartmentHead: z.string().trim().max(240).optional(), + operationDirector: z.string().trim().max(240).optional(), + profitCenter: z.string().trim().max(240).optional(), + accountType: z.string().trim().min(1).max(20), + account: z.string().trim().min(1).max(240), + password: z.string().max(500).optional(), + boundPhone: z.string().trim().max(80).optional(), + verificationContact: z.string().trim().max(240).optional(), + jstOrderService: z.string().trim().max(240).optional(), + accountGroupName: z.string().trim().max(240).optional(), +}).strict() + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const params = request.nextUrl.searchParams + const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, params.get(key)?.trim() ?? ''])) + const page = Math.max(1, Number(params.get('page') || 1) || 1) + const pageSize = Math.min(200, Math.max(1, Number(params.get('pageSize') || 50) || 50)) + return jsonSuccess(await storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: ctx.user }), 200, ctx.requestId) + }, { requireAuth: true }) +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = createSchema.safeParse(body) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺账号信息不完整', 400, ctx.requestId) + return jsonSuccess(await storeAccountManagementService.create(parsed.data, ctx.user), 201, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-information/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-information/route.test.ts new file mode 100644 index 00000000..c830ed4b --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-information/route.test.ts @@ -0,0 +1,73 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonSuccess = jest.fn() +const mockJsonError = jest.fn() +const mockUpdateSupplier = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), +})) + +jest.mock('@/server/services/supply-chain-supplier-information.service', () => ({ + supplyChainSupplierInformationService: { + updateSupplier: (...args: unknown[]) => mockUpdateSupplier(...args), + }, +})) + +const { PUT } = require('./route') + +describe('PUT /api/supply-chain/supplier-information', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => + handler({ requestId: 'req-supplier', user: { id: 'buyer-1', name: '采购员', role: 'USER' } }), + ) + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ data, status, requestId })) + mockJsonError.mockImplementation((code, message, status, requestId) => ({ code, message, status, requestId })) + }) + + it('passes validated supplier updates and the authenticated actor to the service', async () => { + mockUpdateSupplier.mockResolvedValue({ affectedBarcodes: 2, changedValues: 2 }) + const request = { + json: jest.fn().mockResolvedValue({ + supplierKey: '浩远', + updates: { + purchaseOwner: '采购B', + stableLeadTime: '30天', + backupSupplier: '备选厂', + }, + }), + } + + const response = await PUT(request) + + expect(mockHandleRequest.mock.calls[0][2]).toEqual({ requireAuth: true }) + expect(mockUpdateSupplier).toHaveBeenCalledWith({ + supplierKey: '浩远', + updates: { + purchaseOwner: '采购B', + stableLeadTime: '30天', + backupSupplier: '备选厂', + }, + user: expect.objectContaining({ id: 'buyer-1' }), + }) + expect(response).toEqual(expect.objectContaining({ status: 200 })) + }) + + it('rejects an edit that has no concrete field value', async () => { + const request = { + json: jest.fn().mockResolvedValue({ supplierKey: '浩远', updates: {} }), + } + + const response = await PUT(request) + + expect(mockUpdateSupplier).not.toHaveBeenCalled() + expect(response).toEqual(expect.objectContaining({ + message: '供应商信息修改内容不正确', + status: 400, + })) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-information/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-information/route.ts new file mode 100644 index 00000000..1eeffbed --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-information/route.ts @@ -0,0 +1,43 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service' + +const updateSchema = z.object({ + supplierKey: z.string().trim().min(1).max(180), + updates: z.object({ + purchaseOwner: z.string().trim().min(1).max(160).optional(), + supplierName: z.string().trim().min(1).max(180).optional(), + minimumOrderQuantity: z.string().trim().min(1).max(120).optional(), + firstLeadTime: z.string().trim().min(1).max(120).optional(), + stableLeadTime: z.string().trim().min(1).max(120).optional(), + palletSpec: z.string().trim().min(1).max(240).optional(), + backupSupplier: z.string().trim().min(1).max(180).optional(), + supplierType: z.string().trim().min(1).max(160).optional(), + }).strict(), +}).strict().refine((input) => Object.keys(input.updates).length > 0, { + message: '请至少填写一项要修改的内容', + path: ['updates'], +}) + +export async function PUT(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = updateSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '供应商信息修改内容不正确', + 400, + ctx.requestId, + ) + } + + const result = await supplyChainSupplierInformationService.updateSupplier({ + ...parsed.data, + user: ctx.user, + }) + return jsonSuccess(result, 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/warehouse-config/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/warehouse-config/[id]/route.ts index 1f57ee00..d3239a73 100644 --- a/furniture-3d-system/src/app/api/supply-chain/warehouse-config/[id]/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/warehouse-config/[id]/route.ts @@ -6,6 +6,7 @@ import { supplyChainProductArchiveWarehouseConfigService } from '@/server/servic const updateSchema = z.object({ warehouseName: z.string().trim().min(1).max(160), + warehouseOwnership: z.enum(['FACTORY', 'JIANSHANG']), warehouseHead: z.string().trim().max(80).default(''), storageType: z.enum(['INBOUND', 'DROPSHIP']), status: z.enum(['ACTIVE', 'INACTIVE']), diff --git a/furniture-3d-system/src/app/api/supply-chain/warehouse-config/route.ts b/furniture-3d-system/src/app/api/supply-chain/warehouse-config/route.ts index f2dc1435..eeb7f7df 100644 --- a/furniture-3d-system/src/app/api/supply-chain/warehouse-config/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/warehouse-config/route.ts @@ -6,6 +6,7 @@ import { supplyChainProductArchiveWarehouseConfigService } from '@/server/servic const statusSchema = z.enum(['ACTIVE', 'INACTIVE', 'UNCONFIGURED']) const storageTypeSchema = z.enum(['INBOUND', 'DROPSHIP']) +const ownershipSchema = z.enum(['FACTORY', 'JIANSHANG']) const listQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(200).default(100), @@ -14,6 +15,7 @@ const listQuerySchema = z.object({ }) const createSchema = z.object({ warehouseName: z.string().trim().min(1).max(160), + warehouseOwnership: ownershipSchema, warehouseHead: z.string().trim().max(80).default(''), storageType: storageTypeSchema, }).strict() @@ -37,7 +39,7 @@ export async function POST(request: NextRequest) { let body: unknown try { body = await request.json() } catch { return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId) } const parsed = createSchema.safeParse(body) - if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库名称和入库/代发不能为空', 400, ctx.requestId) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库名称、仓库归属和入库/代发不能为空', 400, ctx.requestId) return jsonSuccess(await supplyChainProductArchiveWarehouseConfigService.create(parsed.data), 201, ctx.requestId) }, { requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] }, diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx index 884a768f..53a64c37 100644 --- a/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx @@ -8,6 +8,7 @@ import { Database, PackagePlus, ShoppingCart, + TrendingUp, type LucideIcon, } from 'lucide-react' import type { SupplyChainPermissionTargetKey } from '@/lib/supply-chain-permission-settings' @@ -21,6 +22,7 @@ const ICON_MAP: Record = { Database, PackagePlus, ShoppingCart, + TrendingUp, ClipboardList, } diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx index 54de175e..c8c877c2 100644 --- a/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx @@ -1,15 +1,16 @@ 'use client' import { X } from 'lucide-react' -import { useSupplyChainTabs } from './supply-chain-tabs' +import { isSupplyChainTabRoute, useSupplyChainTabs } from './supply-chain-tabs' export function SupplyChainTabBar() { const { tabs, activePath, clickTab, closeTab } = useSupplyChainTabs() + const visibleTabs = tabs.filter((tab) => isSupplyChainTabRoute(tab.path)) return (
- {tabs.map((tab) => { + {visibleTabs.map((tab) => { const active = tab.path === activePath return (
{ setTabs((current) => { // 工作台不重复加 - if (path === homeTab.path) return current + if (path === homeTab.path || !isSupplyChainTabRoute(path)) return current if (current.some((t) => t.path === path)) return current return [...current, { path, title, closable: true }] }) @@ -90,8 +97,10 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record = { '/supply-chain/database/product-archive': '产品全量档案', '/supply-chain/database/category-config': '类目配置', '/supply-chain/database/warehouse-config': '仓库配置', + '/supply-chain/database/store-account-management': '店铺账号管理', '/supply-chain/new-products/unified-table': '新品开发进度总表', '/supply-chain/new-products/sample-progress': '打样进度明细', + '/supply-chain/purchase/supplier-information': '供应商信息', '/supply-chain/purchase/daily-affairs': '日常事项', '/supply-chain/purchase/price-change': '产品变价', '/supply-chain/operation-logs': '操作日志', diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts index 78e01393..52e7376b 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts @@ -1,6 +1,17 @@ -import { filterSupplyChainMenu, flattenSupplyChainMenu } from './supply-chain-menu' +import { filterSupplyChainMenu, flattenSupplyChainMenu, SUPPLY_CHAIN_MENU } from './supply-chain-menu' describe('supply chain menu', () => { + it('keeps the top-level modules in the requested order', () => { + expect(SUPPLY_CHAIN_MENU.map((module) => module.key)).toEqual([ + 'dashboard', + 'database', + 'new-products', + 'purchase', + 'management', + 'operation-logs', + ]) + }) + it('places the unified table under database and sample progress first under purchase management', () => { const items = flattenSupplyChainMenu() @@ -16,16 +27,34 @@ describe('supply chain menu', () => { module: '采购管理', route: '/supply-chain/new-products/sample-progress', }), + expect.objectContaining({ + label: '供应商信息', + module: '采购管理', + route: '/supply-chain/purchase/supplier-information', + }), ]), ) expect(items.filter((item) => item.module === '采购管理').map((item) => item.label)).toEqual([ '打样进度明细', + '供应商信息', '日常事项', '产品变价', ]) }) + it('keeps warehouse config and store account management as database peers', () => { + const database = SUPPLY_CHAIN_MENU.find((module) => module.key === 'database') + expect(database?.sections).toHaveLength(1) + expect(database?.sections?.[0]?.items.map((item) => item.label)).toEqual([ + '产品全量档案', + '新品开发进度总表', + '类目配置', + '仓库配置', + '店铺账号管理', + ]) + }) + it('only exposes the nodes opened for the current user', () => { const menu = filterSupplyChainMenu(['productArchive']) @@ -39,4 +68,14 @@ describe('supply chain menu', () => { '/supply-chain/operation-logs', ) }) + + it('exposes business management as a top-level route', () => { + expect(flattenSupplyChainMenu()).toEqual( + expect.arrayContaining([expect.objectContaining({ + label: '经营管理', + module: '经营管理', + route: '/supply-chain/management', + })]), + ) + }) }) diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts index b3dcddeb..5166c943 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts @@ -41,11 +41,8 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ { label: '产品全量档案', route: '/supply-chain/database/product-archive', targetKey: 'productArchive' }, { label: '新品开发进度总表', route: '/supply-chain/new-products/unified-table', targetKey: 'newProductUnifiedTable' }, { label: '类目配置', route: '/supply-chain/database/category-config', targetKey: 'categoryConfig' }, - ], - }, - { - items: [ { label: '仓库配置', route: '/supply-chain/database/warehouse-config', targetKey: 'warehouseConfig' }, + { label: '店铺账号管理', route: '/supply-chain/database/store-account-management', targetKey: 'storeAccountManagement' }, ], }, ], @@ -71,12 +68,20 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ { items: [ { label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' }, + { label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' }, { label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' }, { label: '产品变价', route: '/supply-chain/purchase/price-change', targetKey: 'purchasePriceChange' }, ], }, ], }, + { + key: 'management', + label: '经营管理', + icon: 'TrendingUp', + route: '/supply-chain/management', + targetKey: 'businessManagement', + }, { key: 'operation-logs', label: '操作日志', diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx index 335b45c6..f57be37c 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx @@ -9,6 +9,7 @@ import { canResolveProductArchiveMaterialImage, hasProductDevelopmentSkuImage, resolveProductDevelopmentSkuImageUrl, + selectProductArchiveImageUrl, } from './productArchiveImagePriority' export function ProductArchiveMaterialImageCell({ @@ -25,19 +26,21 @@ export function ProductArchiveMaterialImageCell({ const anchorRef = useRef(null) const [inView, setInView] = useState(false) const [failedMaterialImageUrl, setFailedMaterialImageUrl] = useState('') - const [failedSourceImageUrl, setFailedSourceImageUrl] = useState('') + const [failedJstSkuImageUrl, setFailedJstSkuImageUrl] = useState('') + const [failedProductDevelopmentSkuImageUrl, setFailedProductDevelopmentSkuImageUrl] = useState('') - const hasWideRowSkuImage = hasProductDevelopmentSkuImage(row) - const canResolveMaterialImage = !hasWideRowSkuImage && canResolveProductArchiveMaterialImage(row) - const archiveSourceImageUrl = (() => { + const canResolveMaterialImage = canResolveProductArchiveMaterialImage(row) + const jstSkuImageUrl = (() => { if (!canRequestJstImageFallback(row)) return '' const params = new URLSearchParams({ barcode: row.barcode }) return `/api/supply-chain/product-archive/source-image?${params.toString()}` })() - const sourceImageUrl = hasWideRowSkuImage + const productDevelopmentSkuImageUrl = hasProductDevelopmentSkuImage(row) ? resolveProductDevelopmentSkuImageUrl(row) - : archiveSourceImageUrl - const canResolveImage = Boolean(canResolveMaterialImage || sourceImageUrl) + : '' + const canResolveImage = Boolean( + canResolveMaterialImage || jstSkuImageUrl || productDevelopmentSkuImageUrl, + ) useEffect(() => { if (!canResolveImage || inView) return @@ -68,16 +71,25 @@ export function ProductArchiveMaterialImageCell({ && imageState.image?.previewUrl !== failedMaterialImageUrl ? imageState.image : null - const materialLookupSettled = hasWideRowSkuImage - || !canResolveMaterialImage + const materialLookupSettled = !canResolveMaterialImage + || imageState.status === 'resolved' || imageState.status === 'not-found' || imageState.status === 'error' || Boolean(failedMaterialImageUrl) - const useSourceImage = !resolvedImage && materialLookupSettled + const availableJstSkuImageUrl = materialLookupSettled + && jstSkuImageUrl !== failedJstSkuImageUrl + ? jstSkuImageUrl + : '' + const availableProductDevelopmentSkuImageUrl = materialLookupSettled + && productDevelopmentSkuImageUrl !== failedProductDevelopmentSkuImageUrl + ? productDevelopmentSkuImageUrl + : '' const imageUrl = inView - ? resolvedImage?.previewUrl - || (useSourceImage && sourceImageUrl !== failedSourceImageUrl ? sourceImageUrl - : '') + ? selectProductArchiveImageUrl({ + materialLibraryImageUrl: resolvedImage?.previewUrl, + jstSkuImageUrl: availableJstSkuImageUrl, + productDevelopmentSkuImageUrl: availableProductDevelopmentSkuImageUrl, + }) : '' if (!imageUrl) { return ( @@ -103,11 +115,11 @@ export function ProductArchiveMaterialImageCell({ } const matchLabel = resolvedImage?.source === 'barcode' ? '条码匹配' : 'SKU 匹配' - const title = resolvedImage + const title = imageUrl === resolvedImage?.previewUrl ? `${resolvedImage.viewLabel || '素材库图片'} · ${matchLabel}` - : hasWideRowSkuImage - ? '新品 SKU 图片' - : '聚水潭 SKU 图片' + : imageUrl === availableJstSkuImageUrl + ? '聚水潭 SKU 图片' + : '新品 SKU 图片' return ( )} - - +
+ {canDownload ? ( + + ) : null} + + +
{/* 表格 */} @@ -334,8 +349,13 @@ export function ProductArchiveView() { } .archive-toolbar-title { font-size: 15px; font-weight: 600; color: #27272a; } .archive-toolbar-sub { font-size: 12px; color: #a1a1aa; } - .archive-toolbar-action { + .archive-toolbar-actions { + display: inline-flex; + align-items: center; + gap: 8px; margin-left: auto; + } + .archive-toolbar-action { display: inline-flex; align-items: center; gap: 4px; @@ -349,6 +369,7 @@ export function ProductArchiveView() { transition: background 0.15s, border-color 0.15s; } .archive-toolbar-action:hover { background: #f4f4f5; border-color: #a1a1aa; } + .archive-toolbar-action:disabled { cursor: wait; opacity: 0.6; } .archive-toolbar-clear-filter { display: inline-flex; align-items: center; diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts index c603e213..8837a802 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts @@ -80,9 +80,10 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [ { group: '仓库信息', columns: [ - { prop: 'warehouseType', label: '仓库属性', width: 100 }, { prop: 'inOutWarehouse', label: '出入库仓库(采购类型)', width: 170 }, { prop: 'warehouseHead', label: '仓库负责人', width: 110 }, + { prop: 'warehouseOwnership', label: '仓库归属', width: 100 }, + { prop: 'warehouseType', label: '仓库属性', width: 100 }, ], }, { diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx index b9f7f533..21417b91 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx @@ -9,5 +9,5 @@ export default async function ProductArchivePage() { const { authUser } = await requireSession('/login') const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'productArchive') if (!access.view) redirect('/supply-chain') - return + return } diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.test.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.test.ts index 4e597b66..71cc5bbf 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.test.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.test.ts @@ -3,7 +3,9 @@ import { canRequestJstImageFallback, canResolveProductArchiveMaterialImage, hasProductDevelopmentSkuImage, + resolveArchiveImageUrlForCurrentSite, resolveProductDevelopmentSkuImageUrl, + selectProductArchiveImageUrl, } from './productArchiveImagePriority' function row(input: Partial) { @@ -49,4 +51,33 @@ describe('product archive image candidates', () => { expect(canRequestJstImageFallback(row({ barcode: 'BARCODE-1' }))).toBe(false) expect(canRequestJstImageFallback(row({ image: 'https://images.example.com/image.webp' }))).toBe(false) }) + + it('keeps object-storage image URLs on the current site', () => { + expect(resolveArchiveImageUrlForCurrentSite( + 'https://cp.jiansun.vip/api/storage/object?key=products%2Fimage.webp', + )).toBe('/api/storage/object?key=products%2Fimage.webp') + expect(resolveArchiveImageUrlForCurrentSite( + 'https://cdn.example.com/products/image.webp', + )).toBe('https://cdn.example.com/products/image.webp') + }) + + it('uses material library, JST SKU, and product-development SKU images in order', () => { + const candidates = { + materialLibraryImageUrl: 'https://images.example.com/material.webp', + jstSkuImageUrl: 'https://images.example.com/jst.webp', + productDevelopmentSkuImageUrl: 'https://images.example.com/new-sku.webp', + } + + expect(selectProductArchiveImageUrl(candidates)).toBe(candidates.materialLibraryImageUrl) + expect(selectProductArchiveImageUrl({ + ...candidates, + materialLibraryImageUrl: '', + })).toBe(candidates.jstSkuImageUrl) + expect(selectProductArchiveImageUrl({ + ...candidates, + materialLibraryImageUrl: '', + jstSkuImageUrl: '', + })).toBe(candidates.productDevelopmentSkuImageUrl) + expect(selectProductArchiveImageUrl({})).toBe('') + }) }) diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.ts index 3dbcd5a0..76815c31 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/productArchiveImagePriority.ts @@ -2,7 +2,7 @@ import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-ar const OBJECT_STORAGE_ROUTE_PATH = '/api/storage/object' -function resolveArchiveImageUrlForCurrentSite(image: string) { +export function resolveArchiveImageUrlForCurrentSite(image: string) { try { const parsed = new URL(image, 'http://archive-image.local') if (parsed.pathname === OBJECT_STORAGE_ROUTE_PATH && parsed.searchParams.has('key')) { @@ -14,6 +14,18 @@ function resolveArchiveImageUrlForCurrentSite(image: string) { return image } +export function selectProductArchiveImageUrl(input: { + materialLibraryImageUrl?: string + jstSkuImageUrl?: string + productDevelopmentSkuImageUrl?: string +}) { + return [ + input.materialLibraryImageUrl, + input.jstSkuImageUrl, + input.productDevelopmentSkuImageUrl, + ].map((value) => value?.trim() ?? '').find(Boolean) ?? '' +} + export function hasProductDevelopmentSkuImage(row: SupplyChainProductArchiveRow) { return row.firstSourceCode === 'PRODUCT_DEVELOPMENT' && Boolean(row.image.trim()) } diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.test.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.test.ts new file mode 100644 index 00000000..dd591df6 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.test.ts @@ -0,0 +1,94 @@ +import { + migrateWarehouseColumnOrder, + migrateWarehouseColumnOrderOnce, + type ColumnSetting, +} from './useColumnSettings' + +describe('product archive warehouse column order migration', () => { + it('migrates the cached order shown on the existing page', () => { + const settings: ColumnSetting[] = [{ + group: '仓库信息', + columns: [ + { prop: 'warehouseType', label: '仓库属性', width: 130, visible: false }, + { prop: 'inOutWarehouse', label: '出入库仓库(采购类型)', width: 170, visible: true }, + { prop: 'warehouseHead', label: '仓库负责人', width: 110, visible: true }, + { prop: 'warehouseOwnership', label: '仓库归属', width: 90, visible: true }, + { prop: 'customWarehouse', label: '自定义', width: 120, visible: true, isCustom: true }, + ], + }] + + const migrated = migrateWarehouseColumnOrder(settings) + + expect(migrated[0]?.columns.map((column) => column.prop)).toEqual([ + 'inOutWarehouse', + 'warehouseHead', + 'warehouseOwnership', + 'warehouseType', + 'customWarehouse', + ]) + expect(migrated[0]?.columns.find((column) => column.prop === 'warehouseType')).toEqual( + expect.objectContaining({ width: 130, visible: false }), + ) + }) + + it('does not change other groups', () => { + const otherGroup: ColumnSetting = { + group: '基础信息', + columns: [ + { prop: 'barcode', label: '条码', width: 140, visible: true }, + { prop: 'model', label: '型号', width: 120, visible: false }, + ], + } + + const migrated = migrateWarehouseColumnOrder([otherGroup]) + + expect(migrated[0]).toBe(otherGroup) + }) + + it('persists the migrated columns before marking the migration complete', () => { + const settings: ColumnSetting[] = [{ + group: '仓库信息', + columns: [ + { prop: 'warehouseType', label: '仓库属性', width: 100, visible: true }, + { prop: 'inOutWarehouse', label: '出入库仓库(采购类型)', width: 170, visible: true }, + { prop: 'warehouseHead', label: '仓库负责人', width: 110, visible: true }, + { prop: 'warehouseOwnership', label: '仓库归属', width: 100, visible: true }, + ], + }] + const storage = { + getItem: jest.fn(() => null), + setItem: jest.fn(), + } + + migrateWarehouseColumnOrderOnce(settings, storage) + + expect(storage.setItem.mock.calls.map(([key]) => key)).toEqual([ + 'caigou_archive_columns', + 'caigou_archive_columns_warehouse_order_v3', + ]) + expect(JSON.parse(storage.setItem.mock.calls[0]?.[1] ?? '[]')[0].columns) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ prop: 'warehouseOwnership', visible: true }), + expect.objectContaining({ prop: 'warehouseType', width: 100 }), + ])) + }) + + it('preserves later manual sorting after the one-time migration', () => { + const settings: ColumnSetting[] = [{ + group: '仓库信息', + columns: [ + { prop: 'warehouseType', label: '仓库属性', width: 100, visible: true }, + { prop: 'warehouseOwnership', label: '仓库归属', width: 100, visible: true }, + ], + }] + const storage = { + getItem: jest.fn(() => '1'), + setItem: jest.fn(), + } + + const migrated = migrateWarehouseColumnOrderOnce(settings, storage) + + expect(migrated).toBe(settings) + expect(storage.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts index a4f13cd4..3aa58b83 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts @@ -18,6 +18,55 @@ export type ColumnSetting = { } const STORAGE_KEY = 'caigou_archive_columns' +const WAREHOUSE_ORDER_MIGRATION_KEY = 'caigou_archive_columns_warehouse_order_v3' +const WAREHOUSE_COLUMN_ORDER = [ + 'inOutWarehouse', + 'warehouseHead', + 'warehouseOwnership', + 'warehouseType', +] as const + +export function migrateWarehouseColumnOrder( + settings: ColumnSetting[], +): ColumnSetting[] { + const orderByProp = new Map( + WAREHOUSE_COLUMN_ORDER.map((prop, index) => [prop, index]), + ) + + return settings.map((group) => { + if (group.group !== '仓库信息') return group + return { + ...group, + columns: group.columns + .map((column, index) => ({ column, index })) + .sort((left, right) => { + const leftOrder = orderByProp.get(left.column.prop) + const rightOrder = orderByProp.get(right.column.prop) + if (leftOrder === undefined && rightOrder === undefined) { + return left.index - right.index + } + if (leftOrder === undefined) return 1 + if (rightOrder === undefined) return -1 + return leftOrder - rightOrder + }) + .map(({ column }) => column), + } + }) +} + +export function migrateWarehouseColumnOrderOnce( + settings: ColumnSetting[], + storage: Pick, +): ColumnSetting[] { + if (storage.getItem(WAREHOUSE_ORDER_MIGRATION_KEY) === '1') { + return settings + } + + const migrated = migrateWarehouseColumnOrder(settings) + storage.setItem(STORAGE_KEY, JSON.stringify(migrated)) + storage.setItem(WAREHOUSE_ORDER_MIGRATION_KEY, '1') + return migrated +} export function persistColumnSettings(settings: ColumnSetting[]) { if (typeof window === 'undefined') return @@ -54,7 +103,7 @@ function loadSettings(): ColumnSetting[] { const saved = JSON.parse(raw) as ColumnSetting[] if (!Array.isArray(saved)) return defaults // 合并:以 defaults 为准,用 saved 的 visible/顺序覆盖 - return defaults.map((dg) => { + const merged = defaults.map((dg) => { const sg = saved.find((s) => s.group === dg.group) if (!sg) return dg // 字段顺序按 saved,但字段定义以 defaults 为准(处理增删) @@ -95,6 +144,7 @@ function loadSettings(): ColumnSetting[] { columns: [...orderedCols, ...customColumns], } }) + return migrateWarehouseColumnOrderOnce(merged, localStorage) } catch { return defaults } diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveImageExport.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveImageExport.ts new file mode 100644 index 00000000..f4a0eb0b --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveImageExport.ts @@ -0,0 +1,57 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive' + +function exportFileName(contentDisposition: string | null) { + const encoded = contentDisposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (!encoded) return '产品档案图片URL.xlsx' + try { + return decodeURIComponent(encoded) + } catch { + return '产品档案图片URL.xlsx' + } +} + +async function exportErrorMessage(response: Response) { + const payload = await response.json().catch(() => null) as { + error?: { message?: string } + } | null + return payload?.error?.message || '图片 URL 导出失败' +} + +export function useProductArchiveImageExport(filters: ProductArchiveFilters) { + const [exporting, setExporting] = useState(false) + + const exportImages = useCallback(async () => { + if (exporting) return + setExporting(true) + + try { + const params = new URLSearchParams() + if (Object.keys(filters).length > 0) { + params.set('filters', JSON.stringify(filters)) + } + const query = params.size > 0 ? `?${params.toString()}` : '' + const response = await fetch( + `/api/supply-chain/product-archive/image-export${query}`, + ) + if (!response.ok) throw new Error(await exportErrorMessage(response)) + + const url = URL.createObjectURL(await response.blob()) + const link = document.createElement('a') + link.href = url + link.download = exportFileName(response.headers.get('Content-Disposition')) + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + } catch (error) { + window.alert(error instanceof Error ? error.message : '图片 URL 导出失败') + } finally { + setExporting(false) + } + }, [exporting, filters]) + + return { exporting, exportImages } +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchivePageImages.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchivePageImages.ts index 4780c7ec..acfb5059 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchivePageImages.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchivePageImages.ts @@ -10,7 +10,6 @@ import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-ar import type { ApiResponse } from '@/server/core/types' import { canResolveProductArchiveMaterialImage, - hasProductDevelopmentSkuImage, } from './productArchiveImagePriority' const PAGE_STABLE_MS = 600 @@ -30,7 +29,7 @@ type CachedImage = { } function canResolveMaterialImage(row: SupplyChainProductArchiveRow) { - return !hasProductDevelopmentSkuImage(row) && canResolveProductArchiveMaterialImage(row) + return canResolveProductArchiveMaterialImage(row) } function cacheKeyForRow(row: SupplyChainProductArchiveRow) { diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountBatchUpdateDialog.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountBatchUpdateDialog.tsx new file mode 100644 index 00000000..da943493 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountBatchUpdateDialog.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { X } from 'lucide-react' +import type { + StoreAccountBatchEditableFieldKey, + StoreAccountBatchPreview, + StoreAccountBatchPreviewRow, +} from '@/lib/store-account-management' + +type BatchField = { key: StoreAccountBatchEditableFieldKey; label: string } + +type Props = { + ids: string[] + fields: BatchField[] + field: StoreAccountBatchEditableFieldKey + value: string + onFieldChange: (value: StoreAccountBatchEditableFieldKey) => void + onValueChange: (value: string) => void + onClose: () => void + onSave: () => Promise +} + +async function readPreviewResponse(response: Response): Promise { + const payload = await response.json().catch(() => null) as { + success?: boolean + data?: StoreAccountBatchPreview + error?: { message?: string } + } | null + if (!response.ok || !payload?.success || !payload.data) { + throw new Error(payload?.error?.message || '批量修改预览加载失败') + } + return payload.data +} + +function displayFieldValue(field: StoreAccountBatchEditableFieldKey, value: string) { + if (!value.trim()) return '未填写' + if (field === 'accountType') return value === 'MAIN' ? '主账号' : value === 'SUB' ? '子账号' : value + return value +} + +function displayAccount(row: StoreAccountBatchPreviewRow) { + const type = row.accountType === 'MAIN' ? '主账号' : row.accountType === 'SUB' ? '子账号' : '' + if (row.account === null && !type) return '不可见' + return [type, row.account ?? '账号不可见'].filter(Boolean).join(' · ') +} + +export function StoreAccountBatchUpdateDialog({ + ids, + fields, + field, + value, + onFieldChange, + onValueChange, + onClose, + onSave, +}: Props) { + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(true) + const [previewError, setPreviewError] = useState('') + const [saving, setSaving] = useState(false) + const fieldLabel = fields.find((item) => item.key === field)?.label ?? field + const normalizedNextValue = value.trim() + const nextDisplayValue = normalizedNextValue ? displayFieldValue(field, normalizedNextValue) : '待输入' + + useEffect(() => { + const controller = new AbortController() + let active = true + setPreview(null) + setPreviewError('') + setPreviewLoading(true) + + void fetch('/api/supply-chain/store-account-management/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, field }), + signal: controller.signal, + }).then(readPreviewResponse).then((result) => { + if (active) setPreview(result) + }).catch((error) => { + if (active && !(error instanceof Error && error.name === 'AbortError')) { + setPreviewError(error instanceof Error ? error.message : '批量修改预览加载失败') + } + }).finally(() => { + if (active) setPreviewLoading(false) + }) + + return () => { + active = false + controller.abort() + } + }, [field, ids]) + + const counts = useMemo(() => { + if (!preview || !normalizedNextValue) return { changed: 0, unchanged: 0 } + return preview.rows.reduce((current, row) => { + if (row.currentValue.trim() === normalizedNextValue) current.unchanged += 1 + else current.changed += 1 + return current + }, { changed: 0, unchanged: 0 }) + }, [normalizedNextValue, preview]) + + const save = async () => { + setSaving(true) + try { + await onSave() + } catch (error) { + window.alert(error instanceof Error ? error.message : '批量修改失败') + } finally { + setSaving(false) + } + } + + return ( +
{ if (event.target === event.currentTarget) onClose() }}> +
+
+
+

批量修改店铺账号

+

已勾选 {ids.length} 条账号,不会修改密码。

+
+ +
+ + {fields.length === 0 ? ( +

当前没有可批量修改的字段权限。

+ ) : ( + <> + + +
+
+

修改前后预览

+ {previewLoading ? '正在读取...' : `已读取 ${preview?.found ?? 0} / ${ids.length} 条`} +
+
+ + + + + + + + + + + + {previewLoading ? ( + + ) : previewError ? ( + + ) : preview?.rows.length ? preview.rows.map((row) => { + const unchanged = Boolean(normalizedNextValue) && row.currentValue.trim() === normalizedNextValue + return ( + + + + + + + + ) + }) : ( + + )} + +
店铺名称账号修改前:{fieldLabel}统一修改后状态
正在读取修改前内容...
{previewError}
{row.storeName ?? '不可见'}{displayAccount(row)}{displayFieldValue(field, row.currentValue)}{nextDisplayValue}{!normalizedNextValue ? '待输入' : unchanged ? '无变化' : '将修改'}
没有可修改的账号记录。
+
+ {preview && preview.missing > 0 &&

有 {preview.missing} 条账号已不存在,本次不会修改。

} +
+ + +

+ {normalizedNextValue ? `预计修改 ${counts.changed} 条,${counts.unchanged} 条无变化。` : '输入新值后,可在上方逐条核对修改结果。'} +

+ + )} + +
+ + +
+
+ + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountLogDialog.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountLogDialog.tsx new file mode 100644 index 00000000..a06c9419 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountLogDialog.tsx @@ -0,0 +1,92 @@ +'use client' + +import { ArrowRight, Eye, History, X } from 'lucide-react' +import { + formatStoreAccountLogValue, + getStoreAccountLogFieldLabel, + getStoreAccountLogOperationLabel, + normalizeStoreAccountLogChanges, + type StoreAccountChangeLogEntry, +} from '@/lib/store-account-management' + +function formatTime(value: string) { + const date = new Date(value) + return Number.isNaN(date.getTime()) + ? '-' + : new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} + +export function StoreAccountLogDialog({ + logs, + onClose, +}: { + logs: StoreAccountChangeLogEntry[] + onClose: () => void +}) { + return ( +
{ if (event.target === event.currentTarget) onClose() }} + > +
+
+
+

更新日志

+

账号资料的新增、修改与敏感信息查看记录

+
+ +
+ +
+ {logs.length === 0 ? ( +
+ +

暂无更新日志

+
+ ) : logs.map((entry) => { + const changes = normalizeStoreAccountLogChanges(entry.changes) + const passwordViewed = entry.operation === 'VIEW_PASSWORD' + return ( +
+
+ + {getStoreAccountLogOperationLabel(entry.operation)} + + {entry.actorName || '未知操作人'} · {formatTime(entry.createdAt)} +
+ + {passwordViewed ? ( +
+ + 已查看密码 +
+ ) : changes.length === 0 ? ( +

无字段变更明细

+ ) : ( +
+ {changes.map((change, index) => ( +
+ {getStoreAccountLogFieldLabel(change.field)} +
+ + 修改前{formatStoreAccountLogValue(change.previousValue)} + + + + 修改后{formatStoreAccountLogValue(change.nextValue)} + +
+
+ ))} +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx new file mode 100644 index 00000000..d5309582 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx @@ -0,0 +1,313 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { ArrowLeft, ArrowRight, ChevronDown, Eye, EyeOff, FileDown, History, KeyRound, Plus, RotateCcw, Search, Settings2, Upload, X } from 'lucide-react' +import type { StoreAccountBatchEditableFieldKey, StoreAccountChangeLogEntry, StoreAccountFieldAccess, StoreAccountFieldKey, StoreAccountPage, StoreAccountRow, StoreAccountFilters } from '@/lib/store-account-management' +import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FIELDS } from '@/lib/store-account-management' +import { StoreAccountBatchUpdateDialog } from './StoreAccountBatchUpdateDialog' +import { StoreAccountLogDialog } from './StoreAccountLogDialog' +import { StoreAccountPermissionDialog } from './StoreAccountPermissionDialog' + +type Props = { initialData: StoreAccountPage; initialFilters: StoreAccountFilters; canEdit: boolean; canManagePermissions: boolean } +type Draft = Partial> & { id?: string } + +async function readResponse(response: Response) { + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败') + return payload.data +} + +function formatTime(value: string) { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '-' : new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} + +function rowToDraft(row: StoreAccountRow): Draft { + return { + id: row.id, + storeName: row.storeName, + operationOwner: row.operationOwner, + operationDepartmentHead: row.operationDepartmentHead, + operationDirector: row.operationDirector, + profitCenter: row.profitCenter, + accountType: row.accountType, + account: row.account, + password: '', + boundPhone: row.boundPhone, + verificationContact: row.verificationContact, + jstOrderService: row.jstOrderService, + accountGroupName: row.accountGroupName, + } +} + +export function StoreAccountManagementView({ initialData, initialFilters, canEdit, canManagePermissions }: Props) { + const [data, setData] = useState(initialData) + const [filters, setFilters] = useState(initialFilters) + const [draft, setDraft] = useState(null) + const [logs, setLogs] = useState(null) + const [permissionsOpen, setPermissionsOpen] = useState(false) + const [batchOpen, setBatchOpen] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + const [batchField, setBatchField] = useState('operationOwner') + const [batchValue, setBatchValue] = useState('') + const [revealedPasswords, setRevealedPasswords] = useState>({}) + const [loading, setLoading] = useState(false) + const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page)) + const fileRef = useRef(null) + const visibleFields = useMemo(() => STORE_ACCOUNT_FIELDS.filter((field) => data.fieldAccess[field.key] !== 'HIDDEN'), [data.fieldAccess]) + const batchFields = useMemo(() => STORE_ACCOUNT_FIELDS + .filter((field) => !field.system && data.fieldAccess[field.key] === 'EDITABLE' && field.key !== 'password') + .map((field) => ({ key: field.key as StoreAccountBatchEditableFieldKey, label: field.label })), [data.fieldAccess]) + + useEffect(() => { + if (!batchFields.some((field) => field.key === batchField) && batchFields[0]) setBatchField(batchFields[0].key) + }, [batchField, batchFields]) + + useEffect(() => { + setPageInput(String(data.pageInfo.page)) + }, [data.pageInfo.page]) + + const commitPageInput = () => { + const parsed = Number(pageInput) + if (!Number.isFinite(parsed)) { + setPageInput(String(data.pageInfo.page)) + return + } + const nextPage = Math.min(Math.max(1, Math.trunc(parsed)), data.pageInfo.totalPages) + setPageInput(String(nextPage)) + if (nextPage !== data.pageInfo.page) void load(filters, nextPage, data.pageInfo.pageSize) + } + + const load = async (nextFilters = filters, page = data.pageInfo.page, pageSize = data.pageInfo.pageSize) => { + setLoading(true) + try { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) + Object.entries(nextFilters).forEach(([key, value]) => { if (value) params.set(key, value) }) + const next = await readResponse(await fetch(`/api/supply-chain/store-account-management?${params}`, { cache: 'no-store' })) + setData(next); setFilters(nextFilters) + } catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) } + } + + const resetFilters = () => { + setFilters(EMPTY_STORE_ACCOUNT_FILTERS) + void load(EMPTY_STORE_ACCOUNT_FILTERS, 1, data.pageInfo.pageSize) + } + + const save = async () => { + if (!draft) return + const allowedKeys = new Set(STORE_ACCOUNT_FIELDS.filter((field) => !field.system && data.fieldAccess[field.key] === 'EDITABLE').map((field) => field.key)) + const body = Object.fromEntries(Object.entries(draft).filter(([key, value]) => allowedKeys.has(key as StoreAccountFieldKey) && value !== undefined && (key !== 'password' || value !== ''))) + const response = await fetch(draft.id ? `/api/supply-chain/store-account-management/${draft.id}` : '/api/supply-chain/store-account-management', { method: draft.id ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + await readResponse(response) + setDraft(null) + await load(filters, data.pageInfo.page, data.pageInfo.pageSize) + } + + const importFile = async (file: File) => { + const form = new FormData(); form.append('file', file) + const result = await readResponse(await fetch('/api/supply-chain/store-account-management/import', { method: 'POST', body: form })) + window.alert(`导入完成:新增 ${result.created} 条,更新 ${result.updated} 条`) + await load(filters, 1, data.pageInfo.pageSize) + } + + const downloadTemplate = async () => { + const XLSX = await import('xlsx') + const headers = STORE_ACCOUNT_FIELDS.filter((field) => !field.system).map((field) => field.label) + const sheet = XLSX.utils.aoa_to_sheet([headers]) + sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 4) })) + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, sheet, '店铺账号导入') + XLSX.writeFile(workbook, '店铺账号导入模板.xlsx') + } + + const batchUpdate = async (field = batchField, value = batchValue) => { + if (selectedIds.length === 0 || !value.trim()) return + await readResponse(await fetch('/api/supply-chain/store-account-management/batch', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds, field, value }) })) + setSelectedIds([]); setBatchValue(''); setBatchOpen(false) + await load(filters, data.pageInfo.page, data.pageInfo.pageSize) + } + + const openLogs = async (row: StoreAccountRow) => { + const result = await readResponse(await fetch(`/api/supply-chain/store-account-management/logs?id=${encodeURIComponent(row.id)}`, { cache: 'no-store' })) + setLogs(result as StoreAccountChangeLogEntry[]) + } + + const togglePassword = async (row: StoreAccountRow) => { + if (revealedPasswords[row.id] !== undefined) { + setRevealedPasswords(({ [row.id]: _hidden, ...rest }) => rest) + return + } + const result = await readResponse(await fetch(`/api/supply-chain/store-account-management/${encodeURIComponent(row.id)}/password`, { cache: 'no-store' })) + setRevealedPasswords((current) => ({ ...current, [row.id]: result.password })) + } + + return
+
+

店铺账号管理

人工维护店铺账号、负责人和聚水潭服务信息

+
共 {data.pageInfo.total} 条{canManagePermissions && }{canEdit && <> { const file = e.target.files?.[0]; if (file) void importFile(file); e.currentTarget.value = '' }} />}
+
+
{ e.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}> + setFilters({ ...filters, search: e.target.value })} placeholder="批量搜索,多个关键词用逗号分隔" /> + { + const nextFilters = { ...filters, storeName } + setFilters(nextFilters) + void load(nextFilters, 1, data.pageInfo.pageSize) + }} + /> + + setFilters({ ...filters, operationOwner: e.target.value })} placeholder="运营负责人" /> + setFilters({ ...filters, accountGroupName: e.target.value })} placeholder="账号分组" /> + + +
{canEdit && }{visibleFields.map((field) => )}{loading ? : data.rows.length === 0 ? : data.rows.map((row) => {canEdit && }{visibleFields.map((field) => )})}
0 && data.rows.every((row) => selectedIds.includes(row.id))} onChange={(e) => setSelectedIds(e.target.checked ? Array.from(new Set([...selectedIds, ...data.rows.map((row) => row.id)])) : selectedIds.filter((id) => !data.rows.some((row) => row.id === id)))} />{field.label}操作
加载中...
暂无店铺账号
setSelectedIds(e.target.checked ? [...selectedIds, row.id] : selectedIds.filter((id) => id !== row.id))} />{field.key === 'accountType' ? row.accountType === 'MAIN' ? '主账号' : '子账号' : field.key === 'password' ? {revealedPasswords[row.id] !== undefined ? revealedPasswords[row.id] || '未设置' : row.passwordConfigured ? '••••••' : '未设置'}{row.passwordConfigured && } : field.key === 'createdAt' || field.key === 'updatedAt' ? formatTime(row[field.key]) : String(row[field.key as keyof StoreAccountRow] ?? '-')}{canEdit && }
+
+ {loading ? '正在统计...' : `共 ${data.pageInfo.total} 条`} + + setPageInput(event.target.value.replace(/\D/g, ''))} onBlur={commitPageInput} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); commitPageInput() } }} />/{data.pageInfo.totalPages} 页 + + 每页条 +
+ {draft && setDraft(null)} onSave={() => void save()} />} + {logs && setLogs(null)} />} + {permissionsOpen && setPermissionsOpen(false)} onSaved={() => load(filters, data.pageInfo.page, data.pageInfo.pageSize)} />} + {batchOpen && setBatchOpen(false)} onSave={batchUpdate} />} + +
+} + +function StoreNameMultiSelect({ options, value, onApply }: { options: string[]; value: string; onApply: (value: string) => void }) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [draftSelected, setDraftSelected] = useState([]) + const pickerRef = useRef(null) + const appliedSelected = value.split(',').map((item) => item.trim()).filter(Boolean) + const draftSelectedSet = new Set(draftSelected) + const normalizedSearch = search.trim().toLocaleLowerCase() + const filtered = options.filter((item) => item.toLocaleLowerCase().includes(normalizedSearch)) + + useEffect(() => { + if (!open) return + + const closeOnOutsideClick = (event: MouseEvent) => { + if (!pickerRef.current?.contains(event.target as Node)) setOpen(false) + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('mousedown', closeOnOutsideClick) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick) + document.removeEventListener('keydown', closeOnEscape) + } + }, [open]) + + const toggle = (name: string) => { + setDraftSelected((current) => current.includes(name) ? current.filter((item) => item !== name) : [...current, name]) + } + const openPicker = () => { + if (open) { + setOpen(false) + return + } + setDraftSelected(appliedSelected) + setSearch('') + setOpen(true) + } + const clear = () => { + setDraftSelected([]) + setOpen(false) + onApply('') + } + const apply = () => { + setOpen(false) + onApply(draftSelected.join(',')) + } + + return ( +
+ + {open && ( +
+
+ + setSearch(event.target.value)} + placeholder="输入店铺名称搜索" + /> + {search && ( + + )} +
+ {draftSelected.length > 0 &&
已选 {draftSelected.length} 项
} +
+ {filtered.length === 0 ? ( +
暂无匹配店铺
+ ) : filtered.map((name) => ( + + ))} +
+
+ + +
+
+ )} +
+ ) +} + +function AccountDialog({ draft, access, onChange, onClose, onSave }: { draft: Draft; access: StoreAccountFieldAccess; onChange: (value: Draft) => void; onClose: () => void; onSave: () => void }) { + const editable = STORE_ACCOUNT_FIELDS.filter((field) => !field.system && access[field.key] === 'EDITABLE') + return
{ if (e.target === e.currentTarget) onClose() }}>

{draft.id ? '编辑店铺账号' : '新增店铺账号'}

{editable.map((field) => )}
+} + +const styles = ` +.store-account-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.store-account-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:20px 28px 16px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-toolbar h1{margin:0;font-size:20px;font-weight:650}.store-account-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.store-account-actions{display:flex;align-items:center;gap:8px;color:#71717a;font-size:12px}.store-account-actions button,.store-account-filters button,.store-account-pagination button,.store-account-batch button,.modal-actions button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:5px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;cursor:pointer}.store-account-actions .primary,.primary{border-color:#27272a!important;background:#27272a!important;color:#fff!important}.store-account-filters{display:flex;align-items:center;gap:8px;padding:12px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-filters input,.store-account-filters select,.store-account-batch input,.store-account-batch select{height:34px;min-width:130px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#3f3f46;font-size:12px}.store-account-filters input:first-child{width:250px}.icon-button{width:34px;padding:0!important}.store-account-batch{display:flex;align-items:center;gap:8px;padding:8px 28px;border-bottom:1px solid #e4e4e7;background:#fff;color:#52525b;font-size:12px}.store-account-batch input{width:240px}.store-account-batch button:disabled{opacity:.45;cursor:not-allowed}.store-account-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.store-account-table-wrap table{width:100%;min-width:1320px;border-collapse:collapse;background:#fff;font-size:12px}.store-account-table-wrap th{position:sticky;top:0;height:42px;padding:0 12px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;text-align:left;white-space:nowrap}.store-account-table-wrap td{height:46px;padding:0 12px;border-bottom:1px solid #f0f0f1;color:#3f3f46;white-space:nowrap}.store-account-table-wrap tr:hover td{background:#fafafa}.select-column{width:38px;padding:0 8px!important;text-align:center}.row-actions{display:flex;gap:10px}.row-actions button,.password-cell button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;cursor:pointer;font-size:12px}.password-cell{display:inline-flex;align-items:center;gap:5px}.password-cell button{padding:2px}.empty{text-align:center;height:180px!important;color:#a1a1aa!important}.store-account-pagination{position:absolute;bottom:24px;left:50%;z-index:30;display:flex;align-items:center;gap:10px;padding:8px 16px;transform:translateX(-50%);border:1px solid #e4e4e7;border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 4px 12px rgba(0,0,0,.06),0 2px 4px rgba(0,0,0,.04);color:#52525b;font-size:13px;white-space:nowrap;backdrop-filter:blur(16px)}.store-account-page-total{margin-right:8px}.store-account-page-btn{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 12px!important;border:1px solid #e4e4e7!important;border-radius:4px!important;background:#fff!important;color:#27272a!important;font-size:13px!important;cursor:pointer;transition:all .15s}.store-account-page-btn:hover:not(:disabled){border-color:#52525b!important;background:#f4f4f5!important}.store-account-page-btn:disabled{color:#a1a1aa!important;cursor:not-allowed;opacity:.5}.store-account-pagination > .store-account-page-btn{height:30px!important}.store-account-page-num{display:inline-flex;align-items:center;gap:4px;color:#52525b}.store-account-page-input{width:52px;height:28px;padding:0 6px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;font-weight:600;text-align:center;outline:none}.store-account-page-input:focus{border-color:#52525b;box-shadow:0 0 0 2px rgba(82,82,91,.12)}.store-account-page-input:disabled{color:#a1a1aa;background:#f4f4f5}.store-account-page-sep{color:#a1a1aa}.store-account-page-size-wrap{display:inline-flex;align-items:center;gap:6px;margin-left:8px;color:#52525b}.store-account-page-size{height:28px;padding:0 8px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;cursor:pointer;outline:none}.store-account-page-size:focus{border-color:#52525b}.modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.28)}.modal{width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.modal header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.modal h2{margin:0;font-size:16px}.modal header button{border:0;background:transparent;color:#71717a;cursor:pointer}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}.modal label{display:flex;flex-direction:column;gap:6px;color:#52525b;font-size:12px}.modal input,.modal select{height:36px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#27272a;font-size:13px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.hint{margin:0 0 16px;color:#71717a;font-size:12px}@media(max-width:760px){.store-account-toolbar{align-items:flex-start;flex-direction:column;padding:16px}.store-account-filters{flex-wrap:wrap;padding:12px 16px}.store-account-batch{flex-wrap:wrap;padding:8px 16px}.store-account-table-wrap{padding:0 16px}.store-account-pagination{bottom:12px;max-width:calc(100% - 24px);gap:6px;padding:7px 10px;overflow-x:auto}.store-account-table-wrap{padding:0 16px}.form-grid{grid-template-columns:1fr}} +.store-account-filters{position:relative;z-index:40;overflow:visible} +.store-name-picker{position:relative;width:180px;flex:0 0 180px} +.store-name-trigger{display:flex;width:100%;height:34px;align-items:center;justify-content:space-between;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#71717a;font-size:12px;cursor:pointer} +.store-name-trigger:hover,.store-name-trigger.is-open{border-color:#71717a;color:#27272a} +.store-name-trigger>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.store-name-trigger svg{flex:0 0 auto;transition:transform .15s ease} +.store-name-trigger svg.is-open{transform:rotate(180deg)} +.store-name-menu{position:absolute;top:calc(100% + 7px);left:0;z-index:80;width:286px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:10px;border:1px solid #d4d4d8;border-radius:6px;background:#fff;box-shadow:0 10px 24px rgba(0,0,0,.12)} +.store-name-search{display:flex;height:32px;align-items:center;gap:6px;padding:0 8px;border:1px solid #d4d4d8;border-radius:4px;background:#fff;color:#71717a} +.store-name-search:focus-within{border-color:#52525b} +.store-account-filters .store-name-search input{width:100%;min-width:0;height:100%;padding:0;border:0;background:transparent;box-shadow:none;color:#27272a;font-size:13px} +.store-account-filters .store-name-search-clear{display:inline-flex;width:20px;min-width:20px;height:20px;padding:0;border:0;background:transparent;color:#a1a1aa} +.store-name-selected-summary{padding:8px 3px 0;color:#52525b;font-size:12px;font-weight:600} +.store-account-filters .store-name-option input{width:14px;min-width:14px;height:14px;margin:0;padding:0;accent-color:#27272a} +.store-name-options{display:flex;max-height:254px;flex-direction:column;gap:2px;margin-top:8px;overflow-y:auto} +.store-name-option{display:flex;min-height:30px;align-items:center;gap:7px;padding:4px 5px;border-radius:4px;color:#3f3f46;font-size:13px;cursor:pointer} +.store-name-option:hover,.store-name-option.is-selected{background:#f4f4f5} +.store-name-option.is-selected{color:#18181b;font-weight:550} +.store-name-option>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.store-name-empty{padding:24px 8px;text-align:center;color:#a1a1aa;font-size:12px} +.store-name-menu-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:10px} +.store-account-filters .store-name-menu-actions button{height:28px;padding:0 10px;border-radius:4px;font-size:12px;cursor:pointer} +.store-account-filters .store-name-clear{border:1px solid #d4d4d8;background:#fff;color:#52525b} +.store-account-filters .store-name-apply{border:1px solid #27272a;background:#27272a;color:#fff} +.store-account-actions button:disabled{opacity:.45;cursor:not-allowed} +` diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountPermissionDialog.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountPermissionDialog.tsx new file mode 100644 index 00000000..4b7cc50f --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountPermissionDialog.tsx @@ -0,0 +1,309 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { Save, Search, UserRound, Users, X } from 'lucide-react' +import { + isStoreAccountFieldVisibility, + resolveStoreAccountFieldVisibility, + STORE_ACCOUNT_FIELDS, + type StoreAccountFieldKey, + type StoreAccountFieldPermissionRecord, + type StoreAccountFieldPermissionUpdate, + type StoreAccountFieldPermissionValue, +} from '@/lib/store-account-management' +import { + resolveSupplyChainPermissionSubjectKeys, + type SupplyChainPermissionOverrideMap, +} from '@/lib/supply-chain-permission-settings' +import { + usePermissionManagedUsers, + type PermissionManagedUser, +} from '@/app/users/components/usePermissionManagedUsers' +import { + getStoreAccountFieldPermissionBusinessTypes, + getStoreAccountPageAccessLabel, + NO_STORE_ACCOUNT_PAGE_ACCESS, + resolveStoreAccountUserPageAccess, +} from './storeAccountPermissionScope' + +type PermissionSettings = { + permissions: StoreAccountFieldPermissionRecord[] + pagePermissionOverrides: SupplyChainPermissionOverrideMap +} + +const VISIBILITY_OPTIONS: Array<{ value: Exclude; label: string }> = [ + { value: 'HIDDEN', label: '不可见' }, + { value: 'READONLY', label: '只读' }, + { value: 'EDITABLE', label: '可编辑' }, +] + +const VISIBILITY_LABELS: Record, string> = { + HIDDEN: '不可见', + READONLY: '只读', + EDITABLE: '可编辑', +} + +function permissionKey(subjectKey: string, fieldKey: StoreAccountFieldKey) { + return `${subjectKey}::${fieldKey}` +} + +async function readResponse(response: Response) { + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败') + return payload.data +} + +function userSubtitle(user: PermissionManagedUser) { + return user.department?.name || user.email || '未设置部门' +} + +export function StoreAccountPermissionDialog({ + onClose, + onSaved, +}: { + onClose: () => void + onSaved: () => void | Promise +}) { + const managedUsers = usePermissionManagedUsers() + const [roleKey, setRoleKey] = useState('operation') + const [selectedUserId, setSelectedUserId] = useState('') + const [search, setSearch] = useState('') + const [permissions, setPermissions] = useState([]) + const [pagePermissionOverrides, setPagePermissionOverrides] = useState({}) + const [changes, setChanges] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const configurableFields = STORE_ACCOUNT_FIELDS.filter((field) => !field.system) + + const loadSettings = async () => { + setLoading(true) + setMessage('') + try { + const data = await readResponse(await fetch('/api/supply-chain/store-account-management/permissions', { cache: 'no-store' })) as PermissionSettings + setPermissions(data.permissions) + setPagePermissionOverrides(data.pagePermissionOverrides) + setChanges({}) + } catch (error) { + setMessage(error instanceof Error ? error.message : '字段权限读取失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadSettings() + }, []) + + const storedValues = useMemo(() => Object.fromEntries(permissions.map((item) => [ + permissionKey(item.subjectKey, item.fieldKey), + item.visibility, + ])) as Record>, [permissions]) + + const businessTypeOptions = useMemo(() => getStoreAccountFieldPermissionBusinessTypes( + pagePermissionOverrides, + managedUsers.users, + ), [managedUsers.users, pagePermissionOverrides]) + + const activeBusinessType = businessTypeOptions.find((businessType) => businessType.key === roleKey) ?? null + + useEffect(() => { + if (activeBusinessType) return + setRoleKey(businessTypeOptions[0]?.key ?? '') + setSelectedUserId('') + setSearch('') + }, [activeBusinessType, businessTypeOptions]) + + const eligibleUsers = useMemo(() => activeBusinessType?.users ?? [], [activeBusinessType]) + + const visibleUsers = useMemo(() => { + const normalized = search.trim().toLocaleLowerCase() + if (!normalized) return eligibleUsers + return eligibleUsers.filter((user) => `${user.name} ${user.email} ${user.department?.name ?? ''}`.toLocaleLowerCase().includes(normalized)) + }, [eligibleUsers, search]) + + const selectedUser = eligibleUsers.find((user) => user.id === selectedUserId) ?? null + const selectedUserPageAccess = selectedUser + ? resolveStoreAccountUserPageAccess(pagePermissionOverrides, selectedUser) + : null + const activePageAccess = selectedUserPageAccess ?? activeBusinessType?.pageAccess ?? NO_STORE_ACCOUNT_PAGE_ACCESS + + const getValue = (subjectKey: string, fieldKey: StoreAccountFieldKey): StoreAccountFieldPermissionValue => { + const key = permissionKey(subjectKey, fieldKey) + return Object.prototype.hasOwnProperty.call(changes, key) + ? changes[key]!.visibility + : storedValues[key] ?? null + } + + const setValue = (subjectKey: string, fieldKey: StoreAccountFieldKey, visibility: StoreAccountFieldPermissionValue) => { + const key = permissionKey(subjectKey, fieldKey) + const stored = storedValues[key] ?? null + setChanges((current) => { + const next = { ...current } + if (stored === visibility) delete next[key] + else next[key] = { subjectKey, fieldKey, visibility } + return next + }) + setMessage('') + } + + const getEffectiveValue = ( + fieldKey: StoreAccountFieldKey, + roleValue: StoreAccountFieldPermissionValue, + personalValue: StoreAccountFieldPermissionValue, + ) => { + const roleValues = selectedUser + ? resolveSupplyChainPermissionSubjectKeys(selectedUser) + .map((subjectKey) => getValue(subjectKey, fieldKey)) + .filter(isStoreAccountFieldVisibility) + : roleValue && isStoreAccountFieldVisibility(roleValue) ? [roleValue] : [] + return resolveStoreAccountFieldVisibility({ + fieldKey, + pageView: activePageAccess.view, + pageEdit: activePageAccess.edit, + fallback: fieldKey === 'password' ? 'HIDDEN' : activePageAccess.edit ? 'EDITABLE' : 'READONLY', + roleValues, + personalValue, + }) + } + + const save = async () => { + const updates = Object.values(changes) + if (updates.length === 0) return + setSaving(true) + setMessage('') + try { + await readResponse(await fetch('/api/supply-chain/store-account-management/permissions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates }), + })) + await loadSettings() + await onSaved() + setMessage('权限已保存') + } catch (error) { + setMessage(error instanceof Error ? error.message : '字段权限保存失败') + } finally { + setSaving(false) + } + } + + const selectRole = (nextRoleKey: string) => { + setRoleKey(nextRoleKey) + setSelectedUserId('') + setSearch('') + } + + return ( +
{ if (event.target === event.currentTarget) onClose() }}> +
+
+
+

字段权限

+

业务类型默认 · 人员例外

+
+ +
+ +
+ + +
+
+
+

{selectedUser ? selectedUser.name : activeBusinessType ? `${activeBusinessType.label}默认权限` : '暂无可配置对象'}

+

{selectedUser ? `${userSubtitle(selectedUser)} · ${getStoreAccountPageAccessLabel(activePageAccess)}` : getStoreAccountPageAccessLabel(activePageAccess)}

+
+ {message && {message}} +
+ +
+ + + + + + {selectedUser && } + {selectedUser && } + + + + {configurableFields.map((field) => { + const roleValue = getValue(roleKey, field.key) + const personalValue = selectedUser ? getValue(`user@${selectedUser.id}`, field.key) : null + const effectiveValue = getEffectiveValue(field.key, roleValue, personalValue) + return + + + {selectedUser && } + {selectedUser && } + + })} + +
字段业务类型权限个人例外本层结果
{field.label}{field.sensitive && 保密} + + + + {VISIBILITY_LABELS[effectiveValue]}
+
+
+
+ +
+ {Object.keys(changes).length > 0 ? `待保存 ${Object.keys(changes).length} 项` : '没有未保存修改'} +
+ + +
+
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx new file mode 100644 index 00000000..28354004 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/server-session' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' +import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS, type StoreAccountFilters } from '@/lib/store-account-management' +import { StoreAccountManagementView } from './StoreAccountManagementView' + +export const dynamic = 'force-dynamic' +export const metadata = { title: '店铺账号管理 · 供应链中心' } + +function first(value: string | string[] | undefined) { return (Array.isArray(value) ? value[0] : value ?? '').trim() } + +export default async function StoreAccountManagementPage({ searchParams }: { searchParams: Promise> }) { + const { authUser } = await requireSession('/login') + const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'storeAccountManagement') + if (!access.view) redirect('/supply-chain') + const params = await searchParams + const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, first(params[key]).slice(0, 120)])) as StoreAccountFilters + const page = Math.max(1, Number(first(params.page)) || 1) + const pageSize = [50, 100, 200].includes(Number(first(params.pageSize))) ? Number(first(params.pageSize)) : 50 + const data = await storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: authUser }) + return +} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.test.ts b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.test.ts new file mode 100644 index 00000000..cb40ae64 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.test.ts @@ -0,0 +1,37 @@ +import type { PermissionManagedUser } from '@/app/users/components/usePermissionManagedUsers' +import { getStoreAccountFieldPermissionBusinessTypes } from './storeAccountPermissionScope' + +function createUser(input: Partial & Pick): PermissionManagedUser { + return { + email: `${input.id}@example.com`, + role: 'USER', + status: 'ACTIVE', + ...input, + } +} + +describe('getStoreAccountFieldPermissionBusinessTypes', () => { + it('keeps only real business types and users with final page access', () => { + const users = [ + createUser({ id: 'operation-allowed', name: '运营可见', departmentPaths: ['运营中心'] }), + createUser({ id: 'operation-blocked', name: '运营屏蔽', departmentPaths: ['运营中心'] }), + createUser({ id: 'planning-exception', name: '企划例外', departmentPaths: ['品牌中心>商品企划部'] }), + createUser({ id: 'space-exception', name: '空间例外', departmentPaths: ['品牌中心>空间视觉部'] }), + ] + const result = getStoreAccountFieldPermissionBusinessTypes({ + 'storeAccountManagement:operation:view': true, + 'storeAccountManagement:user@operation-blocked:view': false, + 'storeAccountManagement:user@planning-exception:view': true, + 'storeAccountManagement:user@space-exception:view': true, + }, users) + + expect(result.map((item) => item.key)).toEqual(['planning', 'operation']) + expect(result.find((item) => item.key === 'operation')?.users.map((user) => user.id)).toEqual([ + 'operation-allowed', + ]) + expect(result.find((item) => item.key === 'planning')?.pageAccess.view).toBe(false) + expect(result.find((item) => item.key === 'planning')?.users.map((user) => user.id)).toEqual([ + 'planning-exception', + ]) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.ts b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.ts new file mode 100644 index 00000000..a5d92265 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountPermissionScope.ts @@ -0,0 +1,58 @@ +import type { PermissionManagedUser } from '@/app/users/components/usePermissionManagedUsers' +import { STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES } from '@/lib/store-account-management' +import { + getSupplyChainRoleDefaultAccess, + resolveSupplyChainPermissionAccess, + resolveSupplyChainPermissionSubjectKeys, + type SupplyChainPermissionOverrideMap, +} from '@/lib/supply-chain-permission-settings' + +export type StoreAccountPageAccess = { + view: boolean + download: boolean + edit: boolean +} + +export const NO_STORE_ACCOUNT_PAGE_ACCESS: StoreAccountPageAccess = { + view: false, + download: false, + edit: false, +} + +export function getStoreAccountPageAccessLabel(access: StoreAccountPageAccess) { + if (access.edit) return '页面可操作' + if (access.view) return '页面只读' + return '页面未开放' +} + +export function resolveStoreAccountUserPageAccess( + overrides: SupplyChainPermissionOverrideMap, + user: PermissionManagedUser, +) { + return getSupplyChainRoleDefaultAccess(user) ?? resolveSupplyChainPermissionAccess( + overrides, + 'storeAccountManagement', + resolveSupplyChainPermissionSubjectKeys(user), + user.id, + ) +} + +export function getStoreAccountFieldPermissionBusinessTypes( + overrides: SupplyChainPermissionOverrideMap, + users: PermissionManagedUser[], +) { + return STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES + .map((businessType) => { + const pageAccess = resolveSupplyChainPermissionAccess( + overrides, + 'storeAccountManagement', + [businessType.key], + ) + const eligibleUsers = users.filter((user) => ( + resolveSupplyChainPermissionSubjectKeys(user).includes(businessType.key) + && resolveStoreAccountUserPageAccess(overrides, user).view + )) + return { ...businessType, pageAccess, users: eligibleUsers } + }) + .filter((businessType) => businessType.pageAccess.view || businessType.users.length > 0) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/warehouse-config/WarehouseConfigView.tsx b/furniture-3d-system/src/app/supply-chain/database/warehouse-config/WarehouseConfigView.tsx index cd57c4ff..a8152ea6 100644 --- a/furniture-3d-system/src/app/supply-chain/database/warehouse-config/WarehouseConfigView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/warehouse-config/WarehouseConfigView.tsx @@ -6,9 +6,11 @@ import { SupplyChainConfigThemeStyles } from '../components/SupplyChainConfigThe type Status = 'ACTIVE' | 'INACTIVE' type StorageType = 'INBOUND' | 'DROPSHIP' +type WarehouseOwnership = 'FACTORY' | 'JIANSHANG' type Item = { id: string | null warehouseName: string + warehouseOwnership: WarehouseOwnership | null warehouseHead: string storageType: StorageType | null status: Status | null @@ -19,6 +21,7 @@ type PageInfo = { page: number; pageSize: number; total: number; totalPages: num type Draft = { id?: string warehouseName: string + warehouseOwnership: WarehouseOwnership | '' warehouseHead: string storageType: StorageType | '' status: Status @@ -96,6 +99,7 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ warehouseName: draft.warehouseName, + warehouseOwnership: draft.warehouseOwnership, warehouseHead: draft.warehouseHead, storageType: draft.storageType, ...(draft.id ? { status: draft.status } : {}), @@ -113,13 +117,14 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) { } const toggleStatus = async (item: Item) => { - if (!canEdit || !item.id || !item.storageType || !item.status) return + if (!canEdit || !item.id || !item.warehouseOwnership || !item.storageType || !item.status) return try { await readResponse(await fetch(`/api/supply-chain/warehouse-config/${encodeURIComponent(item.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ warehouseName: item.warehouseName, + warehouseOwnership: item.warehouseOwnership ?? (item.warehouseName.startsWith('工厂-') ? 'FACTORY' : ''), warehouseHead: item.warehouseHead, storageType: item.storageType, status: item.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE', @@ -134,11 +139,11 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) { return (
-

产品全量档案 - 仓库配置

按出入库仓库匹配入库或代发

+

产品全量档案 - 仓库配置

按出入库仓库匹配仓库归属、负责人和入库/代发

共 {pageInfo.total} 条 {canEdit ? ( - ) : null} @@ -154,13 +159,14 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
- + - {loading && } - {!loading && error && } - {!loading && !error && items.length === 0 && } + {loading && } + {!loading && error && } + {!loading && !error && items.length === 0 && } {!loading && !error && items.map((item) => + @@ -168,8 +174,8 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
仓库名称仓库负责人入库/代发状态更新时间操作
仓库名称仓库归属仓库负责人入库/代发状态更新时间操作
正在加载...
{error}
暂无配置
正在加载...
{error}
暂无配置
{item.warehouseName}{item.warehouseOwnership === 'FACTORY' ? '工厂' : item.warehouseOwnership === 'JIANSHANG' ? '简尚' : '未配置'} {item.warehouseHead || '-'} {item.storageType === 'INBOUND' ? '入库' : item.storageType === 'DROPSHIP' ? '代发' : '-'} {item.status === 'ACTIVE' ? '启用' : item.status === 'INACTIVE' ? '停用' : '未配置'} {canEdit ? ( <> - - {item.configured && } + + {item.configured && item.warehouseOwnership && } ) : ( 仅查看 @@ -189,7 +195,8 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) { {draft &&
{ if (event.target === event.currentTarget) setDraft(null) }}>

{draft.id ? '编辑仓库配置' : '新增仓库配置'}

- + + {draft.id && } diff --git a/furniture-3d-system/src/app/supply-chain/management/page.tsx b/furniture-3d-system/src/app/supply-chain/management/page.tsx new file mode 100644 index 00000000..263609c7 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/server-session' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +export const metadata = { title: '经营管理 · 供应链中心' } + +export default async function BusinessManagementPage() { + const { authUser } = await requireSession('/login') + const access = await supplyChainPermissionSettingsService.getUserAccess( + authUser, + 'businessManagement', + ) + if (!access.view) redirect('/supply-chain') + + return ( +
+
+

经营管理

+
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationEditDialog.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationEditDialog.tsx new file mode 100644 index 00000000..62d540dc --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationEditDialog.tsx @@ -0,0 +1,280 @@ +'use client' + +import { FormEvent, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import type { + SupplyChainSupplierInformationEditableField, + SupplyChainSupplierInformationRow, + SupplyChainSupplierInformationUpdates, +} from '@/lib/supply-chain-supplier-information' + +type EditValues = Record + +function initialValue(values: readonly string[]) { + return values.length === 1 ? values[0] : '' +} + +function CurrentValues({ values }: { values: readonly string[] }) { + if (values.length === 0) return 当前无值 + if (values.length === 1) return 当前:{values[0]} + return ( +
+ 当前多个值({values.length} 项) +
{values.map((value) => {value})}
+
+ ) +} + +async function readResponse(response: Response) { + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) { + throw new Error(payload?.error?.message || '保存失败,请稍后重试') + } + return payload.data +} + +export function SupplierInformationEditDialog({ + row, + onClose, + onSaved, +}: { + row: SupplyChainSupplierInformationRow + onClose: () => void + onSaved: () => void +}) { + const [values, setValues] = useState({ + purchaseOwner: initialValue(row.purchaserValues), + supplierName: row.supplierName, + minimumOrderQuantity: initialValue(row.minimumOrderQuantityValues), + firstLeadTime: initialValue(row.firstLeadTimeValues), + stableLeadTime: initialValue(row.stableLeadTimeValues), + palletSpec: initialValue(row.palletSpecValues), + backupSupplier: initialValue(row.backupSupplierValues), + supplierType: initialValue(row.supplierTypeValues), + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !saving) onClose() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose, saving]) + + const setField = (field: SupplyChainSupplierInformationEditableField, value: string) => { + setValues((current) => ({ ...current, [field]: value })) + } + + const save = async (event: FormEvent) => { + event.preventDefault() + if (saving) return + + const updates: SupplyChainSupplierInformationUpdates = {} + const addValue = ( + field: SupplyChainSupplierInformationEditableField, + currentValues: readonly string[], + ) => { + const value = values[field].trim() + if (!value) return + if (currentValues.length === 1 && currentValues[0] === value) return + updates[field] = value + } + + addValue('purchaseOwner', row.purchaserValues) + addValue('supplierName', [row.supplierName]) + addValue('minimumOrderQuantity', row.minimumOrderQuantityValues) + addValue('firstLeadTime', row.firstLeadTimeValues) + addValue('stableLeadTime', row.stableLeadTimeValues) + addValue('palletSpec', row.palletSpecValues) + addValue('backupSupplier', row.backupSupplierValues) + addValue('supplierType', row.supplierTypeValues) + + if (Object.keys(updates).length === 0) { + setError('没有需要保存的修改;多个值字段可填写新值后统一覆盖') + return + } + + setSaving(true) + setError(null) + try { + await readResponse(await fetch('/api/supply-chain/supplier-information', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ supplierKey: row.supplierKey, updates }), + })) + onSaved() + } catch (cause) { + setError(cause instanceof Error ? cause.message : '保存失败,请稍后重试') + } finally { + setSaving(false) + } + } + + const editableFields: Array<{ + field: SupplyChainSupplierInformationEditableField + label: string + currentValues: readonly string[] + placeholder: string + }> = [ + { field: 'purchaseOwner', label: '采购', currentValues: row.purchaserValues, placeholder: '填写采购姓名' }, + { field: 'supplierName', label: '供应商', currentValues: [row.supplierName], placeholder: '填写供应商名称' }, + { field: 'minimumOrderQuantity', label: '产品起订量', currentValues: row.minimumOrderQuantityValues, placeholder: '填写产品起订量' }, + { field: 'firstLeadTime', label: '首单生产周期', currentValues: row.firstLeadTimeValues, placeholder: '填写首单生产周期' }, + { field: 'stableLeadTime', label: '稳定生产周期', currentValues: row.stableLeadTimeValues, placeholder: '填写稳定生产周期' }, + { field: 'palletSpec', label: '托盘参数', currentValues: row.palletSpecValues, placeholder: '填写托盘参数' }, + { field: 'backupSupplier', label: '替换供应商', currentValues: row.backupSupplierValues, placeholder: '填写替换供应商' }, + { field: 'supplierType', label: '供应商类型', currentValues: row.supplierTypeValues, placeholder: '填写供应商类型' }, + ] + + return createPortal( +
{ + if (event.target === event.currentTarget && !saving) onClose() + }} + > + +
+
+

编辑供应商信息

+

{row.supplierName} · 关联 {row.barcodeCount} 个条码

+
+ +
+ +
+ 保存后会统一更新该供应商关联的全部条码;留空表示不修改。 +
+ +
+ {editableFields.map((item, index) => ( + + ))} +
+ + {error ?

{error}

: null} + +
+ + +
+ + + +
, + document.body, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationFilters.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationFilters.tsx new file mode 100644 index 00000000..095330b1 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationFilters.tsx @@ -0,0 +1,167 @@ +'use client' + +import Link from 'next/link' +import { Filter, Search, X } from 'lucide-react' +import type { SupplyChainSupplierInformationFilters } from '@/lib/supply-chain-supplier-information' + +const FIELDS: Array<{ + key: keyof SupplyChainSupplierInformationFilters + label: string + placeholder: string +}> = [ + { key: 'purchaser', label: '采购', placeholder: '搜索采购' }, + { key: 'supplier', label: '供应商', placeholder: '搜索供应商' }, + { key: 'minimumOrderQuantity', label: '产品起订量', placeholder: '搜索起订量' }, + { key: 'firstLeadTime', label: '首单生产周期', placeholder: '搜索首单周期' }, + { key: 'stableLeadTime', label: '稳定生产周期', placeholder: '搜索稳定周期' }, + { key: 'palletSpec', label: '托盘参数', placeholder: '搜索托盘参数' }, + { key: 'backupSupplier', label: '替换供应商', placeholder: '搜索替换供应商' }, + { key: 'supplierType', label: '供应商类型', placeholder: '搜索供应商类型' }, +] + +export function SupplierInformationFilters({ + filters, + pageSize, +}: { + filters: SupplyChainSupplierInformationFilters + pageSize: number +}) { + const activeCount = Object.values(filters).filter(Boolean).length + const clearHref = pageSize === 50 + ? '/supply-chain/purchase/supplier-information' + : `/supply-chain/purchase/supplier-information?pageSize=${pageSize}` + + return ( +
+ {pageSize !== 50 ? : null} +
+
+
+ {FIELDS.map((field) => ( + + ))} +
+
+ {activeCount > 0 ? ( + + + 清空 + + ) : null} + +
+ + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationTable.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationTable.tsx new file mode 100644 index 00000000..8486dc38 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationTable.tsx @@ -0,0 +1,192 @@ +'use client' + +import { Pencil } from 'lucide-react' +import type { SupplyChainSupplierInformationRow } from '@/lib/supply-chain-supplier-information' + +function ValueCell({ values }: { values: readonly string[] }) { + if (values.length === 0) return - + if (values.length === 1) return {values[0]} + + return ( +
+ {values.length} 项 +
+ {values.map((value) => {value})} +
+
+ ) +} + +export function SupplierInformationTable({ + rows, + startIndex, + canEdit, + onEdit, +}: { + rows: SupplyChainSupplierInformationRow[] + startIndex: number + canEdit: boolean + onEdit: (row: SupplyChainSupplierInformationRow) => void +}) { + return ( +
+ + + + + + + + + + + + {canEdit ? : null} + + + + + + + + + + + + + {canEdit ? : null} + + + + {rows.map((row, rowIndex) => ( + + + + + + + + + + + {canEdit ? ( + + ) : null} + + ))} + +
序号采购供应商产品起订量首单生产周期稳定生产周期托盘参数替换供应商供应商类型操作
{startIndex + rowIndex + 1} + {row.supplierName} + {row.barcodeCount} 个条码 + + +
+ {rows.length === 0 ?
暂无供应商数据
: null} + + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationView.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationView.tsx new file mode 100644 index 00000000..07c0844a --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/SupplierInformationView.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { ArrowLeft, ArrowRight } from 'lucide-react' +import type { + SupplyChainSupplierInformationFilters, + SupplyChainSupplierInformationPage, + SupplyChainSupplierInformationRow, +} from '@/lib/supply-chain-supplier-information' +import { SupplierInformationEditDialog } from './SupplierInformationEditDialog' +import { SupplierInformationFilters } from './SupplierInformationFilters' +import { SupplierInformationTable } from './SupplierInformationTable' + +type SupplierInformationViewProps = { + data: SupplyChainSupplierInformationPage + filters: SupplyChainSupplierInformationFilters + canEdit: boolean +} + +function pageHref( + page: number, + filters: SupplyChainSupplierInformationFilters, + pageSize: number, +) { + const params = new URLSearchParams() + Object.entries(filters).forEach(([key, value]) => { + if (value) params.set(key, value) + }) + if (page > 1) params.set('page', String(page)) + if (pageSize !== 50) params.set('pageSize', String(pageSize)) + const query = params.toString() + return query + ? `/supply-chain/purchase/supplier-information?${query}` + : '/supply-chain/purchase/supplier-information' +} + +export function SupplierInformationView({ + data, + filters, + canEdit, +}: SupplierInformationViewProps) { + const router = useRouter() + const { rows, pageInfo } = data + const [pageInput, setPageInput] = useState(String(pageInfo.page)) + const [editingRow, setEditingRow] = useState(null) + const hasPrevious = pageInfo.page > 1 + const hasNext = pageInfo.page < pageInfo.totalPages + + useEffect(() => { + setPageInput(String(pageInfo.page)) + }, [pageInfo.page]) + + const goToPage = (page: number, pageSize = pageInfo.pageSize) => { + router.push(pageHref(page, filters, pageSize)) + } + + const commitPageInput = () => { + const parsed = Number(pageInput) + if (!Number.isFinite(parsed)) { + setPageInput(String(pageInfo.page)) + return + } + const nextPage = Math.min(Math.max(1, Math.trunc(parsed)), pageInfo.totalPages) + setPageInput(String(nextPage)) + if (nextPage !== pageInfo.page) goToPage(nextPage) + } + + return ( +
+
+
+

供应商信息

+ 共 {pageInfo.total} 家 +
+
+ + + + +
+ 共 {pageInfo.total} 家 + + + setPageInput(event.target.value.replace(/\D/g, ''))} + onBlur={commitPageInput} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commitPageInput() + } + }} + /> + / + {pageInfo.totalPages} 页 + + + + 每页 + + 家 + +
+ + {editingRow ? ( + setEditingRow(null)} + onSaved={() => { + setEditingRow(null) + router.refresh() + }} + /> + ) : null} + + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/page.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/page.tsx new file mode 100644 index 00000000..1da219d3 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-information/page.tsx @@ -0,0 +1,53 @@ +import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/server-session' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service' +import { + SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS, + type SupplyChainSupplierInformationFilters, +} from '@/lib/supply-chain-supplier-information' +import { SupplierInformationView } from './SupplierInformationView' + +export const dynamic = 'force-dynamic' +export const metadata = { title: '供应商信息 · 供应链中心' } + +type SupplierInformationPageProps = { + searchParams: Promise<{ + page?: string | string[] + pageSize?: string | string[] + } & Partial>> +} + +function firstValue(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] ?? '' : value ?? '' +} + +export default async function SupplierInformationPage({ + searchParams, +}: SupplierInformationPageProps) { + const { authUser } = await requireSession('/login') + const access = await supplyChainPermissionSettingsService.getUserAccess( + authUser, + 'purchaseSupplierInformation', + ) + if (!access.view) redirect('/supply-chain') + + const params = await searchParams + const filters = Object.fromEntries( + SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS.map((key) => [ + key, + firstValue(params[key]).trim().slice(0, 120), + ]), + ) as SupplyChainSupplierInformationFilters + const parsedPage = Number(firstValue(params.page)) + const page = Number.isFinite(parsedPage) ? Math.max(1, Math.trunc(parsedPage)) : 1 + const parsedPageSize = Number(firstValue(params.pageSize)) + const pageSize = [50, 100, 200].includes(parsedPageSize) ? parsedPageSize : 50 + const data = await supplyChainSupplierInformationService.listPage({ + page, + pageSize, + filters, + }) + + return +} diff --git a/furniture-3d-system/src/lib/store-account-crypto.ts b/furniture-3d-system/src/lib/store-account-crypto.ts new file mode 100644 index 00000000..0ed4b07f --- /dev/null +++ b/furniture-3d-system/src/lib/store-account-crypto.ts @@ -0,0 +1,32 @@ +import 'server-only' +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' + +const PREFIX = 'v1:' + +function getKey() { + const secret = process.env.STORE_ACCOUNT_ENCRYPTION_KEY?.trim() + || (process.env.NODE_ENV === 'production' ? '' : process.env.JWT_SECRET?.trim()) + if (!secret || secret.length < 32) { + throw new Error('STORE_ACCOUNT_ENCRYPTION_KEY must be configured with at least 32 characters') + } + return createHash('sha256').update(secret).digest() +} + +export function encryptStoreAccountPassword(value: string) { + if (!value) return '' + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', getKey(), iv) + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return `${PREFIX}${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}` +} + +export function decryptStoreAccountPassword(value: string) { + if (!value) return '' + if (!value.startsWith(PREFIX)) throw new Error('Unsupported store account password format') + const [ivPart, tagPart, dataPart] = value.slice(PREFIX.length).split('.') + if (!ivPart || !tagPart || !dataPart) throw new Error('Invalid store account password') + const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64url')) + decipher.setAuthTag(Buffer.from(tagPart, 'base64url')) + return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8') +} diff --git a/furniture-3d-system/src/lib/store-account-management.test.ts b/furniture-3d-system/src/lib/store-account-management.test.ts new file mode 100644 index 00000000..12a7835a --- /dev/null +++ b/furniture-3d-system/src/lib/store-account-management.test.ts @@ -0,0 +1,85 @@ +import { + formatStoreAccountLogValue, + getStoreAccountLogFieldLabel, + getStoreAccountLogOperationLabel, + normalizeStoreAccountLogChanges, + resolveStoreAccountFieldVisibility, +} from './store-account-management' + +describe('store account field permissions', () => { + it('uses a personal field exception before the business type setting', () => { + expect(resolveStoreAccountFieldVisibility({ + fieldKey: 'operationOwner', + pageView: true, + pageEdit: true, + fallback: 'EDITABLE', + roleValues: ['HIDDEN'], + personalValue: 'READONLY', + })).toBe('READONLY') + }) + + it('inherits the most permissive business type setting without a personal exception', () => { + expect(resolveStoreAccountFieldVisibility({ + fieldKey: 'operationOwner', + pageView: true, + pageEdit: true, + fallback: 'READONLY', + roleValues: ['HIDDEN', 'EDITABLE'], + })).toBe('EDITABLE') + }) + + it('never lets a field exception exceed the page permission boundary', () => { + expect(resolveStoreAccountFieldVisibility({ + fieldKey: 'operationOwner', + pageView: true, + pageEdit: false, + fallback: 'READONLY', + roleValues: ['HIDDEN'], + personalValue: 'EDITABLE', + })).toBe('READONLY') + expect(resolveStoreAccountFieldVisibility({ + fieldKey: 'operationOwner', + pageView: false, + pageEdit: false, + fallback: 'EDITABLE', + roleValues: ['EDITABLE'], + personalValue: 'EDITABLE', + })).toBe('HIDDEN') + }) + + it('keeps system time fields read-only', () => { + expect(resolveStoreAccountFieldVisibility({ + fieldKey: 'createdAt', + pageView: true, + pageEdit: true, + fallback: 'READONLY', + roleValues: [], + personalValue: 'EDITABLE', + })).toBe('READONLY') + }) +}) + +describe('store account log presentation', () => { + it('translates operations, fields and account values into business labels', () => { + expect(getStoreAccountLogOperationLabel('UPDATE')).toBe('修改账号') + expect(getStoreAccountLogOperationLabel('VIEW_PASSWORD')).toBe('查看密码') + expect(getStoreAccountLogOperationLabel('UNKNOWN')).toBe('其他操作') + expect(getStoreAccountLogFieldLabel('operationOwner')).toBe('店铺运营负责人') + expect(getStoreAccountLogFieldLabel('unknownField')).toBe('其他字段') + expect(formatStoreAccountLogValue('MAIN')).toBe('主账号') + expect(formatStoreAccountLogValue('')).toBe('空') + }) + + it('keeps only valid field changes from historical log payloads', () => { + expect(normalizeStoreAccountLogChanges([ + { field: 'operationOwner', previousValue: '甲', nextValue: '乙' }, + null, + { previousValue: '缺少字段' }, + ])).toEqual([{ + field: 'operationOwner', + previousValue: '甲', + nextValue: '乙', + }]) + expect(normalizeStoreAccountLogChanges({})).toEqual([]) + }) +}) diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts new file mode 100644 index 00000000..0842b591 --- /dev/null +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -0,0 +1,229 @@ +export const STORE_ACCOUNT_FIELD_KEYS = [ + 'storeName', + 'operationOwner', + 'operationDepartmentHead', + 'operationDirector', + 'profitCenter', + 'accountType', + 'account', + 'password', + 'boundPhone', + 'verificationContact', + 'jstOrderService', + 'accountGroupName', + 'createdAt', + 'updatedAt', +] as const + +export type StoreAccountFieldKey = (typeof STORE_ACCOUNT_FIELD_KEYS)[number] +export type StoreAccountFieldVisibility = 'HIDDEN' | 'READONLY' | 'EDITABLE' +export type StoreAccountFieldPermissionValue = StoreAccountFieldVisibility | null +export type StoreAccountType = 'MAIN' | 'SUB' +export type StoreAccountBatchEditableFieldKey = Exclude + +export const STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES = [ + { key: 'design', label: '设计' }, + { key: 'planning', label: '企划' }, + { key: 'brandCenter', label: '策划' }, + { key: 'purchase', label: '采购' }, + { key: 'merchandiser', label: '采购-跟单' }, + { key: 'pricing', label: '采购-核价' }, + { key: 'quality', label: '采购-质检' }, + { key: 'operation', label: '运营' }, + { key: 'warehouse', label: '仓库' }, + { key: 'customerService', label: '客服' }, + { key: 'management', label: '经管' }, + { key: 'viewer', label: '普通用户' }, +] as const + +export type StoreAccountFieldPermissionBusinessTypeKey = + (typeof STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES)[number]['key'] + +export const STORE_ACCOUNT_FIELDS: Array<{ + key: StoreAccountFieldKey + label: string + sensitive?: boolean + system?: boolean +}> = [ + { key: 'storeName', label: '店铺名称' }, + { key: 'operationOwner', label: '店铺运营负责人' }, + { key: 'operationDepartmentHead', label: '店铺运营部门负责人' }, + { key: 'operationDirector', label: '店铺运营总监' }, + { key: 'profitCenter', label: '店铺所属利润中心' }, + { key: 'accountType', label: '账号类型' }, + { key: 'account', label: '账号' }, + { key: 'password', label: '密码', sensitive: true }, + { key: 'boundPhone', label: '绑定手机号' }, + { key: 'verificationContact', label: '手机号验证码对接人' }, + { key: 'jstOrderService', label: '聚水潭订单服务号' }, + { key: 'accountGroupName', label: '账号分组名称' }, + { key: 'createdAt', label: '店铺创建时间', system: true }, + { key: 'updatedAt', label: '店铺修改时间', system: true }, +] + +export const STORE_ACCOUNT_EDITABLE_FIELD_KEYS = STORE_ACCOUNT_FIELDS + .filter((field) => !field.system) + .map((field) => field.key) + +export const STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS = STORE_ACCOUNT_EDITABLE_FIELD_KEYS + .filter((key): key is StoreAccountBatchEditableFieldKey => key !== 'password') + +export const STORE_ACCOUNT_FILTER_KEYS = ['search', 'storeName', 'accountType', 'operationOwner', 'accountGroupName'] as const +export type StoreAccountFilterKey = (typeof STORE_ACCOUNT_FILTER_KEYS)[number] +export type StoreAccountFilters = Record + +export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = { + search: '', + storeName: '', + accountType: '', + operationOwner: '', + accountGroupName: '', +} + +export type StoreAccountFieldAccess = Record + +export type StoreAccountFieldPermissionRecord = { + fieldKey: StoreAccountFieldKey + subjectKey: string + visibility: StoreAccountFieldVisibility +} + +export type StoreAccountFieldPermissionUpdate = { + fieldKey: StoreAccountFieldKey + subjectKey: string + visibility: StoreAccountFieldPermissionValue +} + +export type StoreAccountChangeLogEntry = { + id: string + operation: string + actorName: string + createdAt: string + changes: unknown +} + +export type StoreAccountChangeLogChange = { + field: string + previousValue: unknown + nextValue: unknown +} + +const STORE_ACCOUNT_LOG_OPERATION_LABELS: Record = { + CREATE: '新增账号', + UPDATE: '修改账号', + IMPORT_CREATE: '导入新增', + IMPORT_UPDATE: '导入更新', + BATCH_UPDATE: '批量修改', + VIEW_PASSWORD: '查看密码', +} + +export type StoreAccountRow = { + id: string + storeName: string + operationOwner: string + operationDepartmentHead: string + operationDirector: string + profitCenter: string + accountType: StoreAccountType + account: string + passwordConfigured: boolean + boundPhone: string + verificationContact: string + jstOrderService: string + accountGroupName: string + createdAt: string + updatedAt: string +} + +export type StoreAccountPage = { + rows: StoreAccountRow[] + storeNames: string[] + pageInfo: { page: number; pageSize: number; total: number; totalPages: number } + fieldAccess: StoreAccountFieldAccess +} + +export type StoreAccountBatchPreviewRow = { + id: string + storeName: string | null + accountType: StoreAccountType | null + account: string | null + currentValue: string +} + +export type StoreAccountBatchPreview = { + rows: StoreAccountBatchPreviewRow[] + requested: number + found: number + missing: number +} + +export function getStoreAccountFieldLabel(key: StoreAccountFieldKey) { + return STORE_ACCOUNT_FIELDS.find((field) => field.key === key)?.label ?? key +} + +export function getStoreAccountLogOperationLabel(operation: string) { + return STORE_ACCOUNT_LOG_OPERATION_LABELS[operation] ?? '其他操作' +} + +export function getStoreAccountLogFieldLabel(field: string) { + return isStoreAccountFieldKey(field) ? getStoreAccountFieldLabel(field) : '其他字段' +} + +export function formatStoreAccountLogValue(value: unknown) { + if (value === null || value === undefined || String(value).trim() === '') return '空' + if (value === 'MAIN') return '主账号' + if (value === 'SUB') return '子账号' + return String(value) +} + +export function normalizeStoreAccountLogChanges(value: unknown): StoreAccountChangeLogChange[] { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + if (typeof record.field !== 'string') return [] + return [{ + field: record.field, + previousValue: record.previousValue, + nextValue: record.nextValue, + }] + }) +} + +export function isStoreAccountFieldKey(value: string): value is StoreAccountFieldKey { + return (STORE_ACCOUNT_FIELD_KEYS as readonly string[]).includes(value) +} + +export function isStoreAccountBatchEditableFieldKey(value: string): value is StoreAccountBatchEditableFieldKey { + return (STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS as readonly string[]).includes(value) +} + +export function isStoreAccountFieldVisibility(value: unknown): value is StoreAccountFieldVisibility { + return value === 'HIDDEN' || value === 'READONLY' || value === 'EDITABLE' +} + +export function isStoreAccountFieldPermissionSubjectKey(value: string) { + return /^[A-Za-z][A-Za-z0-9_-]{0,119}$/.test(value) || /^user@[A-Za-z0-9_-]{1,114}$/.test(value) +} + +export function resolveStoreAccountFieldVisibility(input: { + fieldKey: StoreAccountFieldKey + pageView: boolean + pageEdit: boolean + fallback: StoreAccountFieldVisibility + roleValues: StoreAccountFieldVisibility[] + personalValue?: StoreAccountFieldVisibility | null +}) { + if (!input.pageView) return 'HIDDEN' as const + + let visibility = input.fallback + if (input.roleValues.includes('EDITABLE')) visibility = 'EDITABLE' + else if (input.roleValues.includes('READONLY')) visibility = 'READONLY' + else if (input.roleValues.includes('HIDDEN')) visibility = 'HIDDEN' + + if (input.personalValue) visibility = input.personalValue + if (visibility === 'EDITABLE' && (!input.pageEdit || input.fieldKey === 'createdAt' || input.fieldKey === 'updatedAt')) { + return 'READONLY' as const + } + return visibility +} diff --git a/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts b/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts index f39ee004..162731e0 100644 --- a/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts @@ -109,6 +109,18 @@ describe('supply chain permission settings', () => { expect( getSupplyChainPermissionTargetForPath('/supply-chain/new-products/sample-progress'), ).toBe('newProductSampleProgress') + expect( + getSupplyChainPermissionTargetForPath('/supply-chain/purchase/supplier-information'), + ).toBe('purchaseSupplierInformation') + expect( + getSupplyChainPermissionTargetForPath('/supply-chain/management'), + ).toBe('businessManagement') + expect( + getSupplyChainApiPermissionRequirement( + '/api/supply-chain/supplier-information', + 'PUT', + ), + ).toEqual({ targetKey: 'purchaseSupplierInformation', action: 'edit' }) }) it('keeps newly registered pages closed until a role or account opens them', () => { diff --git a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts index 3752a4a4..935a3fa2 100644 --- a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts +++ b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts @@ -20,13 +20,16 @@ export type SupplyChainPermissionRoleKey = export type SupplyChainPermissionTargetKey = | 'workspace' | 'dashboard' + | 'businessManagement' | 'productArchive' | 'categoryConfig' | 'warehouseConfig' + | 'storeAccountManagement' | 'newProductOverview' | 'newProductTerminationFeedback' | 'newProductUnifiedTable' | 'newProductSampleProgress' + | 'purchaseSupplierInformation' | 'purchaseDailyAffairs' | 'purchasePriceChange' | 'operationLogs' @@ -70,13 +73,16 @@ export const SUPPLY_CHAIN_PERMISSION_ACTION_LABELS: Record< export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [ { key: 'dashboard', label: '工作台', description: '供应链工作台总览' }, + { key: 'businessManagement', label: '经营管理', description: '后续经营类功能统一入口' }, { key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' }, { key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' }, { key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' }, + { key: 'storeAccountManagement', label: '店铺账号管理', description: '店铺账号人工维护与权限控制' }, { key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' }, { key: 'newProductTerminationFeedback', label: '新品终止任务反馈', description: '维护已撤销、已拒绝和已终止流程的原因' }, { key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' }, { key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' }, + { key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' }, { key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' }, { key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' }, { key: 'operationLogs', label: '操作日志', description: '供应链操作审计' }, @@ -90,13 +96,16 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial< Record > = { dashboard: '/supply-chain/dashboard', + businessManagement: '/supply-chain/management', productArchive: '/supply-chain/database/product-archive', categoryConfig: '/supply-chain/database/category-config', warehouseConfig: '/supply-chain/database/warehouse-config', + storeAccountManagement: '/supply-chain/database/store-account-management', newProductOverview: '/supply-chain/new-products/overview', newProductTerminationFeedback: '/supply-chain/new-products/termination-feedback', newProductUnifiedTable: '/supply-chain/new-products/unified-table', newProductSampleProgress: '/supply-chain/new-products/sample-progress', + purchaseSupplierInformation: '/supply-chain/purchase/supplier-information', purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs', purchasePriceChange: '/supply-chain/purchase/price-change', operationLogs: '/supply-chain/operation-logs', @@ -300,13 +309,16 @@ export function resolveSupplyChainPermissionAccess( export function getSupplyChainPermissionTargetForPath(pathname: string) { if (pathname === '/supply-chain/dashboard') return 'dashboard' as const + if (pathname === '/supply-chain/management') return 'businessManagement' as const if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const + if (pathname === '/supply-chain/database/store-account-management') return 'storeAccountManagement' as const if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const if (pathname === '/supply-chain/new-products/termination-feedback') return 'newProductTerminationFeedback' as const if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const + if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const if (pathname === '/supply-chain/operation-logs') return 'operationLogs' as const @@ -332,8 +344,14 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method: if (pathname.startsWith('/api/supply-chain/warehouse-config')) { return { targetKey: 'warehouseConfig' as const, action } } + if (pathname.startsWith('/api/supply-chain/store-account-management')) { + return { targetKey: 'storeAccountManagement' as const, action } + } if (pathname.startsWith('/api/supply-chain/new-product-termination-feedback')) { return { targetKey: 'newProductTerminationFeedback' as const, action } } + if (pathname.startsWith('/api/supply-chain/supplier-information')) { + return { targetKey: 'purchaseSupplierInformation' as const, action } + } return undefined } diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts index 5dc9dc84..282d1020 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts @@ -43,7 +43,9 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [ { key: 'warehouseHead', label: '仓库负责人' }, { key: 'minimumOrderQuantity', label: '产品起订量' }, { key: 'firstLeadTime', label: '首单生产周期' }, + { key: 'stableLeadTime', label: '稳定生产周期' }, { key: 'palletSpec', label: '托盘参数' }, + { key: 'backupSupplier', label: '替换供应商' }, { key: 'dailySales', label: '采购日销' }, { key: 'sales3d', label: '3日日销' }, { key: 'sales7d', label: '7日日销' }, diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-warehouse-config.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-warehouse-config.ts index 6eadae84..bb91b1cf 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive-warehouse-config.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-warehouse-config.ts @@ -1,6 +1,31 @@ export const WAREHOUSE_CONFIG_ACTIVE = 'ACTIVE' as const export const WAREHOUSE_CONFIG_INACTIVE = 'INACTIVE' as const +export const PRODUCT_ARCHIVE_WAREHOUSE_OWNERSHIPS = ['FACTORY', 'JIANSHANG'] as const + +export type ProductArchiveWarehouseOwnership = + (typeof PRODUCT_ARCHIVE_WAREHOUSE_OWNERSHIPS)[number] + +export function isProductArchiveWarehouseOwnership( + value: string | null | undefined, +): value is ProductArchiveWarehouseOwnership { + return (PRODUCT_ARCHIVE_WAREHOUSE_OWNERSHIPS as readonly string[]).includes(value ?? '') +} + +export function productArchiveWarehouseOwnershipLabel( + value: ProductArchiveWarehouseOwnership | string | null | undefined, +) { + if (value === 'FACTORY') return '工厂' + if (value === 'JIANSHANG') return '简尚' + return '未配置' +} + +export function inferProductArchiveWarehouseOwnership( + warehouseName: string | null | undefined, +): ProductArchiveWarehouseOwnership | null { + return warehouseName?.trim().startsWith('工厂-') ? 'FACTORY' : null +} + export type ProductArchiveWarehouseConfigStatus = | typeof WAREHOUSE_CONFIG_ACTIVE | typeof WAREHOUSE_CONFIG_INACTIVE @@ -8,6 +33,7 @@ export type ProductArchiveWarehouseConfigStatus = export type ProductArchiveWarehouseConfig = { id: string warehouseName: string + warehouseOwnership: ProductArchiveWarehouseOwnership | null warehouseHead: string storageType: 'INBOUND' | 'DROPSHIP' status: ProductArchiveWarehouseConfigStatus diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts index 4921bdc9..495d2003 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -33,6 +33,7 @@ export type SupplyChainProductArchiveRow = { orderType: string inOutWarehouse: string warehouseType: string + warehouseOwnership: string warehouseHead: string storageType: string isSeasonal: string diff --git a/furniture-3d-system/src/lib/supply-chain-supplier-information.ts b/furniture-3d-system/src/lib/supply-chain-supplier-information.ts new file mode 100644 index 00000000..20d0cc24 --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-supplier-information.ts @@ -0,0 +1,77 @@ +export type SupplyChainSupplierInformationRow = { + supplierKey: string + supplierName: string + barcodeCount: number + purchaserValues: string[] + minimumOrderQuantityValues: string[] + firstLeadTimeValues: string[] + stableLeadTimeValues: string[] + palletSpecValues: string[] + backupSupplierValues: string[] + supplierTypeValues: string[] +} + +export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS = [ + 'purchaser', + 'supplier', + 'minimumOrderQuantity', + 'firstLeadTime', + 'stableLeadTime', + 'palletSpec', + 'backupSupplier', + 'supplierType', +] as const + +export type SupplyChainSupplierInformationFilterKey = + (typeof SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS)[number] + +export type SupplyChainSupplierInformationFilters = Record< + SupplyChainSupplierInformationFilterKey, + string +> + +export const EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS: + SupplyChainSupplierInformationFilters = { + purchaser: '', + supplier: '', + minimumOrderQuantity: '', + firstLeadTime: '', + stableLeadTime: '', + palletSpec: '', + backupSupplier: '', + supplierType: '', + } + +export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS = [ + 'purchaseOwner', + 'supplierName', + 'minimumOrderQuantity', + 'firstLeadTime', + 'stableLeadTime', + 'palletSpec', + 'backupSupplier', + 'supplierType', +] as const + +export type SupplyChainSupplierInformationEditableField = + (typeof SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS)[number] + +export type SupplyChainSupplierInformationUpdates = Partial< + Record +> + +export type SupplyChainSupplierInformationUpdateResult = { + affectedBarcodes: number + changedValues: number + supplierKey: string +} + +export type SupplyChainSupplierInformationPage = { + rows: SupplyChainSupplierInformationRow[] + pageInfo: { + page: number + pageSize: number + total: number + totalPages: number + } +} diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-image-cache-sync.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-image-cache-sync.repository.test.ts index 039ae4ed..8623d5a2 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-image-cache-sync.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-image-cache-sync.repository.test.ts @@ -7,24 +7,31 @@ import { function createTransaction() { return { supplyChainProductArchiveImageCache: { - upsert: jest.fn(), - deleteMany: jest.fn(), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + upsert: jest.fn().mockResolvedValue({}), }, } } -describe('product-development archive image cache sync', () => { - it('keeps product-development images in the archive row instead of the material cache', async () => { +function input(overrides: Partial[1]> = {}) { + return { + archiveId: 'archive-1', + barcode: 'BARCODE-1', + spu: 'SPU-1', + sku: 'SKU-1', + image: null, + imageFieldApplied: true, + ...overrides, + } +} + +describe('product-development archive SKU image cache cleanup', () => { + it('keeps the SKU image in the archive row instead of the material cache', async () => { const tx = createTransaction() - await syncProductDevelopmentArchiveImageCache(tx as never, { - archiveId: 'archive-1', - barcode: 'BARCODE-1', - spu: 'SPU-1', - sku: 'SKU-1', + await syncProductDevelopmentArchiveImageCache(tx as never, input({ image: '/api/storage/object?key=sku-1.jpeg', - imageFieldApplied: true, - }) + })) expect(tx.supplyChainProductArchiveImageCache.deleteMany).toHaveBeenCalledWith({ where: { @@ -40,17 +47,10 @@ describe('product-development archive image cache sync', () => { expect(tx.supplyChainProductArchiveImageCache.upsert).not.toHaveBeenCalled() }) - it('clears an old wide-row cache when the current SKU image becomes empty', async () => { + it('clears an old wide-row cache when the SKU image is empty', async () => { const tx = createTransaction() - await syncProductDevelopmentArchiveImageCache(tx as never, { - archiveId: 'archive-1', - barcode: 'BARCODE-1', - spu: 'SPU-1', - sku: 'SKU-1', - image: null, - imageFieldApplied: true, - }) + await syncProductDevelopmentArchiveImageCache(tx as never, input()) expect(tx.supplyChainProductArchiveImageCache.deleteMany).toHaveBeenCalledWith({ where: { @@ -66,19 +66,26 @@ describe('product-development archive image cache sync', () => { expect(tx.supplyChainProductArchiveImageCache.upsert).not.toHaveBeenCalled() }) - it('keeps the existing cache when source priority rejects the image field', async () => { + it('does not clear legacy SKU caches when source priority protects the image field', async () => { const tx = createTransaction() - await syncProductDevelopmentArchiveImageCache(tx as never, { - archiveId: 'archive-1', - barcode: 'BARCODE-1', - spu: 'SPU-1', - sku: 'SKU-1', + await syncProductDevelopmentArchiveImageCache(tx as never, input({ image: '/api/storage/object?key=sku-1.jpeg', imageFieldApplied: false, - }) + })) + expect(tx.supplyChainProductArchiveImageCache.deleteMany).not.toHaveBeenCalledWith({ + where: { + archiveId: 'archive-1', + viewKey: { + in: [ + PRODUCT_DEVELOPMENT_IMAGE_VIEW_KEY, + LEGACY_PRODUCT_DEVELOPMENT_IMAGE_VIEW_KEY, + ], + }, + }, + }) expect(tx.supplyChainProductArchiveImageCache.upsert).not.toHaveBeenCalled() - expect(tx.supplyChainProductArchiveImageCache.deleteMany).not.toHaveBeenCalled() }) + }) diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sync.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sync.repository.test.ts index 0e333651..206d1613 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sync.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sync.repository.test.ts @@ -88,6 +88,7 @@ describe('product-development archive image source boundary', () => { tx, expect.objectContaining({ archiveId: 'archive-1', + image: 'https://new-product.example.com/sku.webp', imageFieldApplied: false, }), ) diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-supplier-information.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-supplier-information.repository.test.ts new file mode 100644 index 00000000..4a2a2207 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-supplier-information.repository.test.ts @@ -0,0 +1,101 @@ +jest.mock('@/lib/prisma', () => ({ + prisma: { + $queryRaw: jest.fn(), + $transaction: jest.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { SupplyChainSupplierInformationRepositoryClass } from '../supply-chain-supplier-information.repository' + +const mockPrisma = prisma as unknown as { + $queryRaw: jest.Mock + $transaction: jest.Mock +} + +const EMPTY_FILTERS = { + purchaser: '', + supplier: '', + minimumOrderQuantity: '', + firstLeadTime: '', + stableLeadTime: '', + palletSpec: '', + backupSupplier: '', + supplierType: '', +} + +function queryText(query: { sql?: string; text?: string }) { + return query.sql ?? query.text ?? String(query) +} + +describe('SupplyChainSupplierInformationRepository', () => { + beforeEach(() => jest.clearAllMocks()) + + it('combines filters against any barcode value in the supplier group', async () => { + mockPrisma.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ total: 0 }]) + const repository = new SupplyChainSupplierInformationRepositoryClass() + + await repository.listPage({ + skip: 0, + take: 50, + filters: { + ...EMPTY_FILTERS, + purchaser: '采购A', + minimumOrderQuantity: '20', + stableLeadTime: '30天', + palletSpec: '托盘B', + backupSupplier: '备选厂', + }, + }) + + const query = mockPrisma.$queryRaw.mock.calls[0][0] as { + sql?: string + text?: string + values?: unknown[] + } + const sql = queryText(query) + expect(sql).toContain('HAVING') + expect(sql).toContain('BOOL_OR("purchaseOwner" ILIKE') + expect(sql).toContain('BOOL_OR("minimumOrderQuantity" ILIKE') + expect(sql).toContain('BOOL_OR("stableLeadTime" ILIKE') + expect(sql).toContain('BOOL_OR("palletSpec" ILIKE') + expect(sql).toContain('BOOL_OR("backupSupplier" ILIKE') + expect(query.values).toEqual(expect.arrayContaining([ + '%采购A%', '%20%', '%30天%', '%托盘B%', '%备选厂%', + ])) + }) + + it('locks matching archive rows before one bulk update', async () => { + const records = [{ + id: 'archive-1', + barcode: 'BC-001', + supplierName: '浩远', + purchaseOwner: '采购A', + minimumOrderQuantity: '10', + firstLeadTime: '7', + stableLeadTime: '', + palletSpec: '标准盘', + backupSupplier: '', + supplierType: '成品', + }] + const tx = { + $queryRaw: jest.fn().mockResolvedValue(records), + supplyChainProductArchive: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, + } + mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx)) + const repository = new SupplyChainSupplierInformationRepositoryClass() + + await expect(repository.updateSupplierFields({ + supplierKey: '浩远', + updates: { purchaseOwner: '采购B', stableLeadTime: '30天', backupSupplier: '备选厂' }, + })).resolves.toEqual(records) + + expect(queryText(tx.$queryRaw.mock.calls[0][0])).toContain('FOR UPDATE') + expect(tx.supplyChainProductArchive.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['archive-1'] } }, + data: { purchaseOwner: '采购B', stableLeadTime: '30天', backupSupplier: '备选厂' }, + }) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts new file mode 100644 index 00000000..2952945d --- /dev/null +++ b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts @@ -0,0 +1,197 @@ +import { prisma } from '@/lib/prisma' +import type { StoreAccountFilterKey, StoreAccountFilters, StoreAccountFieldPermissionUpdate, StoreAccountType } from '@/lib/store-account-management' + +export type StoreAccountRecord = { + id: string + storeName: string + operationOwner: string + operationDepartmentHead: string + operationDirector: string + profitCenter: string + accountType: string + account: string + passwordCiphertext: string + boundPhone: string + verificationContact: string + jstOrderService: string + accountGroupName: string + createdAt: Date + updatedAt: Date +} + +export type StoreAccountChangeRecord = { + id: string + storeAccountId: string | null + storeName: string + operation: string + actorId: string | null + actorName: string + changes: unknown + createdAt: Date +} + +export type StoreAccountFieldPermissionRecord = { + fieldKey: string + subjectKey: string + visibility: string +} + +const ACCOUNT_SELECT = { + id: true, + storeName: true, + operationOwner: true, + operationDepartmentHead: true, + operationDirector: true, + profitCenter: true, + accountType: true, + account: true, + passwordCiphertext: true, + boundPhone: true, + verificationContact: true, + jstOrderService: true, + accountGroupName: true, + createdAt: true, + updatedAt: true, +} as const + +function contains(value: string) { + return { contains: value, mode: 'insensitive' as const } +} + +function splitStoreNames(value: string) { + return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))].slice(0, 500) +} + +function buildWhere(filters: StoreAccountFilters) { + const and: Array> = [] + if (filters.search) { + const searchTokens = [...new Set(filters.search.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean))].slice(0, 100) + and.push({ OR: searchTokens.flatMap((token) => [ + { storeName: contains(token) }, + { account: contains(token) }, + { operationOwner: contains(token) }, + { operationDepartmentHead: contains(token) }, + { operationDirector: contains(token) }, + { profitCenter: contains(token) }, + { boundPhone: contains(token) }, + { verificationContact: contains(token) }, + { jstOrderService: contains(token) }, + { accountGroupName: contains(token) }, + ]) }) + } + const map: Partial> = { + accountType: 'accountType', + operationOwner: 'operationOwner', + accountGroupName: 'accountGroupName', + } + const selectedStoreNames = splitStoreNames(filters.storeName) + if (selectedStoreNames.length > 0) and.push({ storeName: { in: selectedStoreNames } }) + for (const [key, field] of Object.entries(map)) { + const value = filters[key as StoreAccountFilterKey] + if (value) and.push({ [field]: contains(value) }) + } + return and.length ? { AND: and } : undefined +} + +export class StoreAccountManagementRepository { + async listPage(params: { skip: number; take: number; filters: StoreAccountFilters }) { + const where = buildWhere(params.filters) + const [rows, total] = await Promise.all([ + prisma.storeAccount.findMany({ where, select: ACCOUNT_SELECT, orderBy: [{ storeName: 'asc' }, { account: 'asc' }], skip: params.skip, take: params.take }), + prisma.storeAccount.count({ where }), + ]) + return { rows, total } + } + + async listStoreNames() { + const rows = await prisma.storeAccount.findMany({ + select: { storeName: true }, + distinct: ['storeName'], + orderBy: { storeName: 'asc' }, + }) + return rows.map((row) => row.storeName.trim()).filter(Boolean) + } + + async findById(id: string) { + return prisma.storeAccount.findUnique({ where: { id }, select: ACCOUNT_SELECT }) + } + + async findByKey(params: { storeName: string; accountType: StoreAccountType; account: string }) { + return prisma.storeAccount.findUnique({ + where: { storeName_accountType_account: params }, + select: ACCOUNT_SELECT, + }) + } + + async create(data: { + storeName: string + operationOwner?: string + operationDepartmentHead?: string + operationDirector?: string + profitCenter?: string + accountType: StoreAccountType + account: string + passwordCiphertext?: string + boundPhone?: string + verificationContact?: string + jstOrderService?: string + accountGroupName?: string + actorId: string + }) { + const { actorId, ...values } = data + return prisma.storeAccount.create({ data: { ...values, passwordCiphertext: values.passwordCiphertext ?? '', createdById: actorId, updatedById: actorId }, select: ACCOUNT_SELECT }) + } + + async update(id: string, data: Record & { updatedById: string }) { + return prisma.storeAccount.update({ where: { id }, data, select: ACCOUNT_SELECT }) + } + + async listByIds(ids: string[]) { + return prisma.storeAccount.findMany({ where: { id: { in: ids } }, select: ACCOUNT_SELECT }) + } + + async upsertByKey(data: { + storeName: string + accountType: StoreAccountType + account: string + values: Record + actorId: string + }) { + return prisma.storeAccount.upsert({ + where: { storeName_accountType_account: { storeName: data.storeName, accountType: data.accountType, account: data.account } }, + create: { ...data.values, storeName: data.storeName, accountType: data.accountType, account: data.account, createdById: data.actorId, updatedById: data.actorId }, + update: { ...data.values, updatedById: data.actorId }, + select: ACCOUNT_SELECT, + }) + } + + async createLog(data: { storeAccountId: string | null; storeName: string; operation: string; actorId: string; actorName: string; changes: unknown }) { + return prisma.storeAccountChangeLog.create({ data: { ...data, changes: data.changes as object } }) + } + + async listLogs(params: { storeAccountId?: string; storeName?: string; take: number }) { + return prisma.storeAccountChangeLog.findMany({ + where: params.storeAccountId ? { storeAccountId: params.storeAccountId } : params.storeName ? { storeName: params.storeName } : undefined, + orderBy: { createdAt: 'desc' }, + take: params.take, + }) + } + + async listFieldPermissions() { + return prisma.storeAccountFieldPermission.findMany({ select: { fieldKey: true, subjectKey: true, visibility: true } }) + } + + async saveFieldPermissions(params: { updates: StoreAccountFieldPermissionUpdate[]; updatedById: string }) { + return prisma.$transaction(params.updates.map((item) => item.visibility === null + ? prisma.storeAccountFieldPermission.deleteMany({ + where: { fieldKey: item.fieldKey, subjectKey: item.subjectKey }, + }) + : prisma.storeAccountFieldPermission.upsert({ + where: { fieldKey_subjectKey: { fieldKey: item.fieldKey, subjectKey: item.subjectKey } }, + create: { ...item, visibility: item.visibility, updatedById: params.updatedById }, + update: { visibility: item.visibility, updatedById: params.updatedById }, + }))) + } +} + +export const storeAccountManagementRepository = new StoreAccountManagementRepository() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-export.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-export.repository.ts new file mode 100644 index 00000000..b969b1af --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-image-export.repository.ts @@ -0,0 +1,46 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive' +import { buildProductArchiveWhere } from './supply-chain-product-archive.repository' + +const productArchiveImageExportSelect = { + barcode: true, + firstSourceCode: true, + fieldSourceMap: true, + image: true, + imageCache: { + select: { + previewUrl: true, + source: true, + viewKey: true, + status: true, + }, + }, +} satisfies Prisma.SupplyChainProductArchiveSelect + +export type ProductArchiveImageExportRecord = + Prisma.SupplyChainProductArchiveGetPayload<{ + select: typeof productArchiveImageExportSelect + }> + +export interface SupplyChainProductArchiveImageExportRepository { + listRows(filters?: ProductArchiveFilters): Promise +} + +class SupplyChainProductArchiveImageExportRepositoryClass + implements SupplyChainProductArchiveImageExportRepository +{ + async listRows(filters?: ProductArchiveFilters) { + return prisma.supplyChainProductArchive.findMany({ + where: buildProductArchiveWhere(filters), + select: productArchiveImageExportSelect, + orderBy: [ + { businessCreatedAt: 'desc' }, + { barcode: 'asc' }, + ], + }) + } +} + +export const supplyChainProductArchiveImageExportRepository = + new SupplyChainProductArchiveImageExportRepositoryClass() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-warehouse-config.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-warehouse-config.repository.ts index eabd8f2c..0286e929 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-warehouse-config.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-warehouse-config.repository.ts @@ -8,6 +8,7 @@ import type { ProductArchiveWarehouseConfigStatus } from '@/lib/supply-chain-pro const warehouseConfigSelect = { id: true, warehouseName: true, + warehouseOwnership: true, warehouseHead: true, storageType: true, status: true, @@ -29,12 +30,14 @@ export interface SupplyChainProductArchiveWarehouseConfigRepository { listActive(): Promise create(input: { warehouseName: string + warehouseOwnership: string warehouseHead: string storageType: ProductArchiveStorageType }): Promise update(input: { id: string warehouseName: string + warehouseOwnership: string warehouseHead: string storageType: ProductArchiveStorageType status: ProductArchiveWarehouseConfigStatus @@ -87,6 +90,7 @@ class SupplyChainProductArchiveWarehouseConfigRepositoryClass async create(input: { warehouseName: string + warehouseOwnership: string warehouseHead: string storageType: ProductArchiveStorageType }) { @@ -99,6 +103,7 @@ class SupplyChainProductArchiveWarehouseConfigRepositoryClass async update(input: { id: string warehouseName: string + warehouseOwnership: string warehouseHead: string storageType: ProductArchiveStorageType status: ProductArchiveWarehouseConfigStatus @@ -107,6 +112,7 @@ class SupplyChainProductArchiveWarehouseConfigRepositoryClass where: { id: input.id }, data: { warehouseName: input.warehouseName, + warehouseOwnership: input.warehouseOwnership, warehouseHead: input.warehouseHead, storageType: input.storageType, status: input.status, diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts index 33eaa65d..70dc9537 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts @@ -11,6 +11,14 @@ const productArchiveSelect = { spu: true, sku: true, image: true, + imageCache: { + select: { + previewUrl: true, + source: true, + viewKey: true, + status: true, + }, + }, firstSourceCode: true, businessCreatedAt: true, categoryL1: true, diff --git a/furniture-3d-system/src/server/repositories/supply-chain-supplier-information.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-supplier-information.repository.ts new file mode 100644 index 00000000..008e171a --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-supplier-information.repository.ts @@ -0,0 +1,192 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import type { + SupplyChainSupplierInformationFilters, + SupplyChainSupplierInformationUpdates, +} from '@/lib/supply-chain-supplier-information' + +export type SupplyChainSupplierAggregateRecord = { + supplierKey: string + supplierName: string + barcodeCount: number + purchaserValues: string[] + minimumOrderQuantityValues: string[] + firstLeadTimeValues: string[] + stableLeadTimeValues: string[] + palletSpecValues: string[] + backupSupplierValues: string[] + supplierTypeValues: string[] +} + +export type SupplyChainSupplierArchiveRecord = { + id: string + barcode: string + supplierName: string + purchaseOwner: string + minimumOrderQuantity: string + firstLeadTime: string + stableLeadTime: string + palletSpec: string + backupSupplier: string + supplierType: string +} + +export interface SupplyChainSupplierInformationRepository { + listPage(params: { + skip: number + take: number + filters: SupplyChainSupplierInformationFilters + }): Promise<{ + records: SupplyChainSupplierAggregateRecord[] + total: number + }> + updateSupplierFields(params: { + supplierKey: string + updates: SupplyChainSupplierInformationUpdates + }): Promise +} + +function likePattern(value: string) { + return `%${value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%` +} + +function buildAggregateFilterClause(filters: SupplyChainSupplierInformationFilters) { + const clauses: Prisma.Sql[] = [] + if (filters.purchaser) { + clauses.push(Prisma.sql`BOOL_OR("purchaseOwner" ILIKE ${likePattern(filters.purchaser)} ESCAPE '\\')`) + } + if (filters.supplier) { + clauses.push(Prisma.sql`BOOL_OR("supplierName" ILIKE ${likePattern(filters.supplier)} ESCAPE '\\')`) + } + if (filters.minimumOrderQuantity) { + clauses.push(Prisma.sql`BOOL_OR("minimumOrderQuantity" ILIKE ${likePattern(filters.minimumOrderQuantity)} ESCAPE '\\')`) + } + if (filters.firstLeadTime) { + clauses.push(Prisma.sql`BOOL_OR("firstLeadTime" ILIKE ${likePattern(filters.firstLeadTime)} ESCAPE '\\')`) + } + if (filters.stableLeadTime) { + clauses.push(Prisma.sql`BOOL_OR("stableLeadTime" ILIKE ${likePattern(filters.stableLeadTime)} ESCAPE '\\')`) + } + if (filters.palletSpec) { + clauses.push(Prisma.sql`BOOL_OR("palletSpec" ILIKE ${likePattern(filters.palletSpec)} ESCAPE '\\')`) + } + if (filters.backupSupplier) { + clauses.push(Prisma.sql`BOOL_OR("backupSupplier" ILIKE ${likePattern(filters.backupSupplier)} ESCAPE '\\')`) + } + if (filters.supplierType) { + clauses.push(Prisma.sql`BOOL_OR("supplierType" ILIKE ${likePattern(filters.supplierType)} ESCAPE '\\')`) + } + return clauses.length > 0 + ? Prisma.sql`HAVING ${Prisma.join(clauses, ' AND ')}` + : Prisma.empty +} + +export class SupplyChainSupplierInformationRepositoryClass + implements SupplyChainSupplierInformationRepository +{ + async listPage(params: { + skip: number + take: number + filters: SupplyChainSupplierInformationFilters + }) { + const filterClause = buildAggregateFilterClause(params.filters) + const groupedSuppliers = Prisma.sql` + SELECT + LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) AS "supplierKey", + MIN(BTRIM("supplierName")) AS "supplierName", + COUNT(DISTINCT "barcode")::int AS "barcodeCount", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("purchaseOwner")) + FILTER (WHERE NULLIF(BTRIM("purchaseOwner"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "purchaserValues", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("minimumOrderQuantity")) + FILTER (WHERE NULLIF(BTRIM("minimumOrderQuantity"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "minimumOrderQuantityValues", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("firstLeadTime")) + FILTER (WHERE NULLIF(BTRIM("firstLeadTime"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "firstLeadTimeValues", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("stableLeadTime")) + FILTER (WHERE NULLIF(BTRIM("stableLeadTime"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "stableLeadTimeValues", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("palletSpec")) + FILTER (WHERE NULLIF(BTRIM("palletSpec"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "palletSpecValues", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("backupSupplier")) + FILTER (WHERE NULLIF(BTRIM("backupSupplier"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "backupSupplierValues", + COALESCE( + ARRAY_AGG(DISTINCT BTRIM("supplierType")) + FILTER (WHERE NULLIF(BTRIM("supplierType"), '') IS NOT NULL), + ARRAY[]::text[] + ) AS "supplierTypeValues" + FROM "SupplyChainProductArchive" + WHERE NULLIF(BTRIM("supplierName"), '') IS NOT NULL + GROUP BY LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) + ${filterClause} + ` + + const [records, totals] = await Promise.all([ + prisma.$queryRaw(Prisma.sql` + WITH "groupedSuppliers" AS (${groupedSuppliers}) + SELECT * + FROM "groupedSuppliers" + ORDER BY "supplierName" ASC + OFFSET ${params.skip} + LIMIT ${params.take} + `), + prisma.$queryRaw>(Prisma.sql` + WITH "groupedSuppliers" AS (${groupedSuppliers}) + SELECT COUNT(*)::int AS "total" + FROM "groupedSuppliers" + `), + ]) + + return { records, total: totals[0]?.total ?? 0 } + } + + async updateSupplierFields(params: { + supplierKey: string + updates: SupplyChainSupplierInformationUpdates + }) { + return prisma.$transaction(async (tx) => { + const records = await tx.$queryRaw(Prisma.sql` + SELECT + "id", + "barcode", + "supplierName", + "purchaseOwner", + "minimumOrderQuantity", + "firstLeadTime", + "stableLeadTime", + "palletSpec", + "backupSupplier", + "supplierType" + FROM "SupplyChainProductArchive" + WHERE LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) = ${params.supplierKey} + ORDER BY "barcode" ASC + FOR UPDATE + `) + if (records.length === 0) return [] + + await tx.supplyChainProductArchive.updateMany({ + where: { id: { in: records.map((record) => record.id) } }, + data: params.updates, + }) + return records + }) + } +} + +export const supplyChainSupplierInformationRepository = + new SupplyChainSupplierInformationRepositoryClass() diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts index 65fa9819..917126bd 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts @@ -260,6 +260,41 @@ describe('product archive material image service', () => { expect(fetchImpl).toHaveBeenCalledTimes(1) }) + it('ignores a historical product-image cache when the material lookup misses', async () => { + const cacheRepository = { + ...createImageCacheRepository(), + findByIdentifiers: jest.fn().mockResolvedValue({ + id: 'cache-product-fallback', + archiveId: 'archive-product-fallback', + barcode: 'BARCODE-PRODUCT-FALLBACK', + spu: '', + sku: 'SKU-PRODUCT-FALLBACK', + previewUrl: 'https://image.example.com/product-fallback.webp', + source: 'product', + viewKey: 'product-catalog-wide-product', + viewLabel: '产品图片', + status: 'RESOLVED', + checkedAt: new Date(), + error: '', + }), + } satisfies SupplyChainProductArchiveImageCacheRepository + const fetchImpl = jest.fn(async () => jsonResponse(404, { + error: { code: 'WHITE_BACKGROUND_NOT_FOUND' }, + })) as unknown as typeof fetch + const service = new ProductArchiveMaterialImageService( + fetchImpl, + config, + Date.now, + cacheRepository, + ) + + await expect(service.resolve({ + archiveId: 'archive-product-fallback', + sku: 'SKU-PRODUCT-FALLBACK', + })).resolves.toBeNull() + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + it('uses the latest barcode image when the barcode lookup succeeds', async () => { const fetchImpl = jest.fn(async () => jsonResponse(200, { data: { diff --git a/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts b/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts new file mode 100644 index 00000000..066c8636 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts @@ -0,0 +1,119 @@ +import { StoreAccountManagementService } from '../store-account-management.service' +import type { StoreAccountManagementRepository } from '@/server/repositories/store-account-management.repository' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +describe('StoreAccountManagementService field permission settings', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('passes a null personal value to the repository to restore business type inheritance', async () => { + const saveFieldPermissions = jest.fn().mockResolvedValue([]) + const service = new StoreAccountManagementService({ saveFieldPermissions } as unknown as StoreAccountManagementRepository) + + const result = await service.saveFieldPermissions({ + updates: [{ + fieldKey: 'operationOwner', + subjectKey: 'user@user-1', + visibility: null, + }], + user: { + id: 'admin-1', + email: 'admin@example.com', + name: '管理员', + role: 'ADMIN', + status: 'ACTIVE', + }, + }) + + expect(saveFieldPermissions).toHaveBeenCalledWith({ + updates: [{ + fieldKey: 'operationOwner', + subjectKey: 'user@user-1', + visibility: null, + }], + updatedById: 'admin-1', + }) + expect(result).toEqual({ saved: 0, inherited: 1 }) + }) + + it('rejects system fields and unknown business types', async () => { + const saveFieldPermissions = jest.fn().mockResolvedValue([]) + const service = new StoreAccountManagementService({ saveFieldPermissions } as unknown as StoreAccountManagementRepository) + const user = { + id: 'admin-1', + email: 'admin@example.com', + name: '管理员', + role: 'ADMIN' as const, + status: 'ACTIVE' as const, + } + + await expect(service.saveFieldPermissions({ + updates: [{ fieldKey: 'createdAt', subjectKey: 'operation', visibility: 'READONLY' }], + user, + })).rejects.toThrow('字段权限内容不正确') + await expect(service.saveFieldPermissions({ + updates: [{ fieldKey: 'operationOwner', subjectKey: 'unknownRole', visibility: 'READONLY' }], + user, + })).rejects.toThrow('字段权限内容不正确') + await expect(service.saveFieldPermissions({ + updates: [{ fieldKey: 'operationOwner', subjectKey: 'space', visibility: 'READONLY' }], + user, + })).rejects.toThrow('字段权限内容不正确') + expect(saveFieldPermissions).not.toHaveBeenCalled() + }) + + it('returns only store account page permissions to the field permission dialog', async () => { + const listFieldPermissions = jest.fn().mockResolvedValue([{ + fieldKey: 'operationOwner', + subjectKey: 'operation', + visibility: 'READONLY', + }]) + jest.spyOn(supplyChainPermissionSettingsService, 'getSettings').mockResolvedValue({ + overrides: { + 'storeAccountManagement:operation:view': true, + 'storeAccountManagement:user@user-1:edit': true, + 'productArchive:operation:view': true, + }, + }) + const service = new StoreAccountManagementService({ listFieldPermissions } as unknown as StoreAccountManagementRepository) + + const result = await service.getFieldPermissionSettings({ + id: 'admin-1', + email: 'admin@example.com', + name: '管理员', + role: 'ADMIN', + status: 'ACTIVE', + }) + + expect(result.permissions).toHaveLength(1) + expect(result.pagePermissionOverrides).toEqual({ + 'storeAccountManagement:operation:view': true, + 'storeAccountManagement:user@user-1:edit': true, + }) + }) + + it('does not allow a non-admin to read or save field permission settings', async () => { + const listFieldPermissions = jest.fn() + const saveFieldPermissions = jest.fn() + const service = new StoreAccountManagementService({ + listFieldPermissions, + saveFieldPermissions, + } as unknown as StoreAccountManagementRepository) + const user = { + id: 'user-1', + email: 'user@example.com', + name: '普通用户', + role: 'USER' as const, + status: 'ACTIVE' as const, + } + + await expect(service.getFieldPermissionSettings(user)).rejects.toThrow('只有管理员可以查看字段权限配置') + await expect(service.saveFieldPermissions({ + updates: [{ fieldKey: 'operationOwner', subjectKey: 'operation', visibility: 'READONLY' }], + user, + })).rejects.toThrow('只有管理员可以配置字段权限') + expect(listFieldPermissions).not.toHaveBeenCalled() + expect(saveFieldPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts index 05c54e54..b51ef56c 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts @@ -26,8 +26,33 @@ jest.mock('@/server/repositories/supply-chain-product-archive-clearance-stock.re import { recordProductArchiveFieldChanges } from '../supply-chain-product-archive-change-log.service' import { SupplyChainProductArchiveEnrichmentSyncService } from '../supply-chain-product-archive-enrichment-sync.service' +import { buildProductArchiveEnrichmentUpdates } from '../supply-chain-product-archive-enrichment-sync.mapper' describe('supply-chain product archive enrichment sync', () => { + it('does not clear archive warehouse fields when no configuration matches', () => { + const updates = buildProductArchiveEnrichmentUpdates( + { + id: 'archive-1', + barcode: 'BAR-001', + spu: 'SPU-001', + sku: 'SKU-001', + supplierName: '', + inOutWarehouse: 'WH-1', + }, + { + clearanceStocks: [], + suppliers: [], + stocks: [], + warehouses: [], + prices: [], + catalogCosts: [], + warehouseConfigs: [], + }, + ) + + expect(updates.some((update) => 'warehouseHead' in update.patch || 'storageType' in update.patch)).toBe(false) + }) + it('persists every external group and logs only later value changes', async () => { const target = { id: 'archive-1', @@ -123,9 +148,10 @@ describe('supply-chain product archive enrichment sync', () => { }, configRepository: { listActive: jest.fn().mockResolvedValue([{ - id: 'warehouse-config-1', - warehouseName: 'WH-1', - warehouseHead: '负责人', + id: 'warehouse-config-1', + warehouseName: 'WH-1', + warehouseOwnership: 'FACTORY', + warehouseHead: '负责人', storageType: 'INBOUND', }]), }, diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-image-export.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-image-export.service.test.ts new file mode 100644 index 00000000..61332d8a --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-image-export.service.test.ts @@ -0,0 +1,102 @@ +import ExcelJS from 'exceljs' +import type { ProductArchiveImageExportRecord } from '@/server/repositories/supply-chain-product-archive-image-export.repository' +import { + resolveProductArchiveImageExportUrl, + SupplyChainProductArchiveImageExportService, +} from '../supply-chain-product-archive-image-export.service' + +function record( + values: Partial, +): ProductArchiveImageExportRecord { + return { + barcode: 'BAR-001', + firstSourceCode: 'JST_SKU', + fieldSourceMap: {}, + image: null, + imageCache: null, + ...values, + } +} + +describe('product archive image export', () => { + it('uses the same image source priority as the archive page', () => { + expect(resolveProductArchiveImageExportUrl(record({ + firstSourceCode: 'PRODUCT_DEVELOPMENT', + image: 'https://images.example.com/new-sku.webp', + imageCache: { + previewUrl: 'https://images.example.com/material.webp', + source: 'sku', + viewKey: 'sku-zip', + status: 'RESOLVED', + }, + }))).toBe('https://images.example.com/material.webp') + + expect(resolveProductArchiveImageExportUrl(record({ + image: 'https://images.example.com/jst.webp', + imageCache: { + previewUrl: 'https://images.example.com/material.webp', + source: 'barcode', + viewKey: 'latest', + status: 'RESOLVED', + }, + }))).toBe('https://images.example.com/material.webp') + + expect(resolveProductArchiveImageExportUrl(record({ + image: 'https://images.example.com/jst.webp', + }))).toBe('https://images.example.com/jst.webp') + + expect(resolveProductArchiveImageExportUrl(record({ + imageCache: { + previewUrl: 'https://cp.jiansun.vip/api/storage/object?key=product.webp', + source: 'product', + viewKey: 'product-catalog-wide-product', + status: 'RESOLVED', + }, + }))).toBe('') + + expect(resolveProductArchiveImageExportUrl(record({ + firstSourceCode: 'PRODUCT_DEVELOPMENT', + image: 'https://images.example.com/new-sku.webp', + }))).toBe('https://images.example.com/new-sku.webp') + }) + + it('exports all matched barcodes and image URLs as two text columns', async () => { + const repository = { + listRows: jest.fn().mockResolvedValue([ + record({ + barcode: '001234567890', + imageCache: { + previewUrl: 'https://cp.jiansun.vip/api/storage/object?key=folder%2Fone.webp', + source: 'barcode', + viewKey: 'latest', + status: 'RESOLVED', + }, + }), + record({ barcode: 'BAR-EMPTY' }), + ]), + } + const service = new SupplyChainProductArchiveImageExportService( + repository, + () => new Date('2026-08-25T09:30:45.000Z'), + ) + + const payload = await service.createWorkbook({ + filters: { spu: ['SPU-001'] }, + siteOrigin: 'http://localhost:3001', + }) + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(payload.bytes) + const worksheet = workbook.getWorksheet('图片URL')! + + expect(repository.listRows).toHaveBeenCalledWith({ spu: ['SPU-001'] }) + expect(payload.total).toBe(2) + expect(payload.fileName).toMatch(/^产品档案图片URL-20260825-/) + expect(worksheet.getRow(1).values).toEqual([, '条码', '图片URL']) + expect(worksheet.getRow(2).values).toEqual([ + , + '001234567890', + 'http://localhost:3001/api/storage/object?key=folder%2Fone.webp', + ]) + expect(worksheet.getRow(3).values).toEqual([, 'BAR-EMPTY', '']) + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-query.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-query.test.ts new file mode 100644 index 00000000..35fb9111 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-query.test.ts @@ -0,0 +1,26 @@ +import { parseProductArchiveFilters } from '../supply-chain-product-archive-query' + +describe('product archive filter query parser', () => { + it('keeps supported filters for list and image export queries', () => { + expect(parseProductArchiveFilters(JSON.stringify({ + keyword: '茶几', + categoryL1: ['桌类'], + barcode: ['BAR-001'], + createTimeFrom: '2026-08-01', + }))).toEqual({ + success: true, + data: { + keyword: '茶几', + categoryL1: ['桌类'], + barcode: ['BAR-001'], + createTimeFrom: '2026-08-01', + }, + }) + }) + + it('rejects malformed or unknown filter values', () => { + expect(parseProductArchiveFilters('{')).toEqual({ success: false }) + expect(parseProductArchiveFilters(JSON.stringify({ unknown: true }))).toEqual({ success: false }) + expect(parseProductArchiveFilters(JSON.stringify({ productStatus: ['停售'] }))).toEqual({ success: false }) + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts index 31476990..39461de3 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts @@ -48,7 +48,7 @@ function createSourceRecord() { package_volume_cm3: '1', package_set_count: '1', purchase_order_method: '非订单式', - archive_shipping_method: '代发', + factory_warehouse: '成品仓', sku_image_url: 'https://cp.jiansun.vip/api/storage/object?key=sku-1.jpeg', updated_at: new Date('2026-07-17T08:00:00.000Z'), } @@ -98,14 +98,15 @@ describe('supply-chain product archive wide-catalog sync', () => { ) }) - it('does not use the product-level image_url when the current SKU has no image', () => { + it('leaves the image empty when the current SKU has no image', () => { const mapped = mapProductCatalogWideArchiveSource({ ...createSourceRecord(), archive_image: null, - sku_image_url: 'https://image.example.com/shared-spu-image.webp', + sku_image_url: null, }) expect(mapped?.archive.image).toBeNull() + expect(mapped).not.toHaveProperty('productImageUrl') }) it('maps Prisma Decimal measurements returned by the wide catalog query', () => { diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-warehouse-config.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-warehouse-config.service.test.ts index f62f9e76..65ff8f1c 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-warehouse-config.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-warehouse-config.service.test.ts @@ -5,11 +5,13 @@ import type { ProductArchiveWarehouseConfigRecord, SupplyChainProductArchiveWarehouseConfigRepository, } from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository' +import { inferProductArchiveWarehouseOwnership } from '@/lib/supply-chain-product-archive-warehouse-config' function configuration(overrides: Partial = {}) { return { id: 'config-1', warehouseName: '杭州仓', + warehouseOwnership: '', warehouseHead: '负责人甲', storageType: 'INBOUND', status: 'ACTIVE', @@ -32,6 +34,11 @@ function repository( } describe('SupplyChainProductArchiveWarehouseConfigService', () => { + it('infers factory ownership only from the factory warehouse prefix', () => { + expect(inferProductArchiveWarehouseOwnership('工厂-常州博一代发仓')).toBe('FACTORY') + expect(inferProductArchiveWarehouseOwnership('横林二仓')).toBeNull() + }) + it('combines archive warehouse names with saved configurations', async () => { const service = new SupplyChainProductArchiveWarehouseConfigService(repository({ warehouseNames: ['杭州仓', '上海仓'], @@ -87,4 +94,21 @@ describe('SupplyChainProductArchiveWarehouseConfigService', () => { expect(result.items[0].configured).toBe(false) expect(result.pageInfo).toEqual({ page: 1, pageSize: 1, total: 2, totalPages: 2 }) }) + + it('shows inferred ownership for unconfigured warehouse rows', async () => { + const service = new SupplyChainProductArchiveWarehouseConfigService(repository({ + warehouseNames: ['工厂-常州仓', '横林二仓'], + configurations: [], + })) + + const result = await service.list({}) + + expect(result.items).toHaveLength(2) + expect(result.items.find((item) => item.warehouseName === '横林二仓')).toEqual( + expect.objectContaining({ warehouseOwnership: null }), + ) + expect(result.items.find((item) => item.warehouseName === '工厂-常州仓')).toEqual( + expect.objectContaining({ warehouseOwnership: 'FACTORY' }), + ) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts index fda709fa..7ee9fd9f 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive.service.test.ts @@ -1,5 +1,6 @@ import { calculateJstPublicAvailableStock, + resolveProductArchiveWarehouseFields, SupplyChainProductArchiveService, } from '../supply-chain-product-archive.service' import { @@ -22,6 +23,37 @@ describe('supply-chain product archive field mapping', () => { ).toBe('60') }) + it('infers ownership and applies configured warehouse fields without clearing fallbacks', () => { + expect(resolveProductArchiveWarehouseFields({ + warehouseName: '工厂-常州仓', + currentWarehouseHead: '当前采购', + currentStorageType: '入库', + })).toEqual({ + warehouseOwnership: '工厂', + warehouseHead: '当前采购', + storageType: '入库', + }) + expect(resolveProductArchiveWarehouseFields({ + warehouseName: '横林二仓', + currentWarehouseHead: '颜回', + currentStorageType: '代发', + config: { + id: 'legacy-config', + warehouseName: '横林二仓', + warehouseOwnership: '', + warehouseHead: '历史负责人', + storageType: 'INBOUND', + status: 'ACTIVE', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), + }, + })).toEqual({ + warehouseOwnership: '未配置', + warehouseHead: '历史负责人', + storageType: '入库', + }) + }) + it('only exposes the three confirmed sales statuses', () => { expect(normalizeProductArchiveSalesStatus('销售')).toBe('销售') expect(normalizeProductArchiveSalesStatus('停产')).toBe('停产') @@ -49,6 +81,8 @@ describe('supply-chain product archive field mapping', () => { id: 'sku-row-1', inOutWarehouse: 'WH-1', image: null, + imageCache: null, + firstSourceCode: 'PRODUCT_DEVELOPMENT', sku: 'SKU-001', barcode: 'BAR-001', spu: 'SPU-001', @@ -113,9 +147,22 @@ describe('supply-chain product archive field mapping', () => { }, ]), } + const warehouseConfigRepository = { + listActive: jest.fn().mockResolvedValue([{ + id: 'warehouse-config-1', + warehouseName: 'wh-1', + warehouseOwnership: 'JIANSHANG', + warehouseHead: '配置负责人', + storageType: 'DROPSHIP', + status: 'ACTIVE', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), + }]), + } const service = new SupplyChainProductArchiveService( repository as never, inventoryRepository, + warehouseConfigRepository, ) const page = await service.listPage({ page: 1, pageSize: 50 }) @@ -141,8 +188,9 @@ describe('supply-chain product archive field mapping', () => { packType: '双包', orderType: '订单式', warehouseType: '成品发货仓-真实库存', - warehouseHead: '负责人', - storageType: '入库', + warehouseOwnership: '简尚', + warehouseHead: '配置负责人', + storageType: '代发', supplier: '源沃家具', supplierType: '家具厂', supplierStatus: '合作', diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-supplier-information.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-supplier-information.service.test.ts new file mode 100644 index 00000000..9c06541b --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-supplier-information.service.test.ts @@ -0,0 +1,195 @@ +import { SupplyChainSupplierInformationService } from '../supply-chain-supplier-information.service' +import type { SupplyChainSupplierInformationRepository } from '@/server/repositories/supply-chain-supplier-information.repository' + +function createRepository( + overrides: Partial = {}, +): SupplyChainSupplierInformationRepository { + return { + listPage: jest.fn().mockResolvedValue({ total: 0, records: [] }), + updateSupplierFields: jest.fn().mockResolvedValue([]), + ...overrides, + } +} + +describe('SupplyChainSupplierInformationService', () => { + it('returns one normalized supplier row including maintained supplier fields', async () => { + const repository = createRepository({ + listPage: jest.fn().mockResolvedValue({ + total: 1, + records: [{ + supplierKey: '浩远', + supplierName: '浩远', + barcodeCount: 12, + purchaserValues: ['采购B', '采购A', '采购A'], + minimumOrderQuantityValues: ['20'], + firstLeadTimeValues: ['15', '7'], + stableLeadTimeValues: ['30天'], + palletSpecValues: ['60', '30'], + backupSupplierValues: ['备选厂B', '备选厂A'], + supplierTypeValues: ['成品供应商'], + }], + }), + }) + const service = new SupplyChainSupplierInformationService(repository) + + const result = await service.listPage({ + page: 1, + pageSize: 50, + filters: { + purchaser: ' 采购A ', + supplier: ' 浩远 ', + minimumOrderQuantity: '', + firstLeadTime: '', + stableLeadTime: '', + palletSpec: '', + backupSupplier: '', + supplierType: '', + }, + }) + + expect(repository.listPage).toHaveBeenCalledWith({ + skip: 0, + take: 50, + filters: { + purchaser: '采购A', + supplier: '浩远', + minimumOrderQuantity: '', + firstLeadTime: '', + stableLeadTime: '', + palletSpec: '', + backupSupplier: '', + supplierType: '', + }, + }) + expect(result.rows[0]).toEqual(expect.objectContaining({ + purchaserValues: ['采购A', '采购B'], + firstLeadTimeValues: ['7', '15'], + palletSpecValues: ['30', '60'], + stableLeadTimeValues: ['30天'], + backupSupplierValues: ['备选厂A', '备选厂B'], + })) + expect(result.pageInfo).toEqual({ + page: 1, + pageSize: 50, + total: 1, + totalPages: 1, + }) + }) + + it('bounds pagination before querying the repository', async () => { + const repository = createRepository({ + listPage: jest.fn().mockResolvedValue({ total: 250, records: [] }), + }) + const service = new SupplyChainSupplierInformationService(repository) + + const result = await service.listPage({ page: -2, pageSize: 500 }) + + expect(repository.listPage).toHaveBeenCalledWith({ + skip: 0, + take: 200, + filters: { + purchaser: '', + supplier: '', + minimumOrderQuantity: '', + firstLeadTime: '', + stableLeadTime: '', + palletSpec: '', + backupSupplier: '', + supplierType: '', + }, + }) + expect(result.pageInfo.totalPages).toBe(2) + }) + + it('updates only supplied values and records each changed barcode field', async () => { + const repository = createRepository({ + updateSupplierFields: jest.fn().mockResolvedValue([ + { + id: 'archive-1', + barcode: 'BC-001', + supplierName: '浩远', + purchaseOwner: '采购A', + minimumOrderQuantity: '10', + firstLeadTime: '7', + stableLeadTime: '', + palletSpec: '标准盘', + backupSupplier: '', + supplierType: '成品', + }, + { + id: 'archive-2', + barcode: 'BC-002', + supplierName: '浩远', + purchaseOwner: '采购B', + minimumOrderQuantity: '10', + firstLeadTime: '7', + stableLeadTime: '', + palletSpec: '标准盘', + backupSupplier: '', + supplierType: '成品', + }, + ]), + }) + const recordChanges = jest.fn().mockResolvedValue(undefined) + const service = new SupplyChainSupplierInformationService(repository, recordChanges) + + const result = await service.updateSupplier({ + supplierKey: ' 浩 远 ', + updates: { + purchaseOwner: ' 采购C ', + minimumOrderQuantity: ' ', + stableLeadTime: ' 30天 ', + backupSupplier: ' 备选厂 ', + supplierType: '成品', + }, + user: { id: 'buyer-1', name: '采购员', role: 'USER' } as never, + }) + + expect(repository.updateSupplierFields).toHaveBeenCalledWith({ + supplierKey: '浩远', + updates: { + purchaseOwner: '采购C', + stableLeadTime: '30天', + backupSupplier: '备选厂', + supplierType: '成品', + }, + }) + expect(recordChanges).toHaveBeenCalledTimes(2) + expect(recordChanges).toHaveBeenNthCalledWith(1, expect.objectContaining({ + barcode: 'BC-001', + changes: [ + { + field: 'purchaseOwner', + fieldLabel: '采购', + previousValue: '采购A', + nextValue: '采购C', + }, + { + field: 'stableLeadTime', + fieldLabel: '稳定生产周期', + previousValue: '', + nextValue: '30天', + }, + { + field: 'backupSupplier', + fieldLabel: '替换供应商', + previousValue: '', + nextValue: '备选厂', + }, + ], + })) + expect(result).toEqual({ affectedBarcodes: 2, changedValues: 6, supplierKey: '浩远' }) + }) + + it('does not write when every edit field is blank', async () => { + const repository = createRepository() + const service = new SupplyChainSupplierInformationService(repository, jest.fn()) + + await expect(service.updateSupplier({ + supplierKey: '浩远', + updates: { purchaseOwner: ' ', palletSpec: '' }, + user: { id: 'buyer-1', name: '采购员', role: 'USER' } as never, + })).rejects.toThrow('请至少填写一项要修改的内容') + expect(repository.updateSupplierFields).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts index 3e779743..dec22d49 100644 --- a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts +++ b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts @@ -222,6 +222,7 @@ export class ProductArchiveMaterialImageService { && !persistedIsProductDevelopmentImage && persisted?.status === 'RESOLVED' && persisted.previewUrl + && persisted.source !== 'product' ) { const image = { previewUrl: persisted.previewUrl, diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts new file mode 100644 index 00000000..6b5a06ab --- /dev/null +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -0,0 +1,317 @@ +import type { AuthUser } from '@/server/core/types' +import { AppError, ErrorCode } from '@/server/core/errors' +import { + EMPTY_STORE_ACCOUNT_FILTERS, + STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS, + STORE_ACCOUNT_EDITABLE_FIELD_KEYS, + STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES, + STORE_ACCOUNT_FIELDS, + isStoreAccountFieldPermissionSubjectKey, + isStoreAccountFieldVisibility, + resolveStoreAccountFieldVisibility, + type StoreAccountBatchEditableFieldKey, + type StoreAccountBatchPreview, + type StoreAccountFieldAccess, + type StoreAccountFieldKey, + type StoreAccountFieldPermissionUpdate, + type StoreAccountFilters, + type StoreAccountPage, + type StoreAccountType, +} from '@/lib/store-account-management' +import { decryptStoreAccountPassword, encryptStoreAccountPassword } from '@/lib/store-account-crypto' +import { canManageSupplyChainPermissions, resolveSupplyChainPermissionSubjectKeys } from '@/lib/supply-chain-permission-settings' +import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service' +import { + storeAccountManagementRepository, + type StoreAccountManagementRepository, + type StoreAccountRecord, +} from '@/server/repositories/store-account-management.repository' + +const DEFAULT_MAX_PAGE_SIZE = 200 + +function clean(value: unknown, max = 240) { + return String(value ?? '').trim().slice(0, max) +} + +function normalizeType(value: unknown): StoreAccountType { + const type = clean(value, 20).toUpperCase() + if (type === 'MAIN' || type === '主账号' || type === '主账户') return 'MAIN' + if (type === 'SUB' || type === '子账号' || type === '子账户') return 'SUB' + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '账号类型只能是主账号或子账号') +} + +function normalizeFilters(input?: Partial) { + return Object.fromEntries(Object.keys(EMPTY_STORE_ACCOUNT_FILTERS).map((key) => [key, clean(input?.[key as keyof StoreAccountFilters], 120)])) as StoreAccountFilters +} + +function defaultFieldAccess(canEdit: boolean): StoreAccountFieldAccess { + return Object.fromEntries(STORE_ACCOUNT_FIELDS.map((field) => [ + field.key, + field.key === 'password' ? 'HIDDEN' : field.system ? 'READONLY' : canEdit ? 'EDITABLE' : 'READONLY', + ])) as StoreAccountFieldAccess +} + +const FIELD_PERMISSION_BUSINESS_SUBJECT_KEYS = new Set( + STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES.map((subject) => subject.key), +) +const STORE_ACCOUNT_PAGE_PERMISSION_PREFIX = 'storeAccountManagement:' + +export class StoreAccountManagementService { + constructor(private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository) {} + + async getFieldAccess(user: AuthUser) { + const target = await supplyChainPermissionSettingsService.getUserAccess(user, 'storeAccountManagement') + const access = defaultFieldAccess(target.edit) + if (!target.view) { + return { + target, + access: Object.fromEntries(STORE_ACCOUNT_FIELDS.map((field) => [field.key, 'HIDDEN'])) as StoreAccountFieldAccess, + } + } + + const records = await this.repository.listFieldPermissions() + const roleKeys = resolveSupplyChainPermissionSubjectKeys(user) + const userKey = user.id ? `user@${user.id}` : null + for (const field of STORE_ACCOUNT_FIELDS) { + const personal = userKey ? records.find((record) => record.fieldKey === field.key && record.subjectKey === userKey) : null + const values = records + .filter((record) => record.fieldKey === field.key && roleKeys.includes(record.subjectKey as never)) + .map((record) => record.visibility) + .filter(isStoreAccountFieldVisibility) + const personalValue = isStoreAccountFieldVisibility(personal?.visibility) ? personal.visibility : null + access[field.key] = resolveStoreAccountFieldVisibility({ + fieldKey: field.key, + pageView: target.view, + pageEdit: target.edit, + fallback: access[field.key], + roleValues: values, + personalValue, + }) + } + // 密码默认不可见,只有管理员明确设置为可编辑时才允许填写。 + if (canManageSupplyChainPermissions(user) && !records.some((record) => record.fieldKey === 'password' && record.subjectKey === userKey)) { + access.password = target.edit ? 'EDITABLE' : 'READONLY' + } + return { target, access } + } + + async listPage(params: { page?: number; pageSize?: number; filters?: Partial; user: AuthUser }): Promise { + const page = Math.max(1, Math.trunc(params.page || 1)) + const pageSize = Math.min(DEFAULT_MAX_PAGE_SIZE, Math.max(1, Math.trunc(params.pageSize || 50))) + const filters = normalizeFilters(params.filters) + const { target, access } = await this.getFieldAccess(params.user) + if (!target.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限') + const [result, storeNames] = await Promise.all([ + this.repository.listPage({ skip: (page - 1) * pageSize, take: pageSize, filters }), + this.repository.listStoreNames(), + ]) + const rows = result.rows.map((record) => this.toRow(record, access)) + return { rows, storeNames, pageInfo: { page, pageSize, total: result.total, totalPages: Math.max(1, Math.ceil(result.total / pageSize)) }, fieldAccess: access } + } + + async getOne(id: string, user: AuthUser) { + const { target, access } = await this.getFieldAccess(user) + if (!target.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限') + const record = await this.repository.findById(clean(id, 80)) + if (!record) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '店铺账号不存在') + return { row: this.toRow(record, access), fieldAccess: access } + } + + async create(input: Record, user: AuthUser) { + const { target, access } = await this.getFieldAccess(user) + if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限') + const values = this.prepareValues(input, access, true) + if (await this.repository.findByKey({ storeName: values.storeName, accountType: values.accountType, account: values.account })) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '相同店铺、账号类型和账号已存在') + } + const record = await this.repository.create({ ...values, actorId: user.id }) + await this.log(record, 'CREATE', user, this.changeSet({}, values, access)) + return this.toRow(record, access) + } + + async update(id: string, input: Record, user: AuthUser) { + const { target, access } = await this.getFieldAccess(user) + if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限') + const current = await this.repository.findById(clean(id, 80)) + if (!current) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '店铺账号不存在') + const values = this.prepareValues(input, access, false) + if (Object.keys(values).length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少填写一项可修改字段') + const next = await this.repository.update(current.id, { ...values, updatedById: user.id }) + await this.log(next, 'UPDATE', user, this.changeSet(current, values, access)) + return this.toRow(next, access) + } + + async importRows(rows: Array>, user: AuthUser) { + const { target, access } = await this.getFieldAccess(user) + if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限') + if (rows.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据') + if (rows.length > 5000) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行') + let created = 0 + let updated = 0 + for (const input of rows) { + const values = this.prepareValues(input, access, true) + const previous = await this.repository.findByKey({ storeName: values.storeName, accountType: values.accountType as StoreAccountType, account: values.account }) + const record = await this.repository.upsertByKey({ storeName: values.storeName, accountType: values.accountType as StoreAccountType, account: values.account, values, actorId: user.id }) + if (previous) updated += 1 + else created += 1 + await this.log(record, previous ? 'IMPORT_UPDATE' : 'IMPORT_CREATE', user, this.changeSet(previous ?? {}, values, access)) + } + return { created, updated } + } + + async batchPreview(input: { ids: string[]; field: StoreAccountBatchEditableFieldKey; user: AuthUser }): Promise { + const { target, access } = await this.getFieldAccess(input.user) + if (!target.edit || access[input.field] !== 'EDITABLE' || !STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS.includes(input.field)) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有批量查看该字段的权限') + } + const ids = [...new Set(input.ids.map((id) => clean(id, 80)).filter(Boolean))].slice(0, 500) + if (ids.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要预览的账号') + const currentRows = await this.repository.listByIds(ids) + const rowsById = new Map(currentRows.map((row) => [row.id, row])) + const rows = ids.flatMap((id) => { + const record = rowsById.get(id) + if (!record) return [] + return [{ + id: record.id, + storeName: access.storeName === 'HIDDEN' ? null : record.storeName, + accountType: access.accountType === 'HIDDEN' ? null : normalizeType(record.accountType), + account: access.account === 'HIDDEN' ? null : record.account, + currentValue: clean(record[input.field], 500), + }] + }) + return { rows, requested: ids.length, found: rows.length, missing: ids.length - rows.length } + } + + async batchUpdate(input: { ids: string[]; field: StoreAccountBatchEditableFieldKey; value: unknown; user: AuthUser }) { + const { target, access } = await this.getFieldAccess(input.user) + if (!target.edit || access[input.field] !== 'EDITABLE' || !STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS.includes(input.field)) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有批量修改该字段的权限') + } + const ids = [...new Set(input.ids.map((id) => clean(id, 80)).filter(Boolean))].slice(0, 500) + if (ids.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要修改的账号') + const currentRows = await this.repository.listByIds(ids) + const normalized = clean(input.value) + if (input.field === 'accountType') normalizeType(normalized) + const data: Record = { [input.field]: input.field === 'accountType' ? normalizeType(normalized) : normalized } + let changed = 0 + for (const current of currentRows) { + const next = await this.repository.update(current.id, { ...data, updatedById: input.user.id }) + const changes = this.changeSet(current, data, access) + if (changes.length > 0) { + changed += 1 + await this.log(next, 'BATCH_UPDATE', input.user, changes) + } + } + return { affected: currentRows.length, changed } + } + + async listLogs(params: { id?: string; storeName?: string; user: AuthUser }) { + const { target, access } = await this.getFieldAccess(params.user) + if (!target.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限') + const logs = await this.repository.listLogs({ storeAccountId: params.id, storeName: params.storeName, take: 200 }) + return logs.map((log) => ({ + ...log, + changes: Array.isArray(log.changes) + ? log.changes.filter((change) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) return false + const field = (change as { field?: unknown }).field + return typeof field === 'string' && access[field as StoreAccountFieldKey] !== 'HIDDEN' + }) + : [], + })) + } + + async revealPassword(id: string, user: AuthUser) { + const { target, access } = await this.getFieldAccess(user) + if (!target.view || access.password === 'HIDDEN') { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有查看密码的权限') + } + const record = await this.repository.findById(clean(id, 80)) + if (!record) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '店铺账号不存在') + await this.log(record, 'VIEW_PASSWORD', user, [{ field: 'password', previousValue: '', nextValue: '已查看' }]) + return { password: decryptStoreAccountPassword(record.passwordCiphertext) } + } + + async saveFieldPermissions(input: { updates: StoreAccountFieldPermissionUpdate[]; user: AuthUser }) { + if (!canManageSupplyChainPermissions(input.user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以配置字段权限') + const updates = Array.from(new Map(input.updates.map((item) => [`${item.fieldKey}:${item.subjectKey}`, item])).values()) + const invalid = updates.some((item) => ( + !STORE_ACCOUNT_FIELDS.some((field) => field.key === item.fieldKey && !field.system) + || !isStoreAccountFieldPermissionSubjectKey(item.subjectKey) + || (!item.subjectKey.startsWith('user@') && !FIELD_PERMISSION_BUSINESS_SUBJECT_KEYS.has(item.subjectKey as never)) + || (item.visibility !== null && !isStoreAccountFieldVisibility(item.visibility)) + )) + if (invalid) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '字段权限内容不正确') + await this.repository.saveFieldPermissions({ updates, updatedById: input.user.id }) + return { saved: updates.filter((item) => item.visibility !== null).length, inherited: updates.filter((item) => item.visibility === null).length } + } + + async getFieldPermissionSettings(user: AuthUser) { + if (!canManageSupplyChainPermissions(user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以查看字段权限配置') + const [permissions, pagePermissionSettings] = await Promise.all([ + this.repository.listFieldPermissions(), + supplyChainPermissionSettingsService.getSettings(user), + ]) + const pagePermissionOverrides = Object.fromEntries( + Object.entries(pagePermissionSettings.overrides).filter(([key]) => ( + key.startsWith(STORE_ACCOUNT_PAGE_PERMISSION_PREFIX) + )), + ) + return { fields: STORE_ACCOUNT_FIELDS, permissions, pagePermissionOverrides } + } + + private prepareValues(input: Record, access: StoreAccountFieldAccess, required: boolean) { + const values: Record = {} + const fields = STORE_ACCOUNT_EDITABLE_FIELD_KEYS + for (const field of fields) { + if (access[field] !== 'EDITABLE' || input[field] === undefined) continue + const value = clean(input[field]) + if (field === 'password') { + if (value) values.passwordCiphertext = encryptStoreAccountPassword(value) + } + else if (field === 'accountType') values.accountType = normalizeType(value) + else values[field] = value + } + if (required) { + if (!values.storeName || !values.account || !values.accountType) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '店铺名称、账号类型和账号为必填项') + } + return values as Record & { storeName: string; accountType: StoreAccountType; account: string } + } + + private toRow(record: StoreAccountRecord, access: StoreAccountFieldAccess) { + const value = (key: StoreAccountFieldKey, fallback = '') => access[key] === 'HIDDEN' ? '' : fallback + return { + id: record.id, + storeName: value('storeName', record.storeName), + operationOwner: value('operationOwner', record.operationOwner), + operationDepartmentHead: value('operationDepartmentHead', record.operationDepartmentHead), + operationDirector: value('operationDirector', record.operationDirector), + profitCenter: value('profitCenter', record.profitCenter), + accountType: value('accountType', record.accountType) as StoreAccountType, + account: value('account', record.account), + passwordConfigured: access.password !== 'HIDDEN' && Boolean(record.passwordCiphertext), + boundPhone: value('boundPhone', record.boundPhone), + verificationContact: value('verificationContact', record.verificationContact), + jstOrderService: value('jstOrderService', record.jstOrderService), + accountGroupName: value('accountGroupName', record.accountGroupName), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } + } + + private changeSet(previous: Record, values: Record, access: StoreAccountFieldAccess) { + return Object.entries(values).flatMap(([key, next]) => { + const field = key === 'passwordCiphertext' ? 'password' : key + if (access[field as StoreAccountFieldKey] === 'HIDDEN') return [] + const old = key === 'passwordCiphertext' ? previous.passwordCiphertext : previous[key] + if (key !== 'passwordCiphertext' && old === next) return [] + return [{ field, previousValue: key === 'passwordCiphertext' ? (old ? '已设置' : '未设置') : old ?? '', nextValue: key === 'passwordCiphertext' ? (next ? '已设置' : '未设置') : next }] + }) + } + + private async log(record: StoreAccountRecord, operation: string, user: AuthUser, changes: unknown) { + await this.repository.createLog({ storeAccountId: record.id, storeName: record.storeName, operation, actorId: user.id, actorName: user.name, changes }) + } +} + +export const storeAccountManagementService = new StoreAccountManagementService() diff --git a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts index 187fb177..78f27a85 100644 --- a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts @@ -107,13 +107,16 @@ class SupplyChainPermissionSettingsService { if (getSupplyChainRoleDefaultAccess(user)?.view) { return [ 'dashboard', + 'businessManagement', 'productArchive', 'categoryConfig', 'warehouseConfig', + 'storeAccountManagement', 'newProductOverview', 'newProductTerminationFeedback', 'newProductUnifiedTable', 'newProductSampleProgress', + 'purchaseSupplierInformation', 'purchaseDailyAffairs', 'purchasePriceChange', 'operationLogs', @@ -125,13 +128,16 @@ class SupplyChainPermissionSettingsService { const subjectKeys = resolveSupplyChainPermissionSubjectKeys(user) const targetKeys: SupplyChainPermissionTargetKey[] = [ 'dashboard', + 'businessManagement', 'productArchive', 'categoryConfig', 'warehouseConfig', + 'storeAccountManagement', 'newProductOverview', 'newProductTerminationFeedback', 'newProductUnifiedTable', 'newProductSampleProgress', + 'purchaseSupplierInformation', 'purchaseDailyAffairs', 'purchasePriceChange', 'operationLogs', diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts index 3ef95033..93374ec8 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts @@ -199,20 +199,20 @@ export function buildProductArchiveEnrichmentUpdates( { warehouseType: 'remark2' }, ), }, - { + ...(warehouseConfig ? [{ patch: { - warehouseHead: warehouseConfig?.warehouseHead ?? '', - storageType: warehouseConfig?.storageType ?? '', + warehouseHead: warehouseConfig.warehouseHead, + storageType: warehouseConfig.storageType, }, source: source( WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE, CONFIG_SOURCE_SYSTEM, PUBLIC_SOURCE_SCHEMA, 'SupplyChainProductArchiveWarehouseConfig', - warehouseConfig?.id || target.inOutWarehouse || target.barcode, + warehouseConfig.id, { warehouseHead: 'warehouseHead', storageType: 'storageType' }, ), - }, + }] : []), { patch: { purchasePrice: price?.purchasePrice ?? '', diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-image-export.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-image-export.service.ts new file mode 100644 index 00000000..3f1f1fea --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-image-export.service.ts @@ -0,0 +1,126 @@ +import ExcelJS from 'exceljs' +import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive' +import { + JST_SKU_ARCHIVE_SOURCE_CODE, + PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE, + parseProductArchiveFieldSourceMap, +} from '@/lib/supply-chain-product-archive-field-source' +import { + type ProductArchiveImageExportRecord, + supplyChainProductArchiveImageExportRepository, + type SupplyChainProductArchiveImageExportRepository, +} from '@/server/repositories/supply-chain-product-archive-image-export.repository' + +const OBJECT_STORAGE_ROUTE_PATH = '/api/storage/object' +const PRODUCT_DEVELOPMENT_CACHE_VIEW_KEYS = new Set([ + 'product-catalog-wide', + 'product-catalog-wide-sku', + 'product-catalog-wide-product', +]) + +function trim(value: string | null | undefined) { + return value?.trim() ?? '' +} + +export function resolveProductArchiveImageExportUrl( + record: ProductArchiveImageExportRecord, +) { + const sourceImage = trim(record.image) + const cacheImage = record.imageCache?.status === 'RESOLVED' + && record.imageCache.source !== 'product' + && !PRODUCT_DEVELOPMENT_CACHE_VIEW_KEYS.has(record.imageCache.viewKey ?? '') + ? trim(record.imageCache.previewUrl) + : '' + const imageSourceCode = + parseProductArchiveFieldSourceMap(record.fieldSourceMap).image?.sourceCode + || record.firstSourceCode + const jstSkuImage = imageSourceCode === JST_SKU_ARCHIVE_SOURCE_CODE + ? sourceImage + : '' + const productDevelopmentSkuImage = imageSourceCode === PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE + ? sourceImage + : '' + + return cacheImage || jstSkuImage || productDevelopmentSkuImage || '' +} + +export function resolveProductArchiveImageExportUrlForSite( + imageUrl: string, + siteOrigin: string, +) { + const normalized = imageUrl.trim() + if (!normalized) return '' + + try { + const parsed = new URL(normalized, siteOrigin) + if (parsed.pathname === OBJECT_STORAGE_ROUTE_PATH && parsed.searchParams.has('key')) { + return new URL(`${parsed.pathname}${parsed.search}`, siteOrigin).toString() + } + return parsed.toString() + } catch { + return normalized + } +} + +function fileTimestamp(now: Date) { + const pad = (value: number) => String(value).padStart(2, '0') + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '-', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join('') +} + +export class SupplyChainProductArchiveImageExportService { + constructor( + private readonly repository: SupplyChainProductArchiveImageExportRepository = + supplyChainProductArchiveImageExportRepository, + private readonly now: () => Date = () => new Date(), + ) {} + + async createWorkbook(params: { + filters?: ProductArchiveFilters + siteOrigin: string + }) { + const records = await this.repository.listRows(params.filters) + const workbook = new ExcelJS.Workbook() + workbook.creator = '产品全量档案' + workbook.created = this.now() + + const worksheet = workbook.addWorksheet('图片URL') + worksheet.columns = [ + { header: '条码', key: 'barcode', width: 28 }, + { header: '图片URL', key: 'imageUrl', width: 80 }, + ] + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + worksheet.autoFilter = { from: 'A1', to: 'B1' } + worksheet.getRow(1).font = { bold: true } + worksheet.getRow(1).alignment = { vertical: 'middle' } + worksheet.getColumn('barcode').numFmt = '@' + worksheet.getColumn('imageUrl').numFmt = '@' + + for (const record of records) { + worksheet.addRow({ + barcode: record.barcode, + imageUrl: resolveProductArchiveImageExportUrlForSite( + resolveProductArchiveImageExportUrl(record), + params.siteOrigin, + ), + }) + } + + const buffer = await workbook.xlsx.writeBuffer() + return { + bytes: buffer, + fileName: `产品档案图片URL-${fileTimestamp(this.now())}.xlsx`, + total: records.length, + } + } +} + +export const supplyChainProductArchiveImageExportService = + new SupplyChainProductArchiveImageExportService() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts new file mode 100644 index 00000000..6b082e38 --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { + PRODUCT_ARCHIVE_SALES_STATUSES, + type ProductArchiveFilters, +} from '@/lib/supply-chain-product-archive' + +const archiveDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) + +const productArchiveFiltersSchema = z.object({ + keyword: z.string().trim().min(1).max(160).optional(), + categoryL1: z.array(z.string().trim().min(1).max(80)).max(20).optional(), + categoryL2: z.array(z.string().trim().min(1).max(160)).max(200).optional(), + spu: z.array(z.string().trim().min(1).max(120)).max(200).optional(), + sku: z.array(z.string().trim().min(1).max(120)).max(200).optional(), + barcode: z.array(z.string().trim().min(1).max(120)).max(200).optional(), + productName: z.array(z.string().trim().min(1).max(160)).max(200).optional(), + designer: z.array(z.string().trim().min(1).max(80)).max(200).optional(), + productStatus: z.array(z.enum(PRODUCT_ARCHIVE_SALES_STATUSES)).max(3).optional(), + material: z.array(z.string().trim().min(1).max(160)).max(200).optional(), + color: z.array(z.string().trim().min(1).max(80)).max(200).optional(), + brand: z.array(z.string().trim().min(1).max(120)).max(200).optional(), + supplier: z.array(z.string().trim().min(1).max(160)).max(200).optional(), + purchaser: z.array(z.string().trim().min(1).max(120)).max(200).optional(), + inOutWarehouse: z.array(z.string().trim().min(1).max(160)).max(200).optional(), + createTimeFrom: archiveDateSchema.optional(), + createTimeTo: archiveDateSchema.optional(), + updateTimeFrom: archiveDateSchema.optional(), + updateTimeTo: archiveDateSchema.optional(), +}).strict() + +export function parseProductArchiveFilters(raw: string | null) { + if (!raw) return { success: true as const, data: {} as ProductArchiveFilters } + + try { + const parsed = productArchiveFiltersSchema.safeParse(JSON.parse(raw)) + if (!parsed.success) return { success: false as const } + return { success: true as const, data: parsed.data } + } catch { + return { success: false as const } + } +} diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-warehouse-config.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-warehouse-config.service.ts index df1eccc2..8e816f62 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-warehouse-config.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-warehouse-config.service.ts @@ -7,6 +7,10 @@ import { import { WAREHOUSE_CONFIG_ACTIVE, WAREHOUSE_CONFIG_INACTIVE, + inferProductArchiveWarehouseOwnership, + isProductArchiveWarehouseOwnership, + type ProductArchiveWarehouseOwnership, + productArchiveWarehouseOwnershipLabel, type ProductArchiveWarehouseConfigStatus, } from '@/lib/supply-chain-product-archive-warehouse-config' import { @@ -20,6 +24,7 @@ const WAREHOUSE_NAME_MAX_LENGTH = 160 export type WarehouseConfigDTO = { id: string | null warehouseName: string + warehouseOwnership: ProductArchiveWarehouseOwnership | null warehouseHead: string storageType: ProductArchiveStorageType | null status: ProductArchiveWarehouseConfigStatus | null @@ -39,6 +44,10 @@ function mapRecord(record: ProductArchiveWarehouseConfigRecord): WarehouseConfig return { id: record.id, warehouseName: record.warehouseName, + warehouseOwnership: + isProductArchiveWarehouseOwnership(record.warehouseOwnership) + ? record.warehouseOwnership as ProductArchiveWarehouseOwnership + : inferProductArchiveWarehouseOwnership(record.warehouseName), warehouseHead: record.warehouseHead, storageType: record.storageType as ProductArchiveStorageType, status: record.status as ProductArchiveWarehouseConfigStatus, @@ -56,6 +65,7 @@ function mapUnconfigured(warehouseName: string): WarehouseConfigDTO { return { id: null, warehouseName, + warehouseOwnership: inferProductArchiveWarehouseOwnership(warehouseName), warehouseHead: '', storageType: null, status: null, @@ -89,6 +99,13 @@ function cleanStorageType(value: string): ProductArchiveStorageType { return value as ProductArchiveStorageType } +function cleanOwnership(value: string): ProductArchiveWarehouseOwnership { + if (!isProductArchiveWarehouseOwnership(value)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${productArchiveWarehouseOwnershipLabel(value)}仓库归属不正确`) + } + return value as ProductArchiveWarehouseOwnership +} + function cleanStatus(value: string): ProductArchiveWarehouseConfigStatus { if (value !== WAREHOUSE_CONFIG_ACTIVE && value !== WAREHOUSE_CONFIG_INACTIVE) { throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '状态不正确') @@ -152,12 +169,14 @@ export class SupplyChainProductArchiveWarehouseConfigService { async create(input: { warehouseName: string + warehouseOwnership: string warehouseHead: string storageType: string }) { try { return mapRecord(await this.repository.create({ warehouseName: cleanWarehouseName(input.warehouseName), + warehouseOwnership: cleanOwnership(input.warehouseOwnership), warehouseHead: cleanWarehouseHead(input.warehouseHead), storageType: cleanStorageType(input.storageType), })) @@ -172,6 +191,7 @@ export class SupplyChainProductArchiveWarehouseConfigService { async update(input: { id: string warehouseName: string + warehouseOwnership: string warehouseHead: string storageType: string status: string @@ -185,6 +205,7 @@ export class SupplyChainProductArchiveWarehouseConfigService { return mapRecord(await this.repository.update({ id, warehouseName: cleanWarehouseName(input.warehouseName), + warehouseOwnership: cleanOwnership(input.warehouseOwnership), warehouseHead: cleanWarehouseHead(input.warehouseHead), storageType: cleanStorageType(input.storageType), status: cleanStatus(input.status), diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts index ebd72e60..d975e269 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts @@ -72,6 +72,7 @@ export function mapProductArchiveRow({ orderType: record.orderType, inOutWarehouse: record.inOutWarehouse, warehouseType: record.warehouseType, + warehouseOwnership: '', warehouseHead: record.warehouseHead, storageType: storageTypeLabel(record.storageType), isSeasonal: record.isSeasonal, diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts index 0832916a..d17c4de7 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts @@ -16,7 +16,16 @@ import { import { mapProductArchiveRow, normalizeArchiveIdentity, + normalizeWarehouseName, } from './supply-chain-product-archive.mapper' +import { + inferProductArchiveWarehouseOwnership, + productArchiveWarehouseOwnershipLabel, +} from '@/lib/supply-chain-product-archive-warehouse-config' +import { + supplyChainProductArchiveWarehouseConfigRepository, + type ProductArchiveWarehouseConfigRecord, +} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository' function inventoryInteger(value: string) { const normalized = value.trim() @@ -35,6 +44,25 @@ export function calculateJstPublicAvailableStock( ).toString() } +export function resolveProductArchiveWarehouseFields(input: { + warehouseName: string + currentWarehouseHead: string + currentStorageType: string + config?: ProductArchiveWarehouseConfigRecord +}) { + const configured = input.config + return { + warehouseOwnership: productArchiveWarehouseOwnershipLabel( + configured?.warehouseOwnership + ?? inferProductArchiveWarehouseOwnership(input.warehouseName), + ), + warehouseHead: configured?.warehouseHead ?? input.currentWarehouseHead, + storageType: configured?.storageType + ? configured.storageType === 'INBOUND' ? '入库' : '代发' + : input.currentStorageType, + } +} + export class SupplyChainProductArchiveService { constructor( private readonly repository: SupplyChainProductArchiveRepository = @@ -43,6 +71,10 @@ export class SupplyChainProductArchiveService { JstInventoryPublicAvailableRepository, 'listByBarcodes' > = jstInventoryPublicAvailableRepository, + private readonly warehouseConfigRepository: Pick< + typeof supplyChainProductArchiveWarehouseConfigRepository, + 'listActive' + > | null = null, ) {} async listPage(params: { @@ -57,6 +89,17 @@ export class SupplyChainProductArchiveService { take: pageSize, filters: params.filters, }) + let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = [] + if (this.warehouseConfigRepository) { + try { + warehouseConfigs = await this.warehouseConfigRepository.listActive() + } catch (error) { + console.warn('[product-archive] warehouse config lookup failed', error) + } + } + const warehouseConfigByName = new Map( + warehouseConfigs.map((config) => [normalizeWarehouseName(config.warehouseName), config]), + ) let inventoryProfiles: JstInventoryPublicAvailableProfile[] = [] try { inventoryProfiles = await this.inventoryRepository.listByBarcodes( @@ -73,11 +116,22 @@ export class SupplyChainProductArchiveService { ) const now = new Date() return { - rows: records.map((record) => ({ - ...mapProductArchiveRow({ record, now }), - stockJianshang: - inventoryByBarcode.get(normalizeArchiveIdentity(record.barcode)) ?? '', - })), + rows: records.map((record) => { + const row = mapProductArchiveRow({ record, now }) + const config = warehouseConfigByName.get(normalizeWarehouseName(record.inOutWarehouse)) + const warehouseFields = resolveProductArchiveWarehouseFields({ + warehouseName: record.inOutWarehouse, + currentWarehouseHead: record.warehouseHead, + currentStorageType: row.storageType, + config, + }) + return { + ...row, + ...warehouseFields, + stockJianshang: + inventoryByBarcode.get(normalizeArchiveIdentity(record.barcode)) ?? '', + } + }), pageInfo: { page, pageSize, @@ -106,4 +160,8 @@ export class SupplyChainProductArchiveService { } export const supplyChainProductArchiveService = - new SupplyChainProductArchiveService() + new SupplyChainProductArchiveService( + undefined, + undefined, + supplyChainProductArchiveWarehouseConfigRepository, + ) diff --git a/furniture-3d-system/src/server/services/supply-chain-supplier-information.service.ts b/furniture-3d-system/src/server/services/supply-chain-supplier-information.service.ts new file mode 100644 index 00000000..57c33934 --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-supplier-information.service.ts @@ -0,0 +1,136 @@ +import { + EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS, + SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS, + type SupplyChainSupplierInformationFilters, + type SupplyChainSupplierInformationPage, + type SupplyChainSupplierInformationUpdates, +} from '@/lib/supply-chain-supplier-information' +import { + PRODUCT_ARCHIVE_CHANGE_FIELDS, + type ProductArchiveFieldChange, +} from '@/lib/supply-chain-product-archive-change-log' +import type { AuthUser } from '@/server/core/types' +import { AppError, ErrorCode } from '@/server/core/errors' +import { + supplyChainSupplierInformationRepository, + type SupplyChainSupplierInformationRepository, +} from '@/server/repositories/supply-chain-supplier-information.repository' +import { + archiveChangeActorFromUser, + recordProductArchiveFieldChanges, +} from '@/server/services/supply-chain-product-archive-change-log.service' + +const EDITABLE_FIELD_LABELS = Object.fromEntries( + PRODUCT_ARCHIVE_CHANGE_FIELDS.map((field) => [field.key, field.label]), +) as Record + +function normalizeValues(values: readonly string[]) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-CN', { numeric: true })) +} + +export class SupplyChainSupplierInformationService { + constructor( + private readonly repository: SupplyChainSupplierInformationRepository = + supplyChainSupplierInformationRepository, + private readonly recordChanges: typeof recordProductArchiveFieldChanges = + recordProductArchiveFieldChanges, + ) {} + + async listPage(params: { + page: number + pageSize: number + filters?: Partial + }) { + const page = Math.max(1, Math.trunc(params.page) || 1) + const pageSize = Math.min(200, Math.max(1, Math.trunc(params.pageSize) || 50)) + const filters = Object.fromEntries( + Object.keys(EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS).map((key) => [ + key, + params.filters?.[key as keyof SupplyChainSupplierInformationFilters] + ?.trim() + .slice(0, 120) ?? '', + ]), + ) as SupplyChainSupplierInformationFilters + const { records, total } = await this.repository.listPage({ + skip: (page - 1) * pageSize, + take: pageSize, + filters, + }) + + return { + rows: records.map((record) => ({ + ...record, + purchaserValues: normalizeValues(record.purchaserValues), + minimumOrderQuantityValues: normalizeValues(record.minimumOrderQuantityValues), + firstLeadTimeValues: normalizeValues(record.firstLeadTimeValues), + stableLeadTimeValues: normalizeValues(record.stableLeadTimeValues), + palletSpecValues: normalizeValues(record.palletSpecValues), + backupSupplierValues: normalizeValues(record.backupSupplierValues), + supplierTypeValues: normalizeValues(record.supplierTypeValues), + })), + pageInfo: { + page, + pageSize, + total, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + } satisfies SupplyChainSupplierInformationPage + } + + async updateSupplier(input: { + supplierKey: string + updates: SupplyChainSupplierInformationUpdates + user: AuthUser + }) { + const supplierKey = normalizeSupplierKey(input.supplierKey) + const updates = Object.fromEntries( + SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS.flatMap((field) => { + const value = input.updates[field]?.trim() + return value ? [[field, value]] : [] + }), + ) as SupplyChainSupplierInformationUpdates + if (!supplierKey || Object.keys(updates).length === 0) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少填写一项要修改的内容') + } + + const records = await this.repository.updateSupplierFields({ supplierKey, updates }) + if (records.length === 0) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商关联的产品档案不存在') + } + + let changedValues = 0 + for (const record of records) { + const changes = Object.entries(updates).flatMap(([field, nextValue]) => { + const previousValue = record[field as keyof SupplyChainSupplierInformationUpdates] + if (previousValue === nextValue) return [] + return [{ + field: field as keyof SupplyChainSupplierInformationUpdates, + fieldLabel: EDITABLE_FIELD_LABELS[field as keyof SupplyChainSupplierInformationUpdates], + previousValue, + nextValue, + } satisfies ProductArchiveFieldChange] + }) + if (changes.length === 0) continue + changedValues += changes.length + await this.recordChanges({ + barcode: record.barcode, + actor: archiveChangeActorFromUser(input.user), + changes, + }) + } + + return { + affectedBarcodes: records.length, + changedValues, + supplierKey: normalizeSupplierKey(updates.supplierName ?? records[0].supplierName), + } + } +} + +export function normalizeSupplierKey(value: string) { + return value.trim().replace(/\s+/g, '').toLocaleLowerCase('zh-CN') +} + +export const supplyChainSupplierInformationService = + new SupplyChainSupplierInformationService() From 629ad421048072252bd76739fe9c715d96e4224f Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 26 Aug 2026 18:19:24 +0800 Subject: [PATCH 04/13] feat(installation): auto-explode unnamed assemblies --- .../InstallationDemoWorkbenchClient.tsx | 43 +- .../components/ExplosionEditorPanel.tsx | 2 +- ...eInstallationModelUploadController.test.ts | 79 +++ .../useInstallationModelUploadController.ts | 62 +-- .../hooks/useInstallationPartCatalog.ts | 11 +- .../installationAssemblyContactGraph.test.ts | 55 +++ .../lib/installationAssemblyContactGraph.ts | 185 +++++++ .../lib/installationAssemblyExplodeLayout.ts | 231 +++------ .../installationAutoExplodeTemplateStorage.ts | 8 + ...ionConnectedPanelSeparationPlanner.test.ts | 60 +++ ...allationConnectedPanelSeparationPlanner.ts | 102 ++++ .../installationDrawerAssemblyPlanner.test.ts | 167 +++++++ .../lib/installationDrawerAssemblyPlanner.ts | 286 +++++++++++ .../lib/installationHardwareExplodePlanner.ts | 251 ++++++++++ ...ionLocalHorizontalPartitionPlanner.test.ts | 58 +++ ...allationLocalHorizontalPartitionPlanner.ts | 192 ++++++++ ...tallationLocalVerticalLayerPlanner.test.ts | 68 +++ .../installationLocalVerticalLayerPlanner.ts | 162 +++++++ .../lib/installationModelGeometry.test.ts | 32 ++ .../lib/installationModelGeometry.ts | 21 + ...nstallationOrientationLayerPlanner.test.ts | 144 ++++++ .../installationOrientationLayerPlanner.ts | 402 +++++++++++++++ .../installationPartCatalogHelpers.test.ts | 102 ++++ .../lib/installationPartCatalogHelpers.ts | 50 ++ .../installationUnnamedCabinetShelfPlanner.ts | 92 ++++ ...allationUnnamedCabinetVisibilityPlanner.ts | 186 +++++++ ...tionUnnamedProductStructurePlanner.test.ts | 94 ++++ ...tallationUnnamedProductStructurePlanner.ts | 18 + ...tionUnnamedStructureExplodePlanner.test.ts | 429 ++++++++++++++++ ...tallationUnnamedStructureExplodePlanner.ts | 456 ++++++++++++++++++ 30 files changed, 3852 insertions(+), 196 deletions(-) create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationModelUploadController.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationAssemblyContactGraph.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationAssemblyContactGraph.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationConnectedPanelSeparationPlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationConnectedPanelSeparationPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawerAssemblyPlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawerAssemblyPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationHardwareExplodePlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationLocalHorizontalPartitionPlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationLocalHorizontalPartitionPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationLocalVerticalLayerPlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationLocalVerticalLayerPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationModelGeometry.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationOrientationLayerPlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationOrientationLayerPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationPartCatalogHelpers.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationUnnamedCabinetShelfPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationUnnamedCabinetVisibilityPlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationUnnamedProductStructurePlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationUnnamedProductStructurePlanner.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationUnnamedStructureExplodePlanner.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationUnnamedStructureExplodePlanner.ts diff --git a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx index 59913664..e8884121 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx @@ -23,7 +23,10 @@ import { buildInstallationCameraViewKey, captureInstallationCameraView, } from "./lib/installationCameraView"; -import { collectInstallationModelNodeSnapshots } from "./lib/installationModelGeometry"; +import { + collectInstallationModelNodeSnapshots, + resetInstallationModelGeometry, +} from "./lib/installationModelGeometry"; import { normalizeInstallationStepDrafts } from "./lib/installationStepDraftBuilder"; import { buildInstallationStepMaterialShareSnapshotsByStepId } from "./lib/installationStepMaterialShareSnapshots"; import { @@ -1141,6 +1144,29 @@ export default function InstallationDemoWorkbenchClient({ [], ); + const resetExplosionGeometry = useCallback(() => { + partCatalog.resetAllPartTransforms(); + + const engine = previewEngineRef.current ?? previewEngine; + if (!engine) { + setModelNodeSnapshots([]); + return; + } + + const nodeNames = resetInstallationModelGeometry(engine); + syncModelGeometry(engine, nodeNames); + }, [partCatalog, previewEngine, syncModelGeometry]); + + const prepareModelReplacement = useCallback(async () => { + resetExplosionGeometry(); + previewEngineRef.current = null; + setPreviewEngine(null); + setModelNodeNames([]); + setModelNodeSnapshots([]); + + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }, [resetExplosionGeometry]); + const selectNumberingPart = useCallback( (partId: string) => { numberingCatalog.setSelectedPartId(partId); @@ -1665,9 +1691,13 @@ export default function InstallationDemoWorkbenchClient({ const handleUploadModel = useCallback( (file: File) => { if (!catalog.access.canEdit || !selectedDrawing) return; - void upload.uploadModelForDrawing(selectedDrawing.id, file); + void upload.uploadModelForDrawing( + selectedDrawing.id, + file, + prepareModelReplacement, + ); }, - [catalog.access.canEdit, selectedDrawing, upload], + [catalog.access.canEdit, prepareModelReplacement, selectedDrawing, upload], ); const prepareNumberingSnapshotBatchForShare = useCallback(async ( @@ -2567,11 +2597,6 @@ export default function InstallationDemoWorkbenchClient({ numberingCatalog.setSelectedPartId(null); }} onModelNodesChange={(nextNodeNames) => { - const previewEngine = previewEngineRef.current; - if (previewEngine) { - syncModelGeometry(previewEngine, nextNodeNames); - return; - } setModelNodeNames(nextNodeNames); setModelNodeSnapshots([]); }} @@ -2972,7 +2997,7 @@ export default function InstallationDemoWorkbenchClient({ explosionSelectedNodeNames, ); }} - onResetAllParts={partCatalog.resetAllPartTransforms} + onResetAllParts={resetExplosionGeometry} onSelectNumberingPart={selectNumberingPart} onNumberingCategoryChange={(nextCategory) => { setNumberingCategory(nextCategory); diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/ExplosionEditorPanel.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/ExplosionEditorPanel.tsx index 3bd2b17c..e745354e 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/ExplosionEditorPanel.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/ExplosionEditorPanel.tsx @@ -61,7 +61,7 @@ export function ExplosionEditorPanel({
} + +
{state.fileName}
+
+ {state.total > 0 ? `${state.processed} / ${state.total} 条` : '正在读取文件...'} + {state.total > 0 && {percent}%} +
+
0 ? state.processed : undefined} + > + 0 ? `${percent}%` : '36%' }} /> +
+
新增 {state.created}更新 {state.updated}
+ {state.message &&

{state.message}

} + + + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx index d5309582..822e34fc 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx @@ -5,8 +5,10 @@ import { ArrowLeft, ArrowRight, ChevronDown, Eye, EyeOff, FileDown, History, Key import type { StoreAccountBatchEditableFieldKey, StoreAccountChangeLogEntry, StoreAccountFieldAccess, StoreAccountFieldKey, StoreAccountPage, StoreAccountRow, StoreAccountFilters } from '@/lib/store-account-management' import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FIELDS } from '@/lib/store-account-management' import { StoreAccountBatchUpdateDialog } from './StoreAccountBatchUpdateDialog' +import { StoreAccountImportProgress } from './StoreAccountImportProgress' import { StoreAccountLogDialog } from './StoreAccountLogDialog' import { StoreAccountPermissionDialog } from './StoreAccountPermissionDialog' +import { useStoreAccountImport } from './useStoreAccountImport' type Props = { initialData: StoreAccountPage; initialFilters: StoreAccountFilters; canEdit: boolean; canManagePermissions: boolean } type Draft = Partial> & { id?: string } @@ -93,6 +95,10 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi void load(EMPTY_STORE_ACCOUNT_FILTERS, 1, data.pageInfo.pageSize) } + const storeAccountImport = useStoreAccountImport({ + onCompleted: () => load(filters, 1, data.pageInfo.pageSize), + }) + const save = async () => { if (!draft) return const allowedKeys = new Set(STORE_ACCOUNT_FIELDS.filter((field) => !field.system && data.fieldAccess[field.key] === 'EDITABLE').map((field) => field.key)) @@ -103,13 +109,6 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi await load(filters, data.pageInfo.page, data.pageInfo.pageSize) } - const importFile = async (file: File) => { - const form = new FormData(); form.append('file', file) - const result = await readResponse(await fetch('/api/supply-chain/store-account-management/import', { method: 'POST', body: form })) - window.alert(`导入完成:新增 ${result.created} 条,更新 ${result.updated} 条`) - await load(filters, 1, data.pageInfo.pageSize) - } - const downloadTemplate = async () => { const XLSX = await import('xlsx') const headers = STORE_ACCOUNT_FIELDS.filter((field) => !field.system).map((field) => field.label) @@ -144,7 +143,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi return

店铺账号管理

人工维护店铺账号、负责人和聚水潭服务信息

-
共 {data.pageInfo.total} 条{canManagePermissions && }{canEdit && <> { const file = e.target.files?.[0]; if (file) void importFile(file); e.currentTarget.value = '' }} />}
+
共 {data.pageInfo.total} 条{canManagePermissions && }{canEdit && <> { const file = e.target.files?.[0]; if (file) void storeAccountImport.importFile(file); e.currentTarget.value = '' }} />}
{ e.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}> setFilters({ ...filters, search: e.target.value })} placeholder="批量搜索,多个关键词用逗号分隔" /> @@ -174,6 +173,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi {logs && setLogs(null)} />} {permissionsOpen && setPermissionsOpen(false)} onSaved={() => load(filters, data.pageInfo.page, data.pageInfo.pageSize)} />} {batchOpen && setBatchOpen(false)} onSave={batchUpdate} />} +
} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountImportStream.test.ts b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountImportStream.test.ts new file mode 100644 index 00000000..8ecf42c3 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountImportStream.test.ts @@ -0,0 +1,42 @@ +import { readStoreAccountImportStream } from './storeAccountImportStream' + +function streamResponse(chunks: string[]) { + const encoder = new TextEncoder() + let index = 0 + return { + ok: true, + body: { + getReader() { + return { + async read() { + if (index >= chunks.length) return { done: true, value: undefined } + return { done: false, value: encoder.encode(chunks[index++]) } + }, + } + }, + }, + } as unknown as Response +} + +describe('readStoreAccountImportStream', () => { + it('parses progress split across arbitrary response chunks', async () => { + const events: unknown[] = [] + const result = await readStoreAccountImportStream(streamResponse([ + '{"type":"progress","progress":{"processed":0,"total":2,', + '"created":0,"updated":0}}\n{"type":"progress","progress":{"processed":1,"total":2,"created":1,"updated":0}}\n', + '{"type":"complete","progress":{"processed":2,"total":2,"created":1,"updated":1}}\n', + ]), (event) => events.push(event)) + + expect(result).toEqual({ processed: 2, total: 2, created: 1, updated: 1 }) + expect(events).toHaveLength(3) + }) + + it('keeps the last progress and surfaces a streamed business error', async () => { + const events: unknown[] = [] + await expect(readStoreAccountImportStream(streamResponse([ + '{"type":"progress","progress":{"processed":3,"total":8,"created":2,"updated":1}}\n', + '{"type":"error","progress":{"processed":3,"total":8,"created":2,"updated":1},"message":"第4行格式不正确"}\n', + ]), (event) => events.push(event))).rejects.toThrow('第4行格式不正确') + expect(events).toHaveLength(2) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountImportStream.ts b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountImportStream.ts new file mode 100644 index 00000000..5fc833bd --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/storeAccountImportStream.ts @@ -0,0 +1,63 @@ +import type { + StoreAccountImportProgress, + StoreAccountImportStreamEvent, +} from '@/lib/store-account-management' + +function isProgress(value: unknown): value is StoreAccountImportProgress { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const progress = value as Record + return ['processed', 'total', 'created', 'updated'].every((key) => ( + typeof progress[key] === 'number' && Number.isFinite(progress[key]) && progress[key] >= 0 + )) +} + +function parseEvent(line: string): StoreAccountImportStreamEvent { + const value = JSON.parse(line) as Record + if (!isProgress(value.progress)) throw new Error('导入进度响应格式不正确') + if (value.type === 'progress' || value.type === 'complete') { + return { type: value.type, progress: value.progress } + } + if (value.type === 'error' && typeof value.message === 'string') { + return { type: 'error', progress: value.progress, message: value.message } + } + throw new Error('导入进度响应格式不正确') +} + +async function responseError(response: Response) { + const payload = await response.json().catch(() => null) + return payload?.error?.message || '导入请求失败' +} + +export async function readStoreAccountImportStream( + response: Response, + onEvent: (event: StoreAccountImportStreamEvent) => void, +) { + if (!response.ok) throw new Error(await responseError(response)) + if (!response.body) throw new Error('浏览器无法读取导入进度') + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let completed: StoreAccountImportProgress | null = null + + const consumeLine = (line: string) => { + if (!line.trim()) return + const event = parseEvent(line) + onEvent(event) + if (event.type === 'complete') completed = event.progress + if (event.type === 'error') throw new Error(event.message) + } + + while (true) { + const { done, value } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + lines.forEach(consumeLine) + if (done) break + } + consumeLine(buffer) + + if (!completed) throw new Error('导入连接提前结束,请查看已导入的数据') + return completed +} diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/useStoreAccountImport.ts b/furniture-3d-system/src/app/supply-chain/database/store-account-management/useStoreAccountImport.ts new file mode 100644 index 00000000..43abe063 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/useStoreAccountImport.ts @@ -0,0 +1,65 @@ +import { useCallback, useState } from 'react' +import type { StoreAccountImportProgress, StoreAccountImportStreamEvent } from '@/lib/store-account-management' +import { readStoreAccountImportStream } from './storeAccountImportStream' + +export type StoreAccountImportState = StoreAccountImportProgress & { + fileName: string + status: 'preparing' | 'running' | 'complete' | 'error' + message?: string +} + +type Options = { + onCompleted: () => Promise +} + +const EMPTY_PROGRESS: StoreAccountImportProgress = { + processed: 0, + total: 0, + created: 0, + updated: 0, +} + +export function useStoreAccountImport({ onCompleted }: Options) { + const [state, setState] = useState(null) + const isImporting = state?.status === 'preparing' || state?.status === 'running' + + const importFile = useCallback(async (file: File) => { + if (isImporting) return + setState({ ...EMPTY_PROGRESS, fileName: file.name, status: 'preparing' }) + const form = new FormData() + form.append('file', file) + + try { + const response = await fetch('/api/supply-chain/store-account-management/import', { + method: 'POST', + body: form, + }) + await readStoreAccountImportStream(response, (event: StoreAccountImportStreamEvent) => { + setState({ + ...event.progress, + fileName: file.name, + status: event.type === 'progress' ? 'running' : event.type, + ...(event.type === 'error' ? { message: event.message } : {}), + }) + }) + await onCompleted() + } catch (error) { + const message = error instanceof Error ? error.message : '导入失败' + setState((current) => ({ + ...EMPTY_PROGRESS, + ...current, + fileName: file.name, + status: 'error', + message, + })) + } + }, [isImporting, onCompleted]) + + const dismiss = useCallback(() => { + setState((current) => ( + current?.status === 'preparing' || current?.status === 'running' ? current : null + )) + }, []) + + return { state, isImporting, importFile, dismiss } +} diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts index 0842b591..2b6958b1 100644 --- a/furniture-3d-system/src/lib/store-account-management.ts +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -142,6 +142,18 @@ export type StoreAccountPage = { fieldAccess: StoreAccountFieldAccess } +export type StoreAccountImportProgress = { + processed: number + total: number + created: number + updated: number +} + +export type StoreAccountImportStreamEvent = + | { type: 'progress'; progress: StoreAccountImportProgress } + | { type: 'complete'; progress: StoreAccountImportProgress } + | { type: 'error'; progress: StoreAccountImportProgress; message: string } + export type StoreAccountBatchPreviewRow = { id: string storeName: string | null diff --git a/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts b/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts index 066c8636..10f4ed7f 100644 --- a/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/store-account-management.service.test.ts @@ -1,6 +1,7 @@ import { StoreAccountManagementService } from '../store-account-management.service' import type { StoreAccountManagementRepository } from '@/server/repositories/store-account-management.repository' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { STORE_ACCOUNT_FIELDS, type StoreAccountFieldAccess } from '@/lib/store-account-management' describe('StoreAccountManagementService field permission settings', () => { afterEach(() => { @@ -117,3 +118,83 @@ describe('StoreAccountManagementService field permission settings', () => { expect(saveFieldPermissions).not.toHaveBeenCalled() }) }) + +describe('StoreAccountManagementService import', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('updates matching keys, preserves a blank password and reports real row progress', async () => { + const existing = { + id: 'account-1', + storeName: '测试店铺', + operationOwner: '旧负责人', + operationDepartmentHead: '', + operationDirector: '', + profitCenter: '', + accountType: 'MAIN', + account: 'main-account', + passwordCiphertext: 'existing-ciphertext', + boundPhone: '', + verificationContact: '', + jstOrderService: '', + accountGroupName: '', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + } + const created = { ...existing, id: 'account-2', storeName: '新增店铺', accountType: 'SUB', account: 'sub-account' } + const findByKey = jest.fn().mockResolvedValueOnce(existing).mockResolvedValueOnce(null) + const upsertByKey = jest.fn() + .mockResolvedValueOnce({ ...existing, operationOwner: '新负责人' }) + .mockResolvedValueOnce(created) + const createLog = jest.fn().mockResolvedValue(undefined) + const service = new StoreAccountManagementService({ + findByKey, + upsertByKey, + createLog, + } as unknown as StoreAccountManagementRepository) + const access = Object.fromEntries(STORE_ACCOUNT_FIELDS.map((field) => [ + field.key, + field.system ? 'READONLY' : 'EDITABLE', + ])) as StoreAccountFieldAccess + jest.spyOn(service, 'getFieldAccess').mockResolvedValue({ + target: { view: true, edit: true, download: true }, + access, + }) + const onProgress = jest.fn() + + const result = await service.importRows([ + { + storeName: '测试店铺', + accountType: '主账号', + account: 'main-account', + operationOwner: '新负责人', + password: '', + }, + { + storeName: '新增店铺', + accountType: '子账号', + account: 'sub-account', + operationOwner: '新增负责人', + }, + ], { + id: 'admin-1', + email: 'admin@example.com', + name: '管理员', + role: 'ADMIN', + status: 'ACTIVE', + }, onProgress) + + expect(result).toEqual({ created: 1, updated: 1 }) + expect(upsertByKey).toHaveBeenNthCalledWith(1, expect.objectContaining({ + storeName: '测试店铺', + accountType: 'MAIN', + account: 'main-account', + values: expect.objectContaining({ operationOwner: '新负责人' }), + })) + expect(upsertByKey.mock.calls[0][0].values).not.toHaveProperty('passwordCiphertext') + expect(onProgress).toHaveBeenNthCalledWith(1, { processed: 1, total: 2, created: 0, updated: 1 }) + expect(onProgress).toHaveBeenNthCalledWith(2, { processed: 2, total: 2, created: 1, updated: 1 }) + expect(createLog).toHaveBeenCalledTimes(2) + }) +}) diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts index 6b5a06ab..c23ecb12 100644 --- a/furniture-3d-system/src/server/services/store-account-management.service.ts +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -15,6 +15,7 @@ import { type StoreAccountFieldKey, type StoreAccountFieldPermissionUpdate, type StoreAccountFilters, + type StoreAccountImportProgress, type StoreAccountPage, type StoreAccountType, } from '@/lib/store-account-management' @@ -141,7 +142,11 @@ export class StoreAccountManagementService { return this.toRow(next, access) } - async importRows(rows: Array>, user: AuthUser) { + async importRows( + rows: Array>, + user: AuthUser, + onProgress?: (progress: StoreAccountImportProgress) => void, + ) { const { target, access } = await this.getFieldAccess(user) if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限') if (rows.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据') @@ -155,6 +160,7 @@ export class StoreAccountManagementService { if (previous) updated += 1 else created += 1 await this.log(record, previous ? 'IMPORT_UPDATE' : 'IMPORT_CREATE', user, this.changeSet(previous ?? {}, values, access)) + onProgress?.({ processed: created + updated, total: rows.length, created, updated }) } return { created, updated } } From 023c4c07d2e015314a9fce4965eaa5cc738a88f9 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 27 Aug 2026 13:52:56 +0800 Subject: [PATCH 06/13] feat(sample-progress): enhance detail collaboration --- .../migration.sql | 71 +++++++++ furniture-3d-system/prisma/schema.prisma | 25 +++ .../sample-progress/[id]/history/route.ts | 3 +- .../api/intake/sample-progress/[id]/route.ts | 9 +- .../app/api/intake/sample-progress/route.ts | 7 +- .../sample-progress/SampleProgressClient.tsx | 29 ++-- .../SampleProgressColumnSettings.tsx | 92 +++++++++++ .../SampleProgressEditorDrawer.test.tsx | 23 ++- .../components/SampleProgressEditorDrawer.tsx | 141 ++++++++++------- .../SampleProgressSupplierEditor.tsx | 73 +++++++++ .../components/SampleProgressTableRow.tsx | 75 +++++---- .../sample-progress/sampleProgressColumns.ts | 78 ++++++++++ .../sampleProgressSuppliers.test.ts | 26 ++++ .../sampleProgressSuppliers.ts | 29 ++++ .../useSampleProgressColumnVisibility.test.ts | 54 +++++++ .../useSampleProgressColumnVisibility.ts | 94 ++++++++++++ .../src/app/supply-chain/layout.tsx | 6 +- .../new-products/sample-progress/page.tsx | 5 +- .../src/app/supply-chain/page.tsx | 6 +- .../lib/sample-progress-permissions.test.ts | 28 ++++ .../src/lib/sample-progress-permissions.ts | 26 ++++ .../src/lib/sample-progress.test.ts | 25 +++ .../src/lib/sample-progress.ts | 48 ++++++ ...sample-progress-supplier-migration.test.ts | 31 ++++ .../sample-progress.repository.ts | 74 ++++++++- .../services/sample-progress-change-log.ts | 42 +++++ .../services/sample-progress-serialization.ts | 65 +++++++- .../sample-progress-supplier-state.test.ts | 52 +++++++ .../sample-progress-supplier-state.ts | 144 ++++++++++++++++++ .../services/sample-progress.service.test.ts | 78 ++++++++++ .../services/sample-progress.service.ts | 140 ++++++++++++++--- 31 files changed, 1454 insertions(+), 145 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260827143000_add_sample_progress_suppliers/migration.sql create mode 100644 furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressColumnSettings.tsx create mode 100644 furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressSupplierEditor.tsx create mode 100644 furniture-3d-system/src/app/intake/sample-progress/sampleProgressColumns.ts create mode 100644 furniture-3d-system/src/app/intake/sample-progress/sampleProgressSuppliers.test.ts create mode 100644 furniture-3d-system/src/app/intake/sample-progress/sampleProgressSuppliers.ts create mode 100644 furniture-3d-system/src/app/intake/sample-progress/useSampleProgressColumnVisibility.test.ts create mode 100644 furniture-3d-system/src/app/intake/sample-progress/useSampleProgressColumnVisibility.ts create mode 100644 furniture-3d-system/src/lib/sample-progress-permissions.test.ts create mode 100644 furniture-3d-system/src/lib/sample-progress-permissions.ts create mode 100644 furniture-3d-system/src/server/repositories/__tests__/sample-progress-supplier-migration.test.ts create mode 100644 furniture-3d-system/src/server/services/sample-progress-supplier-state.test.ts create mode 100644 furniture-3d-system/src/server/services/sample-progress-supplier-state.ts diff --git a/furniture-3d-system/prisma/migrations/20260827143000_add_sample_progress_suppliers/migration.sql b/furniture-3d-system/prisma/migrations/20260827143000_add_sample_progress_suppliers/migration.sql new file mode 100644 index 00000000..defc06df --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260827143000_add_sample_progress_suppliers/migration.sql @@ -0,0 +1,71 @@ +CREATE TABLE "SampleProgressSupplier" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "supplier" TEXT NOT NULL, + "supplierConfirmedAt" TIMESTAMP(3), + "sampleNotifiedAt" TIMESTAMP(3), + "sampleDueDate" DATE NOT NULL, + "courierTrackingNumber" TEXT, + "status" TEXT NOT NULL, + "requestedCost" DECIMAL(12,2), + "actualCost" DECIMAL(12,2), + "progressDescription" TEXT, + "actualSampleCompletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SampleProgressSupplier_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SampleProgressSupplier_itemId_sortOrder_key" +ON "SampleProgressSupplier"("itemId", "sortOrder"); + +CREATE INDEX "SampleProgressSupplier_itemId_idx" +ON "SampleProgressSupplier"("itemId"); + +CREATE INDEX "SampleProgressSupplier_status_idx" +ON "SampleProgressSupplier"("status"); + +CREATE INDEX "SampleProgressSupplier_sampleDueDate_idx" +ON "SampleProgressSupplier"("sampleDueDate"); + +ALTER TABLE "SampleProgressSupplier" +ADD CONSTRAINT "SampleProgressSupplier_itemId_fkey" +FOREIGN KEY ("itemId") REFERENCES "SampleProgressItem"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "SampleProgressSupplier" ( + "id", + "itemId", + "sortOrder", + "supplier", + "supplierConfirmedAt", + "sampleNotifiedAt", + "sampleDueDate", + "courierTrackingNumber", + "status", + "requestedCost", + "actualCost", + "progressDescription", + "actualSampleCompletedAt", + "createdAt", + "updatedAt" +) +SELECT + 'legacy_' || "id", + "id", + 0, + "supplier", + "supplierConfirmedAt", + "sampleNotifiedAt", + "sampleDueDate", + "courierTrackingNumber", + "status", + "requestedCost", + "actualCost", + "progressDescription", + "actualSampleCompletedAt", + "createdAt", + "updatedAt" +FROM "SampleProgressItem"; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 34378c06..369c6385 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -45,6 +45,7 @@ model SampleProgressItem { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt changeLogs SampleProgressChangeLog[] + suppliers SampleProgressSupplier[] @@index([status]) @@index([brand]) @@ -54,6 +55,30 @@ model SampleProgressItem { @@index([designer]) } +model SampleProgressSupplier { + id String @id @default(cuid()) + itemId String + item SampleProgressItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + sortOrder Int @default(0) + supplier String + supplierConfirmedAt DateTime? + sampleNotifiedAt DateTime? + sampleDueDate DateTime @db.Date + courierTrackingNumber String? + status String + requestedCost Decimal? @db.Decimal(12, 2) + actualCost Decimal? @db.Decimal(12, 2) + progressDescription String? + actualSampleCompletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([itemId, sortOrder]) + @@index([itemId]) + @@index([status]) + @@index([sampleDueDate]) +} + model SampleProgressChangeLog { id String @id @default(cuid()) itemId String diff --git a/furniture-3d-system/src/app/api/intake/sample-progress/[id]/history/route.ts b/furniture-3d-system/src/app/api/intake/sample-progress/[id]/history/route.ts index f64d1933..4d50bbfb 100644 --- a/furniture-3d-system/src/app/api/intake/sample-progress/[id]/history/route.ts +++ b/furniture-3d-system/src/app/api/intake/sample-progress/[id]/history/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from 'next/server' +import { hasSampleProgressBusinessEditorAccess } from '@/lib/sample-progress-permissions' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { sampleProgressService } from '@/server/services/sample-progress.service' @@ -9,7 +10,7 @@ export async function GET(request: NextRequest, context: Context) { return handleRequest(request, async (ctx) => { if (request.nextUrl.searchParams.get('permissionScope') === 'supply-chain') { const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'newProductSampleProgress') - if (!access.view) return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有打样进度明细权限', 403, ctx.requestId) + if (!access.view && !hasSampleProgressBusinessEditorAccess(ctx.user)) return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有打样进度明细权限', 403, ctx.requestId) } return jsonSuccess(await sampleProgressService.listChangeLogs((await context.params).id), 200, ctx.requestId) }, { requireAuth: true }) diff --git a/furniture-3d-system/src/app/api/intake/sample-progress/[id]/route.ts b/furniture-3d-system/src/app/api/intake/sample-progress/[id]/route.ts index c788f661..f5bfbb73 100644 --- a/furniture-3d-system/src/app/api/intake/sample-progress/[id]/route.ts +++ b/furniture-3d-system/src/app/api/intake/sample-progress/[id]/route.ts @@ -1,25 +1,26 @@ import { NextRequest } from 'next/server' import { sampleProgressUpdateSchema } from '@/lib/sample-progress' +import { hasSampleProgressBusinessEditorAccess } from '@/lib/sample-progress-permissions' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { sampleProgressService } from '@/server/services/sample-progress.service' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' -async function requireSupplyChainPermission(request: NextRequest, user: Parameters[0], action: 'view' | 'edit', requestId: string) { +async function requireSupplyChainPermission(request: NextRequest, user: Parameters[0], action: 'view' | 'edit', requestId: string, allowBusinessEditor = false) { if (request.nextUrl.searchParams.get('permissionScope') !== 'supply-chain') return null const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'newProductSampleProgress') - if (access[action]) return null + if (access[action] || (allowBusinessEditor && hasSampleProgressBusinessEditorAccess(user))) return null return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有打样进度明细权限', 403, requestId) } type Context = { params: Promise<{ id: string }> } export async function PUT(request: NextRequest, context: Context) { return handleRequest(request, async (ctx) => { - const denied = await requireSupplyChainPermission(request, ctx.user, 'edit', ctx.requestId) + const denied = await requireSupplyChainPermission(request, ctx.user, 'edit', ctx.requestId, true) if (denied) return denied const parsed = sampleProgressUpdateSchema.safeParse(await request.json().catch(() => null)) if (!parsed.success) return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '数据不完整', 400, ctx.requestId) - return jsonSuccess(await sampleProgressService.update((await context.params).id, parsed.data, { id: ctx.user?.id, name: ctx.user?.name, role: ctx.user?.role }), 200, ctx.requestId) + return jsonSuccess(await sampleProgressService.update((await context.params).id, parsed.data, ctx.user), 200, ctx.requestId) }, { requireAuth: true }) } export async function DELETE(request: NextRequest, context: Context) { diff --git a/furniture-3d-system/src/app/api/intake/sample-progress/route.ts b/furniture-3d-system/src/app/api/intake/sample-progress/route.ts index 1d0ce5d3..08422d90 100644 --- a/furniture-3d-system/src/app/api/intake/sample-progress/route.ts +++ b/furniture-3d-system/src/app/api/intake/sample-progress/route.ts @@ -1,20 +1,21 @@ import { NextRequest } from 'next/server' import { sampleProgressInputSchema } from '@/lib/sample-progress' +import { hasSampleProgressBusinessEditorAccess } from '@/lib/sample-progress-permissions' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { sampleProgressService } from '@/server/services/sample-progress.service' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' -async function requireSupplyChainPermission(request: NextRequest, user: Parameters[0], action: 'view' | 'edit', requestId: string) { +async function requireSupplyChainPermission(request: NextRequest, user: Parameters[0], action: 'view' | 'edit', requestId: string, allowBusinessEditor = false) { if (request.nextUrl.searchParams.get('permissionScope') !== 'supply-chain') return null const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'newProductSampleProgress') - if (access[action]) return null + if (access[action] || (allowBusinessEditor && hasSampleProgressBusinessEditorAccess(user))) return null return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有打样进度明细权限', 403, requestId) } export async function GET(request: NextRequest) { return handleRequest(request, async (ctx) => { - const denied = await requireSupplyChainPermission(request, ctx.user, 'view', ctx.requestId) + const denied = await requireSupplyChainPermission(request, ctx.user, 'view', ctx.requestId, true) if (denied) return denied return jsonSuccess(await sampleProgressService.list(), 200, ctx.requestId) }, { requireAuth: true }) diff --git a/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx b/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx index 5bd19d23..ad62c8a6 100644 --- a/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx +++ b/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx @@ -10,19 +10,21 @@ import { SAMPLE_PROGRESS_STATUSES, } from '@/lib/sample-progress' import type { SampleProgressDTO } from '@/server/services/sample-progress.service' +import SampleProgressColumnSettings from './components/SampleProgressColumnSettings' import SampleProgressEditorDrawer from './components/SampleProgressEditorDrawer' import SampleProgressTableRow from './components/SampleProgressTableRow' +import { isSampleProgressSupplierOverdue, sampleProgressSuppliers } from './sampleProgressSuppliers' +import { useSampleProgressColumnVisibility } from './useSampleProgressColumnVisibility' const PAGE_SIZE = 20 -const hiddenTerminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'TERMINATED']) const utcToday = () => { const now = new Date() return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) } -const isOverdue = (item: SampleProgressDTO) => !item.autoGenerated && !hiddenTerminalStatuses.has(item.status) && new Date(item.sampleDueDate).getTime() < utcToday() -const TABLE_HEADERS = ['图片','开款编号','品牌','结构设计师','寻源负责人','跟单负责人','收到流程时间','实际打样完成时间','总时间','寻源时间','打样时间','产品尺寸','颜色','材质','打样数量','供应商','供应商确认时间','通知打样时间','打样交期','寄出快递单号','打样状态','进度说明','需求成本','实际成本','评审单','由壹点链接','操作'] as const +const isOverdue = (item: SampleProgressDTO) => sampleProgressSuppliers(item) + .some((supplier) => isSampleProgressSupplierOverdue(supplier, utcToday())) -export default function SampleProgressClient({ initialItems, currentUserName, currentUserRole, currentSampleStageOpeningCodes = [], embedded = false, permissionScope, permissionCanOperate = true }: { initialItems: SampleProgressDTO[]; currentUserName: string; currentUserRole?: string; currentSampleStageOpeningCodes?: string[]; embedded?: boolean; permissionScope?: 'supply-chain'; permissionCanOperate?: boolean }) { +export default function SampleProgressClient({ initialItems, currentUserName, currentUserRole, currentSampleStageOpeningCodes = [], embedded = false, permissionScope, permissionCanOperate = true, permissionCanEditAllRecords = false }: { initialItems: SampleProgressDTO[]; currentUserName: string; currentUserRole?: string; currentSampleStageOpeningCodes?: string[]; embedded?: boolean; permissionScope?: 'supply-chain'; permissionCanOperate?: boolean; permissionCanEditAllRecords?: boolean }) { const [items, setItems] = useState(initialItems) const [keyword, setKeyword] = useState('') const [status, setStatus] = useState('') @@ -32,14 +34,16 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu const [editorItem, setEditorItem] = useState(null) const [historyItem, setHistoryItem] = useState(null) const [previewImage, setPreviewImage] = useState(null) + const columnVisibility = useSampleProgressColumnVisibility(currentUserName) const scopeQuery = permissionScope ? '?permissionScope=supply-chain' : '' - const canEdit = (item: SampleProgressDTO) => permissionCanOperate && canEditSampleProgress(currentUserName, item, currentUserRole) + const canEdit = (item: SampleProgressDTO) => permissionCanEditAllRecords || (permissionCanOperate && canEditSampleProgress(currentUserName, item, currentUserRole)) const canDelete = permissionCanOperate && isSampleProgressAdministrator(currentUserRole) const currentStageCodes = useMemo(() => new Set(currentSampleStageOpeningCodes.map((code) => code.trim().toUpperCase())), [currentSampleStageOpeningCodes]) const currentStageItems = useMemo(() => items.filter((item) => currentStageCodes.has(item.openingCode.trim().toUpperCase())), [currentStageCodes, items]) const filtered = useMemo(() => items.filter((item) => { - const haystack = [item.openingCode, item.brand, item.supplier, item.designer, item.purchaseOwner, item.color, item.material].join(' ').toLowerCase() + const supplierKeywords = sampleProgressSuppliers(item).flatMap((supplier) => [supplier.supplier, supplier.courierTrackingNumber, supplier.progressDescription]) + const haystack = [item.openingCode, item.brand, item.designer, item.purchaseOwner, item.color, item.material, ...supplierKeywords].join(' ').toLowerCase() const statusMatches = status === 'ALL' || (!status ? currentStageCodes.has(item.openingCode.trim().toUpperCase()) : item.status === status) return (!keyword || haystack.includes(keyword.toLowerCase())) && statusMatches && (!overdueOnly || isOverdue(item)) }), [currentStageCodes, items, keyword, overdueOnly, status]) @@ -47,6 +51,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu const rows = filtered.slice((Math.min(page, totalPages) - 1) * PAGE_SIZE, Math.min(page, totalPages) * PAGE_SIZE) const overdueCount = currentStageItems.filter(isOverdue).length const statusCounts = SAMPLE_PROGRESS_STATUSES.map((value) => ({ value, count: items.filter((item) => item.status === value).length })) + const tableMinWidth = columnVisibility.visibleColumns.reduce((total, column) => total + column.minWidth, 0) useEffect(() => { setPageInput(String(page)) @@ -101,17 +106,23 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu {overdueOnly && } + {embedded && permissionCanOperate && }
- +
- {TABLE_HEADERS.map((label) => )} + {columnVisibility.visibleColumns.map((column) => )} - {rows.map((item) => setEditorItem(item)} onHistory={setHistoryItem} onDelete={(row) => void remove(row)} onPreview={setPreviewImage}/>) } + {rows.map((item) => setEditorItem(item)} onHistory={setHistoryItem} onDelete={(row) => void remove(row)} onPreview={setPreviewImage}/>) }
{label}
{column.label}
{rows.length === 0 &&
暂无匹配的打样记录
} diff --git a/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressColumnSettings.tsx b/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressColumnSettings.tsx new file mode 100644 index 00000000..25406266 --- /dev/null +++ b/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressColumnSettings.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Check, LockKeyhole, RotateCcw, Settings2, X } from 'lucide-react' +import { SAMPLE_PROGRESS_COLUMNS, type SampleProgressColumnKey } from '../sampleProgressColumns' + +type Props = { + hiddenCount: number + visibleColumnKeys: ReadonlySet + onToggleColumn: (key: SampleProgressColumnKey) => void + onShowAllColumns: () => void +} + +export default function SampleProgressColumnSettings({ + hiddenCount, + visibleColumnKeys, + onToggleColumn, + onShowAllColumns, +}: Props) { + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!open) return + + const closeOnOutsidePointer = (event: PointerEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false) + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('pointerdown', closeOnOutsidePointer) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('pointerdown', closeOnOutsidePointer) + document.removeEventListener('keydown', closeOnEscape) + } + }, [open]) + + return
+ + + {open &&
+
+
+
显示字段
+
设置仅影响当前账号在本浏览器的表格
+
+
+ + +
+
+ +
+ {SAMPLE_PROGRESS_COLUMNS.map((column) => { + const checked = visibleColumnKeys.has(column.key) + return + })} +
+
} +
+} diff --git a/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.test.tsx b/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.test.tsx index f29d6b1f..dc26fbb3 100644 --- a/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.test.tsx +++ b/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.test.tsx @@ -101,6 +101,27 @@ describe('SampleProgressEditorDrawer', () => { await waitFor(() => expect(onSaved).toHaveBeenCalledWith(item)) }) + it('adds a supplier column and submits suppliers in display order', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ data: item }), + } as Response) + render() + + fireEvent.click(screen.getByRole('button', { name: '新增供应商' })) + fireEvent.change(screen.getByLabelText('供应商 2 · 供应商'), { target: { value: '第二供应商' } }) + fireEvent.change(screen.getByLabelText('供应商 2 · 打样状态'), { target: { value: 'PAINT' } }) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + const payload = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) + expect(payload.suppliers).toHaveLength(2) + expect(payload.suppliers.map((supplier: { supplier: string }) => supplier.supplier)).toEqual([ + '测试供应商', '第二供应商', + ]) + expect(payload.suppliers[1].status).toBe('PAINT') + }) + it('passes the supply-chain permission scope when editing and creating records', async () => { fetchMock.mockResolvedValue({ ok: true, @@ -184,7 +205,7 @@ describe('SampleProgressEditorDrawer', () => { fireEvent.change(screen.getByLabelText('打样交期'), { target: { value: '2026-09-05' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(screen.getByRole('alert')).toHaveTextContent('请填写交期变更原因') + expect(screen.getByRole('alert')).toHaveTextContent('请填写供应商 A 的交期变更原因') expect(fetchMock).not.toHaveBeenCalled() }) diff --git a/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.tsx b/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.tsx index 4c8745fb..b3732127 100644 --- a/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.tsx +++ b/furniture-3d-system/src/app/intake/sample-progress/components/SampleProgressEditorDrawer.tsx @@ -2,54 +2,68 @@ import { useEffect, useId, useState } from 'react' import Image from 'next/image' -import { ImageIcon, LoaderCircle, Save, X, ZoomIn } from 'lucide-react' +import { ImageIcon, LoaderCircle, Plus, Save, X, ZoomIn } from 'lucide-react' import { - SAMPLE_PROGRESS_STATUS_LABELS, - SAMPLE_PROGRESS_STATUSES, + type SampleProgressSupplierInput, type SampleProgressUpdateInput, } from '@/lib/sample-progress' import type { SampleProgressDTO } from '@/server/services/sample-progress.service' +import { sampleProgressSuppliers } from '../sampleProgressSuppliers' +import SampleProgressSupplierEditor from './SampleProgressSupplierEditor' const inputClass = 'h-10 w-full rounded-[5px] border border-black/[0.12] bg-white px-3 text-sm text-[#252b2d] outline-none transition focus:border-[#26756b] focus:ring-2 focus:ring-[#26756b]/10 disabled:cursor-not-allowed disabled:bg-[#f4f5f5] disabled:text-[#747d80]' const labelClass = 'space-y-1.5 text-xs font-bold text-[#5f696c]' -const emptyDraft = (): SampleProgressUpdateInput => ({ +type SampleProgressDraft = Omit & { + suppliers: SampleProgressSupplierInput[] +} + +const emptySupplier = (sampleDueDate = new Date()): SampleProgressSupplierInput => ({ + supplier: '', supplierConfirmedAt: null, sampleNotifiedAt: null, sampleDueDate, + courierTrackingNumber: '', status: 'BARE_WOOD', requestedCost: null, actualCost: null, + progressDescription: '', deliveryDateChangeReason: '', +}) + +const emptyDraft = (): SampleProgressDraft => ({ orderDate: new Date(), imageUrl: '', openingCode: '', productDevelopmentFlowUrl: '', productSize: '', color: '', material: '', sampleQuantity: null, brand: '', sampleDueDate: new Date(), supplier: '', supplierConfirmedAt: null, sampleNotifiedAt: null, courierTrackingNumber: '', finalSampleFactory: '', status: 'BARE_WOOD', designer: '', purchaseOwner: '', merchandiserOwner: '', progressDescription: '', requestedCost: null, actualCost: null, followUpOwner: '', deliveryDateChangeReason: '', + suppliers: [emptySupplier()], }) -const draftFromItem = (item: SampleProgressDTO): SampleProgressUpdateInput => ({ - orderDate: new Date(item.orderDate), imageUrl: item.imageUrl || '', openingCode: item.openingCode, - productDevelopmentFlowUrl: item.productDevelopmentFlowUrl || '', productSize: item.productSize, - color: item.color, material: item.material, sampleQuantity: item.sampleQuantity, brand: item.brand, - sampleDueDate: new Date(item.sampleDueDate), supplier: item.supplier, - supplierConfirmedAt: item.supplierConfirmedAt ? new Date(item.supplierConfirmedAt) : null, - sampleNotifiedAt: item.sampleNotifiedAt ? new Date(item.sampleNotifiedAt) : null, - courierTrackingNumber: item.courierTrackingNumber || '', finalSampleFactory: item.finalSampleFactory || '', - status: item.status as SampleProgressUpdateInput['status'], designer: item.designer, - purchaseOwner: item.purchaseOwner || '', merchandiserOwner: item.merchandiserOwner || '', - progressDescription: item.progressDescription || '', requestedCost: item.requestedCost, - actualCost: item.actualCost, followUpOwner: item.followUpOwner || '', deliveryDateChangeReason: '', -}) +const draftFromItem = (item: SampleProgressDTO): SampleProgressDraft => { + const suppliers = sampleProgressSuppliers(item).map((supplier) => ({ + ...supplier, + status: supplier.status as SampleProgressSupplierInput['status'], + sampleDueDate: new Date(supplier.sampleDueDate), + supplierConfirmedAt: supplier.supplierConfirmedAt ? new Date(supplier.supplierConfirmedAt) : null, + sampleNotifiedAt: supplier.sampleNotifiedAt ? new Date(supplier.sampleNotifiedAt) : null, + deliveryDateChangeReason: '', + })) + return { + orderDate: new Date(item.orderDate), imageUrl: item.imageUrl || '', openingCode: item.openingCode, + productDevelopmentFlowUrl: item.productDevelopmentFlowUrl || '', productSize: item.productSize, + color: item.color, material: item.material, sampleQuantity: item.sampleQuantity, brand: item.brand, + sampleDueDate: new Date(item.sampleDueDate), supplier: item.supplier, + supplierConfirmedAt: item.supplierConfirmedAt ? new Date(item.supplierConfirmedAt) : null, + sampleNotifiedAt: item.sampleNotifiedAt ? new Date(item.sampleNotifiedAt) : null, + courierTrackingNumber: item.courierTrackingNumber || '', finalSampleFactory: item.finalSampleFactory || '', + status: item.status as SampleProgressUpdateInput['status'], designer: item.designer, + purchaseOwner: item.purchaseOwner || '', merchandiserOwner: item.merchandiserOwner || '', + progressDescription: item.progressDescription || '', requestedCost: item.requestedCost, + actualCost: item.actualCost, followUpOwner: item.followUpOwner || '', deliveryDateChangeReason: '', + suppliers, + } +} const inputDate = (value: string | Date) => { const date = new Date(value) return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) } -const inputDateTime = (value: string | Date | null | undefined) => { - if (!value) return '' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return '' - const local = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000) - return local.toISOString().slice(0, 16) -} -const parseDateInput = (value: string) => new Date(`${value}T12:00:00.000Z`) - type Props = { item: SampleProgressDTO | null permissionScope?: 'supply-chain' @@ -59,12 +73,11 @@ type Props = { export default function SampleProgressEditorDrawer({ item, permissionScope, onClose, onSaved }: Props) { const titleId = useId() - const [draft, setDraft] = useState(() => item ? draftFromItem(item) : emptyDraft()) + const [draft, setDraft] = useState(() => item ? draftFromItem(item) : emptyDraft()) const [oaReachedAt, setOaReachedAt] = useState(item?.oaReachedAt ?? null) const [saving, setSaving] = useState(false) const [message, setMessage] = useState('') const [previewOpen, setPreviewOpen] = useState(false) - const scopeQuery = permissionScope ? '?permissionScope=supply-chain' : '' useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -76,11 +89,17 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl return () => window.removeEventListener('keydown', onKeyDown) }, [onClose, previewOpen, saving]) - const setField = (key: K, value: SampleProgressUpdateInput[K]) => { + const setField = (key: K, value: SampleProgressDraft[K]) => { setDraft((current) => ({ ...current, [key]: value })) setMessage('') } - const dueDateChanged = Boolean(item && inputDate(draft.sampleDueDate) !== inputDate(item.sampleDueDate)) + const originalSuppliers = item ? sampleProgressSuppliers(item) : [] + const dueDateChanged = (supplier: SampleProgressSupplierInput, index: number) => { + const original = supplier.id + ? originalSuppliers.find((candidate) => candidate.id === supplier.id) + : originalSuppliers[index] + return Boolean(original && inputDate(supplier.sampleDueDate) !== inputDate(original.sampleDueDate)) + } async function loadOaPrefill() { if (item || !draft.openingCode.trim()) return @@ -101,14 +120,29 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl async function save() { if (!draft.openingCode.trim()) { setMessage('请填写开款编号'); return } - if (dueDateChanged && !draft.deliveryDateChangeReason?.trim()) { setMessage('请填写交期变更原因'); return } + const missingReasonIndex = draft.suppliers.findIndex((supplier, index) => dueDateChanged(supplier, index) && !supplier.deliveryDateChangeReason?.trim()) + if (missingReasonIndex >= 0) { setMessage(`请填写供应商 ${String.fromCharCode(65 + missingReasonIndex)} 的交期变更原因`); return } + const firstSupplier = draft.suppliers[0] + const payload: SampleProgressUpdateInput = { + ...draft, + supplier: firstSupplier.supplier, + supplierConfirmedAt: firstSupplier.supplierConfirmedAt, + sampleNotifiedAt: firstSupplier.sampleNotifiedAt, + sampleDueDate: firstSupplier.sampleDueDate, + courierTrackingNumber: firstSupplier.courierTrackingNumber, + status: firstSupplier.status, + requestedCost: firstSupplier.requestedCost, + actualCost: firstSupplier.actualCost, + progressDescription: firstSupplier.progressDescription, + deliveryDateChangeReason: firstSupplier.deliveryDateChangeReason, + } setSaving(true) setMessage('') try { - const response = await fetch(item ? `/api/intake/sample-progress/${item.id}${scopeQuery}` : `/api/intake/sample-progress${scopeQuery}`, { + const response = await fetch(item ? `/api/intake/sample-progress/${item.id}${permissionScope ? '?permissionScope=supply-chain' : ''}` : `/api/intake/sample-progress${permissionScope ? '?permissionScope=supply-chain' : ''}`, { method: item ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(draft), + body: JSON.stringify(payload), }) const result = await response.json().catch(() => null) if (!response.ok) { setMessage(result?.error?.message || '保存失败,请稍后重试'); return } @@ -122,7 +156,7 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl } return
{ if (event.target === event.currentTarget && !saving) onClose() }}> -
-
- setField('supplierConfirmedAt', value)}/> - setField('sampleNotifiedAt', value)}/> - - setField('courierTrackingNumber', value)}/> - {dueDateChanged &&
setField('deliveryDateChangeReason', value)}/>
} -
- -
- -
- setField('requestedCost', value)}/> - setField('actualCost', value)}/> -