diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 73d8d328..2afd8194 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -939,7 +939,6 @@ model AssetFamily { deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) variants AssetVariant[] - skuGroups AssetSkuGroup[] compatibleSeries AssetFamilySeriesCompatibility[] createdAt DateTime @default(now()) @@ -958,26 +957,6 @@ model AssetFamily { @@index([deletedById]) } -model AssetSkuGroup { - id String @id @default(cuid()) - assetFamilyId String - assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) - code String - name String - description String? @db.Text - sortOrder Int @default(0) - metadata Json? - variants AssetVariant[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([assetFamilyId, code]) - @@index([assetFamilyId]) - @@index([code]) - @@index([sortOrder]) -} - model AssetSeries { id String @id @default(cuid()) code String @unique @@ -1025,8 +1004,6 @@ model AssetVariant { id String @id @default(cuid()) assetFamilyId String assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) - skuGroupId String? - skuGroup AssetSkuGroup? @relation(fields: [skuGroupId], references: [id], onDelete: SetNull) code String @unique name String identityKey String @@ -1084,7 +1061,6 @@ model AssetVariant { @@unique([assetFamilyId, identityKey]) @@index([assetFamilyId]) - @@index([skuGroupId]) @@index([status]) @@index([barcode]) @@index([substrateId]) diff --git a/furniture-3d-system/src/app/globals.css b/furniture-3d-system/src/app/globals.css index 72bd1cef..a7eb425b 100644 --- a/furniture-3d-system/src/app/globals.css +++ b/furniture-3d-system/src/app/globals.css @@ -1450,6 +1450,15 @@ animation: slideInRight 0.35s cubic-bezier(0.32, 0.72, 0, 1); } +@keyframes nav-progress { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(260%); + } +} + /* 滑块 — Precision Atelier 配色 */ .apple-slider { width: 100%; diff --git a/furniture-3d-system/src/app/intake/intakeClientTypes.ts b/furniture-3d-system/src/app/intake/intakeClientTypes.ts index 5c459ae8..acc9adab 100644 --- a/furniture-3d-system/src/app/intake/intakeClientTypes.ts +++ b/furniture-3d-system/src/app/intake/intakeClientTypes.ts @@ -18,6 +18,7 @@ import type { WarehouseLocationDTO, } from "@/server/services"; import type { PlatformMetricItem } from "@/components/platform/types"; +import type { NonIntegratedProductPageInfo } from "./non-integrated/core/nonIntegratedApi"; import type { NonIntegratedProductRecord } from "./non-integrated/nonIntegratedStorage"; type IntakeClientUser = { @@ -45,6 +46,11 @@ type CommonIntakeClientProps = { materialLibraryCategories: MaterialCategoryDTO[]; materialLibraryDictionaries: SubstrateDictionaryBundleDTO; initialNonIntegratedProducts: NonIntegratedProductRecord[]; + initialNonIntegratedPageInfo?: { + product?: NonIntegratedProductPageInfo; + stock?: NonIntegratedProductPageInfo; + series?: NonIntegratedProductPageInfo; + }; initialNodePermissionOverrides?: IntakeNodePermissionOverrideMap; initialActiveSection?: IntakeSection; initialActiveRawMaterialLibrary?: RawMaterialLibraryKey; diff --git a/furniture-3d-system/src/app/intake/intakeClientViewProps.tsx b/furniture-3d-system/src/app/intake/intakeClientViewProps.tsx index 7b9df952..c1fd54f4 100644 --- a/furniture-3d-system/src/app/intake/intakeClientViewProps.tsx +++ b/furniture-3d-system/src/app/intake/intakeClientViewProps.tsx @@ -118,6 +118,7 @@ export function buildIntakePrimaryDomainProps(params: { pendingEntryTab: IntakeEntryTab; skuList: IntegratedPendingSeriesRow[]; nonIntegratedProducts: NonIntegratedProductRecord[]; + initialNonIntegratedPageInfo: IntakeClientProps["initialNonIntegratedPageInfo"]; canDeleteNonIntegratedProducts: boolean; canCreateNonIntegratedProducts: boolean; canUseDesignControlledProductData: boolean; @@ -162,6 +163,7 @@ export function buildIntakePrimaryDomainProps(params: { pendingEntryTab: params.pendingEntryTab, integratedRows: params.skuList, nonIntegratedProducts: params.nonIntegratedProducts, + initialNonIntegratedPageInfo: params.initialNonIntegratedPageInfo, canDeleteProducts: params.canDeleteNonIntegratedProducts, canCreateProducts: params.canCreateNonIntegratedProducts, canUseDesignControlledProductData: diff --git a/furniture-3d-system/src/app/intake/loading.tsx b/furniture-3d-system/src/app/intake/loading.tsx new file mode 100644 index 00000000..09e34774 --- /dev/null +++ b/furniture-3d-system/src/app/intake/loading.tsx @@ -0,0 +1,41 @@ +const ROWS = Array.from({ length: 8 }, (_, index) => index) +const COLUMNS = Array.from({ length: 8 }, (_, index) => index) + +export default function IntakeLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {COLUMNS.map((column) => ( +
+
+
+ ))} + {ROWS.flatMap((row) => + COLUMNS.map((column) => ( +
+
+
+ )), + )} +
+
+
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 1109f218..718d866c 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -34,6 +34,7 @@ import { NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, replaceNonIntegratedProducts, saveNonIntegratedProduct, + type NonIntegratedFactoryRow, type NonIntegratedSkuItem, type NonIntegratedProductRecord, } from "./nonIntegratedStorage"; @@ -454,6 +455,38 @@ function createSkuItem( }; } +function createFactoryRow( + overrides: Partial = {}, +): NonIntegratedFactoryRow { + return { + skuId: "sku-test-1", + confirmed: false, + model: "SZ0230K01", + skuCode: "SZ023001", + setCount: "1", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75", + size: "140/60/75", + color: "胡桃色", + material: "橡胶木", + installMethod: "拆装", + supplierName: "兰考华兰家具", + supplierCode: "N1280", + warehouse: "郑州仓", + shippingMethod: "入仓", + minimumOrderQuantity: "50", + taxIncludedUnitPrice: "", + leadTime: "30天", + assemblyMode: "", + productionType: "OEM", + installationService: "送货", + supplierCategory: "主力供应商", + purchaseOrderMethod: "订单式", + executeStandard: "GB/T 3324", + ...overrides, + }; +} + function createMaterialFinish( overrides: Partial & Pick, ): MaterialDTO { @@ -4446,7 +4479,7 @@ describe("NonIntegratedDetailClient", () => { }); }); - it("shares product factory selection fields by model without changing SKU content", async () => { + it("shares product factory selection fields by SPU without changing SKU content", async () => { const actor = userEvent.setup(); saveNonIntegratedProduct( @@ -4573,12 +4606,12 @@ describe("NonIntegratedDetailClient", () => { expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([ "郑州仓", "郑州仓", - "", + "郑州仓", ]); expect(savedProduct?.factoryRows.map((row) => row.shippingMethod)).toEqual([ "代发", "代发", - "", + "代发", ]); expect( savedProduct?.factoryRows.map((row) => row.minimumOrderQuantity), @@ -4586,7 +4619,7 @@ describe("NonIntegratedDetailClient", () => { expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([ "OEM", "OEM", - "", + "OEM", ]); expect( savedProduct?.factoryRows.map((row) => row.installationService), @@ -4594,11 +4627,11 @@ describe("NonIntegratedDetailClient", () => { expect(savedProduct?.factoryRows.map((row) => row.supplierCategory)).toEqual([ "主力供应商", "主力供应商", - "", + "主力供应商", ]); expect( savedProduct?.factoryRows.map((row) => row.purchaseOrderMethod), - ).toEqual(["订单式", "订单式", ""]); + ).toEqual(["订单式", "订单式", "订单式"]); expect(savedProduct?.skuItems.map((item) => item.manufacturer)).toEqual([ "曹县嘉欢", "曹县嘉欢", @@ -4640,17 +4673,17 @@ describe("NonIntegratedDetailClient", () => { expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([ "杭州仓", "杭州仓", - "", + "杭州仓", ]); expect(savedProduct?.factoryRows.map((row) => row.shippingMethod)).toEqual([ "入仓", "入仓", - "", + "入仓", ]); expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([ "ODM", "ODM", - "", + "ODM", ]); expect(savedProduct?.skuItems.map((item) => item.manufacturer)).toEqual([ "曹县嘉欢", @@ -4679,16 +4712,82 @@ describe("NonIntegratedDetailClient", () => { expect(savedProduct?.factoryRows.map((row) => row.shippingMethod)).toEqual([ "入仓", "入仓", - "", + "入仓", ]); expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([ "ODM", "ODM", - "", + "ODM", ]); }); }); + it("requires product factory rows to be manually confirmed after all fields are filled", async () => { + const actor = userEvent.setup(); + const productId = "SZ0230-product-factory-manual-confirm"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "SZ0230", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, + skuItems: [ + createSkuItem({ + id: "sku-factory-confirm-1", + model: "SZ0230K01", + skuCode: "SZ023001", + barcode: "SZ023001N1280V01", + manufacturer: "兰考华兰家具", + manufacturerCode: "N1280", + installMethod: "拆装", + executeStandard: "GB/T 3324", + }), + ], + factoryRows: [ + createFactoryRow({ + skuId: "sku-factory-confirm-1", + confirmed: false, + model: "SZ0230K01", + skuCode: "SZ023001", + barcode: "SZ023001N1280V01", + }), + ], + documentWorkspaceConfirmations: + createDefaultDocumentWorkspaceConfirmations(false), + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); + + const confirmButton = screen.getByTestId("product-factory-confirm"); + expect(confirmButton).toHaveTextContent("资料已填完核对无误请确认。"); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.factoryRows?.[0]?.confirmed).toBe(false); + expect(savedProduct?.documentWorkspaceConfirmations?.产品定厂).not.toBe( + true, + ); + expect(savedProduct?.documentWorkspaceApprovedAt?.产品定厂).toBeUndefined(); + }); + + await actor.click(confirmButton); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.factoryRows?.[0]?.confirmed).toBe(true); + expect(savedProduct?.documentWorkspaceConfirmations?.产品定厂).toBe(true); + expect(savedProduct?.documentWorkspaceApprovedAt?.产品定厂).toEqual( + expect.any(String), + ); + }); + expect(confirmButton).toHaveTextContent("已确认"); + }); + it("persists the SKU national standard compliance selection", async () => { const actor = userEvent.setup(); @@ -6447,8 +6546,16 @@ describe("NonIntegratedDetailClient", () => { weightKg: "18.5", }, ]); + expect(savedProduct?.documentWorkspaceConfirmations?.样包测量).not.toBe( + true, + ); + expect(savedProduct?.documentWorkspaceApprovedAt?.样包测量).toBeUndefined(); }); + const confirmButton = screen.getByTestId("sample-measurement-confirm"); + expect(confirmButton).toHaveTextContent("资料已填完核对无误请确认。"); + await actor.click(confirmButton); + await waitFor(() => { const savedProduct = getNonIntegratedProductById( "SZ0230-sample-measurement", @@ -6458,6 +6565,7 @@ describe("NonIntegratedDetailClient", () => { expect.any(String), ); }); + expect(confirmButton).toHaveTextContent("已确认"); expect( screen.getByTestId("sample-measurement-lengthCm-sku-sample-1"), diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index e5c185ef..bebf75b2 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -1370,7 +1370,9 @@ export default function NonIntegratedDetailClient({ isReadOnly || isSeriesSkuComponentAggregateMode; const { allFinishedSampleShipmentRowsComplete, + allProductFactoryRowsConfirmed, allProductFactoryRowsComplete, + allSampleMeasurementRowsConfirmedByPricing, allSampleMeasurementRowsComplete, confirmedProductFactoryRowCount, displayedFinishedSampleShipmentRowsComplete, @@ -1383,6 +1385,7 @@ export default function NonIntegratedDetailClient({ displayedProductPricingRowsAllSystemEntered, displayedProductPricingRowsComplete, displayedProductPricingRowsReadyForPush, + displayedSampleMeasurementRowsConfirmedByPricing, displayedSampleMeasurementRowsComplete, isSampleMeasurementSkuLocked, planningLayoutHasContent, @@ -1460,6 +1463,9 @@ export default function NonIntegratedDetailClient({ const activeDocumentWorkspaceHasContent = (documentWorkspaceCountByCategory[activeDocumentWorkspaceCategory] ?? 0) > 0; + const displayedSampleMeasurementHasPendingRows = + displayedProductSampleMeasurementRows.length > 0 && + !displayedSampleMeasurementRowsComplete; const { basicInfoDisplayCount, basicInfoIsApproved, @@ -1476,19 +1482,19 @@ export default function NonIntegratedDetailClient({ isWorkflowNodeConfirmed, } = useNonIntegratedWorkflowConfirmationState({ allFinishedSampleShipmentRowsComplete, - allProductFactoryRowsComplete, - allSampleMeasurementRowsComplete, + allProductFactoryRowsConfirmed, + allSampleMeasurementRowsConfirmedByPricing, basicInfoHasContent, displayedFinishedSampleShipmentRowsComplete, displayedFirstQualityRowsComplete, displayedProductFactoryIsConfirmed, displayedProductLimitRowsComplete, displayedProductSampleMeasurementRows, + displayedSampleMeasurementRowsConfirmedByPricing, displayedSampleMeasurementRowsComplete, documentWorkspaceApprovedAt, documentWorkspaceConfirmations, documentWorkspaceRevisionSignatures, - isSampleMeasurementSkuLocked, previousDocumentWorkspaceRevisionSignatures: documentWorkspaceRevisionSignatureRef.current, productPricingCanBeConfirmed, @@ -1498,20 +1504,13 @@ export default function NonIntegratedDetailClient({ }); useNonIntegratedWorkflowAutoConfirmationEffects({ allFinishedSampleShipmentRowsComplete, - allProductFactoryRowsComplete, - allSampleMeasurementRowsComplete, existingProductCreatedAt, - factoryRows, finishedSampleShipmentRows, isReadOnly, mode, - productFactoryRows, productId, - sampleMeasurementRows, setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations, - setFactoryRows, - supplierCodeByName, }); useNonIntegratedWorkflowRevisionResetEffect({ documentWorkspaceRevisionSignatureRef, @@ -1803,6 +1802,7 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations; const nextDocumentWorkspaceApprovedAt = overrides?.documentWorkspaceApprovedAt ?? documentWorkspaceApprovedAt; + const nextFactoryRows = overrides?.factoryRows ?? productFactoryRows; const nextReviewRounds = overrides?.reviewRounds ?? reviewRounds; const nextFinishedSampleShipmentRows = overrides?.finishedSampleShipmentRows ?? productFinishedSampleShipmentRows; @@ -1810,6 +1810,8 @@ export default function NonIntegratedDetailClient({ overrides?.openingReference ?? openingReferenceRef.current; const nextSkuItems = overrides?.skuItems ?? skuItems; const nextPricingRows = overrides?.pricingRows ?? productPricingRows; + const nextSampleMeasurementRows = + overrides?.sampleMeasurementRows ?? productSampleMeasurementRows; const nextSkuBatchCount = overrides?.skuBatchCount ?? skuBatchCount; const nextSkuBatchConfirmedAt = overrides?.skuBatchConfirmedAt ?? skuBatchConfirmedAt; @@ -1874,11 +1876,11 @@ export default function NonIntegratedDetailClient({ bomDetails, nextReviewRounds, qualityInspectionSheet, - productFactoryRows, + nextFactoryRows, nextPricingRows, nextPlanningLayoutRows, productLimitRows, - productSampleMeasurementRows, + nextSampleMeasurementRows, nextFinishedSampleShipmentRows, productFirstQualityRows, uploadFiles, @@ -2585,13 +2587,16 @@ export default function NonIntegratedDetailClient({ derivedSkuItems, displayedProductFactoryHasPendingRows, displayedProductFactoryHasSystemEnteredPricingRows, + displayedProductFactoryIsConfirmed, displayedProductFactoryRows, displayedProductFactoryRowsComplete, documentWorkspaceApprovedAt, documentWorkspaceConfirmations, + documentWorkspaceRevisionSignatureRef, isCurrentUserAdmin, isReadOnly, isSkuItemFactoryConfirmed, + persistEditedProduct, productFactoryModelAutofillRef, productFactoryReopenField, productFactoryRows, @@ -2631,7 +2636,7 @@ export default function NonIntegratedDetailClient({ documentWorkspaceApprovedAt, documentWorkspaceConfirmations, documentWorkspaceRevisionSignatureRef, - isProductFactorySkuComplete, + isProductFactorySkuConfirmed: isSkuItemFactoryConfirmed, isProductLimitNodeLocked, isProductPricingNodeLocked, isReadOnly, @@ -2643,6 +2648,8 @@ export default function NonIntegratedDetailClient({ planningLayoutRowsRef, pricingRows, productId, + productSampleMeasurementRows, + sampleMeasurementRows, scheduleAutoSave, setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations, @@ -2879,11 +2886,20 @@ export default function NonIntegratedDetailClient({ handleFinishedSampleShipmentStatusClick, handleSampleMeasurementStatusClick, } = useSampleWorkflowStatusController({ + documentWorkspaceApprovedAt, + documentWorkspaceConfirmations, + documentWorkspaceRevisionSignatureRef, displayedFinishedSampleShipmentRows, displayedFinishedSampleShipmentRowsComplete, displayedProductFactoryIsConfirmed, displayedProductSampleMeasurementRows, + displayedSampleMeasurementIsConfirmed, displayedSampleMeasurementRowsComplete, + isReadOnly, + persistEditedProduct, + productSampleMeasurementRows, + setDocumentWorkspaceApprovedAt, + setDocumentWorkspaceConfirmations, setSaveMessage, setSaveMessageType, }); @@ -3820,7 +3836,7 @@ export default function NonIntegratedDetailClient({ allRows: productSampleMeasurementRows, displayedRows: displayedProductSampleMeasurementRows, isReadOnly, - isProductFactorySkuComplete, + isProductFactorySkuConfirmed: isSkuItemFactoryConfirmed, isSampleMeasurementSkuLocked, onUpdateRow: updateSampleMeasurementRow, }, @@ -4320,9 +4336,12 @@ export default function NonIntegratedDetailClient({ documentWorkspaceCountByCategory } documentWorkspaceView={documentWorkspaceView} - isProductFactoryRowComplete={isProductFactoryRowComplete} + isSampleMeasurementConfirmed={ + displayedSampleMeasurementIsConfirmed + } isWorkflowNodeConfirmed={isWorkflowNodeConfirmed} productFactoryRows={productFactoryRows} + sampleMeasurementRows={productSampleMeasurementRows} visibleGroupedUploadFiles={visibleGroupedUploadFiles} onOpenBasicInfo={() => setDocumentWorkspaceView("detail")} onOpenDocumentWorkspace={handleOpenDocumentWorkspace} @@ -4781,6 +4800,7 @@ export default function NonIntegratedDetailClient({ displayedProductPricingRowsReadyForPush, displayedProductSampleMeasurementRowCount: displayedProductSampleMeasurementRows.length, + displayedSampleMeasurementHasPendingRows, displayedSampleMeasurementIsConfirmed, documentWorkspaceConfirmationAction: renderDocumentWorkspaceConfirmationAction(), diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedDetailTypes.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedDetailTypes.ts index 0f58be86..f66c6dc1 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedDetailTypes.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedDetailTypes.ts @@ -2,6 +2,7 @@ import type { MaterialDTO } from "@/server/services"; import type { NonIntegratedDocumentWorkspaceApprovalMap, NonIntegratedDocumentWorkspaceConfirmationMap, + NonIntegratedFactoryRow, NonIntegratedFinishedSampleShipmentRow, NonIntegratedOpeningReference, NonIntegratedProductRecord, @@ -11,6 +12,7 @@ import type { NonIntegratedReviewIssue, NonIntegratedReviewRound, NonIntegratedReviewSheet, + NonIntegratedSampleMeasurementRow, NonIntegratedSkuItem, } from "./core/nonIntegratedStorage"; import type { @@ -58,6 +60,8 @@ export type AutoSaveNoticeType = "saving" | "success" | "error"; export type PersistEditedProductOverrides = { documentWorkspaceConfirmations?: NonIntegratedDocumentWorkspaceConfirmationMap; documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap; + factoryRows?: NonIntegratedFactoryRow[]; + sampleMeasurementRows?: NonIntegratedSampleMeasurementRow[]; reviewRounds?: NonIntegratedReviewRound[]; finishedSampleShipmentRows?: NonIntegratedFinishedSampleShipmentRow[]; openingReference?: NonIntegratedOpeningReference; diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowAutoConfirmationEffects.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowAutoConfirmationEffects.ts index 30fea2b2..819c4ce6 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowAutoConfirmationEffects.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowAutoConfirmationEffects.ts @@ -1,89 +1,41 @@ import { useEffect, type Dispatch, type SetStateAction } from "react"; import { FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, - PRODUCT_FACTORY_WORKSPACE_CATEGORY, - SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, } from "./workspaces/documents/documentWorkspaceConfig"; import type { DocumentWorkspaceCategory } from "./workspaces/documents/documentWorkspaceHelpers"; -import { - isProductFactoryRowComplete, - normalizeFactoryRowsForPayload, -} from "./workspaces/basic-info/productFactoryHelpers"; import type { NonIntegratedDocumentWorkspaceApprovalMap, NonIntegratedDocumentWorkspaceConfirmationMap, - NonIntegratedFactoryRow, NonIntegratedFinishedSampleShipmentRow, - NonIntegratedSampleMeasurementRow, } from "./core/nonIntegratedStorage"; type DetailMode = "create" | "edit" | "view"; type UseNonIntegratedWorkflowAutoConfirmationEffectsParams = { allFinishedSampleShipmentRowsComplete: boolean; - allProductFactoryRowsComplete: boolean; - allSampleMeasurementRowsComplete: boolean; existingProductCreatedAt: string; - factoryRows: NonIntegratedFactoryRow[]; finishedSampleShipmentRows: NonIntegratedFinishedSampleShipmentRow[]; isReadOnly: boolean; mode: DetailMode; - productFactoryRows: NonIntegratedFactoryRow[]; productId?: string; - sampleMeasurementRows: NonIntegratedSampleMeasurementRow[]; setDocumentWorkspaceApprovedAt: Dispatch< SetStateAction >; setDocumentWorkspaceConfirmations: Dispatch< SetStateAction >; - setFactoryRows: Dispatch>; - supplierCodeByName: Map; }; export function useNonIntegratedWorkflowAutoConfirmationEffects({ allFinishedSampleShipmentRowsComplete, - allProductFactoryRowsComplete, - allSampleMeasurementRowsComplete, existingProductCreatedAt, - factoryRows, finishedSampleShipmentRows, isReadOnly, mode, - productFactoryRows, productId, - sampleMeasurementRows, setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations, - setFactoryRows, - supplierCodeByName, }: UseNonIntegratedWorkflowAutoConfirmationEffectsParams) { - useEffect(() => { - if (isReadOnly || factoryRows.length === 0) return; - - const normalizedRows = normalizeFactoryRowsForPayload( - productFactoryRows, - supplierCodeByName, - ); - const hasConfirmationChange = normalizedRows.some( - (row) => row.confirmed !== isProductFactoryRowComplete(row), - ); - if (!hasConfirmationChange) return; - - setFactoryRows( - normalizedRows.map((row) => ({ - ...row, - confirmed: isProductFactoryRowComplete(row), - })), - ); - }, [ - factoryRows.length, - isReadOnly, - productFactoryRows, - setFactoryRows, - supplierCodeByName, - ]); - useEffect(() => { if ( mode !== "edit" || @@ -99,17 +51,6 @@ export function useNonIntegratedWorkflowAutoConfirmationEffects({ touched: boolean; complete: boolean; }> = [ - { - category: PRODUCT_FACTORY_WORKSPACE_CATEGORY, - touched: factoryRows.length > 0, - complete: allProductFactoryRowsComplete, - }, - { - category: SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, - touched: sampleMeasurementRows.length > 0, - complete: - allProductFactoryRowsComplete && allSampleMeasurementRowsComplete, - }, { category: FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, touched: finishedSampleShipmentRows.length > 0, @@ -149,15 +90,11 @@ export function useNonIntegratedWorkflowAutoConfirmationEffects({ }); }, [ allFinishedSampleShipmentRowsComplete, - allProductFactoryRowsComplete, - allSampleMeasurementRowsComplete, existingProductCreatedAt, - factoryRows.length, finishedSampleShipmentRows.length, isReadOnly, mode, productId, - sampleMeasurementRows.length, setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations, ]); diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowCompletion.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowCompletion.ts index 2827725a..d582c6f3 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowCompletion.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowCompletion.ts @@ -56,7 +56,7 @@ export function useNonIntegratedWorkflowCompletion({ productSampleMeasurementRows: NonIntegratedSampleMeasurementRow[]; }) { const confirmedProductFactoryRowCount = useMemo( - () => productFactoryRows.filter(isProductFactoryRowComplete).length, + () => productFactoryRows.filter((row) => row.confirmed).length, [productFactoryRows], ); const populatedProductPricingRowCount = useMemo( @@ -109,7 +109,10 @@ export function useNonIntegratedWorkflowCompletion({ displayedProductFactoryRows.some((row) => !isProductFactoryRowComplete(row)); const displayedProductFactoryIsConfirmed = displayedProductFactoryRows.length > 0 && - !displayedProductFactoryHasPendingRows; + displayedProductFactoryRows.every((row) => row.confirmed); + const allProductFactoryRowsConfirmed = + productFactoryRows.length > 0 && + productFactoryRows.every((row) => row.confirmed); const displayedProductFactoryRowsComplete = displayedProductFactoryRows.length > 0 && displayedProductFactoryRows.every(isProductFactoryRowComplete); @@ -162,6 +165,16 @@ export function useNonIntegratedWorkflowCompletion({ ), [productPricingRows], ); + const allSampleMeasurementRowsConfirmedByPricing = + productSampleMeasurementRows.length > 0 && + productSampleMeasurementRows.every((row) => + productPricingSystemEnteredSkuIdSet.has(row.skuId), + ); + const displayedSampleMeasurementRowsConfirmedByPricing = + displayedProductSampleMeasurementRows.length > 0 && + displayedProductSampleMeasurementRows.every((row) => + productPricingSystemEnteredSkuIdSet.has(row.skuId), + ); const displayedProductFactoryHasSystemEnteredPricingRows = displayedProductFactoryRows.some((row) => productPricingSystemEnteredSkuIdSet.has(row.skuId), @@ -179,7 +192,10 @@ export function useNonIntegratedWorkflowCompletion({ return { allFinishedSampleShipmentRowsComplete, + allProductFactoryRowsConfirmed, allProductFactoryRowsComplete, + allProductPricingRowsSystemEntered, + allSampleMeasurementRowsConfirmedByPricing, allSampleMeasurementRowsComplete, confirmedProductFactoryRowCount, displayedFinishedSampleShipmentRowsComplete, @@ -192,6 +208,7 @@ export function useNonIntegratedWorkflowCompletion({ displayedProductPricingRowsAllSystemEntered, displayedProductPricingRowsComplete, displayedProductPricingRowsReadyForPush, + displayedSampleMeasurementRowsConfirmedByPricing, displayedSampleMeasurementRowsComplete, isSampleMeasurementSkuLocked, planningLayoutHasContent, diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmationState.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmationState.ts index db84f226..5b48d2f9 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmationState.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmationState.ts @@ -25,19 +25,19 @@ import { type UseNonIntegratedWorkflowConfirmationStateParams = { allFinishedSampleShipmentRowsComplete: boolean; - allProductFactoryRowsComplete: boolean; - allSampleMeasurementRowsComplete: boolean; + allProductFactoryRowsConfirmed: boolean; + allSampleMeasurementRowsConfirmedByPricing: boolean; basicInfoHasContent: boolean; displayedFinishedSampleShipmentRowsComplete: boolean; displayedFirstQualityRowsComplete: boolean; displayedProductFactoryIsConfirmed: boolean; displayedProductLimitRowsComplete: boolean; displayedProductSampleMeasurementRows: Array<{ skuId: string }>; + displayedSampleMeasurementRowsConfirmedByPricing: boolean; displayedSampleMeasurementRowsComplete: boolean; documentWorkspaceApprovedAt: NonIntegratedDocumentWorkspaceApprovalMap; documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap; documentWorkspaceRevisionSignatures: Record; - isSampleMeasurementSkuLocked: (skuId: string) => boolean; previousDocumentWorkspaceRevisionSignatures: | Record | null @@ -54,19 +54,19 @@ type UseNonIntegratedWorkflowConfirmationStateParams = { export function useNonIntegratedWorkflowConfirmationState({ allFinishedSampleShipmentRowsComplete, - allProductFactoryRowsComplete, - allSampleMeasurementRowsComplete, + allProductFactoryRowsConfirmed, + allSampleMeasurementRowsConfirmedByPricing, basicInfoHasContent, displayedFinishedSampleShipmentRowsComplete, displayedFirstQualityRowsComplete, displayedProductFactoryIsConfirmed, displayedProductLimitRowsComplete, displayedProductSampleMeasurementRows, + displayedSampleMeasurementRowsConfirmedByPricing, displayedSampleMeasurementRowsComplete, documentWorkspaceApprovedAt, documentWorkspaceConfirmations, documentWorkspaceRevisionSignatures, - isSampleMeasurementSkuLocked, previousDocumentWorkspaceRevisionSignatures, productPricingCanBeConfirmed, projectStatus, @@ -92,13 +92,15 @@ export function useNonIntegratedWorkflowConfirmationState({ const isWorkflowNodeConfirmed = useCallback( (category: WorkflowNodeCategory, hasContent: boolean) => { if (category === PRODUCT_FACTORY_WORKSPACE_CATEGORY) { - return hasContent && allProductFactoryRowsComplete; + return hasContent && allProductFactoryRowsConfirmed; } if (category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY) { + if (!hasContent || !allProductFactoryRowsConfirmed) return false; + if (allSampleMeasurementRowsConfirmedByPricing) return true; return ( - hasContent && - allProductFactoryRowsComplete && - allSampleMeasurementRowsComplete + documentWorkspaceConfirmations[category] === true && + Boolean(documentWorkspaceApprovedAt[category]) && + !dirtyWorkflowNodeCategories.has(category) ); } if (category === FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY) { @@ -110,12 +112,6 @@ export function useNonIntegratedWorkflowConfirmationState({ ) { return false; } - if ( - category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY && - !displayedProductFactoryIsConfirmed - ) { - return false; - } return ( !shouldRequireWorkflowConfirmation(category) || @@ -127,10 +123,9 @@ export function useNonIntegratedWorkflowConfirmationState({ }, [ allFinishedSampleShipmentRowsComplete, - allProductFactoryRowsComplete, - allSampleMeasurementRowsComplete, + allProductFactoryRowsConfirmed, + allSampleMeasurementRowsConfirmedByPricing, dirtyWorkflowNodeCategories, - displayedProductFactoryIsConfirmed, documentWorkspaceApprovedAt, documentWorkspaceConfirmations, productPricingCanBeConfirmed, @@ -142,11 +137,6 @@ export function useNonIntegratedWorkflowConfirmationState({ Boolean(documentWorkspaceApprovedAt[category]), [documentWorkspaceApprovedAt, documentWorkspaceConfirmations], ); - const isSampleMeasurementNodeLocked = - displayedProductSampleMeasurementRows.length > 0 && - displayedProductSampleMeasurementRows.every((row) => - isSampleMeasurementSkuLocked(row.skuId), - ); const isFinishedSampleShipmentNodeLocked = isDocumentWorkspaceNodeLocked( FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, ); @@ -156,12 +146,20 @@ export function useNonIntegratedWorkflowConfirmationState({ const isProductLimitNodeLocked = isDocumentWorkspaceNodeLocked( PRODUCT_LIMIT_WORKSPACE_CATEGORY, ); - const displayedSampleMeasurementIsConfirmed = isWorkflowNodeConfirmed( - SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, - displayedSampleMeasurementRowsComplete, - ); + const displayedSampleMeasurementHasContent = + displayedProductSampleMeasurementRows.length > 0; + const displayedSampleMeasurementStoredConfirmed = + documentWorkspaceConfirmations[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY] === + true && + Boolean(documentWorkspaceApprovedAt[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]) && + !dirtyWorkflowNodeCategories.has(SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY); + const displayedSampleMeasurementIsConfirmed = + displayedSampleMeasurementHasContent && + displayedProductFactoryIsConfirmed && + (displayedSampleMeasurementRowsConfirmedByPricing || + displayedSampleMeasurementStoredConfirmed); const isSampleMeasurementNodeApproved = - isSampleMeasurementNodeLocked && + displayedSampleMeasurementIsConfirmed && !dirtyWorkflowNodeCategories.has(SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY); const displayedFinishedSampleShipmentIsConfirmed = isWorkflowNodeConfirmed( FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowRows.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowRows.ts index d7ca3e06..752e7091 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowRows.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowRows.ts @@ -128,7 +128,7 @@ export function useNonIntegratedWorkflowRows({ () => new Set( productFactoryRows - .filter((row) => row.confirmed || isProductFactoryRowComplete(row)) + .filter((row) => row.confirmed) .map((row) => row.skuId), ), [productFactoryRows], diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/FinishedSampleShipmentWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/FinishedSampleShipmentWorkspace.tsx index 34be8212..593745f5 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/FinishedSampleShipmentWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/FinishedSampleShipmentWorkspace.tsx @@ -124,7 +124,7 @@ export function FinishedSampleShipmentWorkspace({
-
+
{allRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再进行成品寄样。 @@ -134,9 +134,9 @@ export function FinishedSampleShipmentWorkspace({ 当前批次还没有 SKU 信息。
) : ( -
+
- + {[ "寄样", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/FirstQualityWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/FirstQualityWorkspace.tsx index 4adb5c66..d023fc53 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/FirstQualityWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/FirstQualityWorkspace.tsx @@ -89,7 +89,7 @@ export function FirstQualityWorkspace({ -
+
{allRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再进行首批质检。 @@ -99,9 +99,9 @@ export function FirstQualityWorkspace({ 当前批次还没有 SKU 信息。
) : ( -
+
- + {[ "批次", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductLimitWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductLimitWorkspace.tsx index 2663c83b..5749de0a 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductLimitWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductLimitWorkspace.tsx @@ -96,7 +96,7 @@ export function ProductLimitWorkspace({ -
+
{allRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再填写产品限价。 @@ -106,9 +106,9 @@ export function ProductLimitWorkspace({ 当前批次还没有 SKU 信息。
) : ( -
+
- + {[ "产品图片", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductPricingWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductPricingWorkspace.tsx index df5330c9..7a1c1c01 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductPricingWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ProductPricingWorkspace.tsx @@ -113,7 +113,7 @@ export function ProductPricingWorkspace({ -
+
{allRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再核价。 @@ -123,9 +123,9 @@ export function ProductPricingWorkspace({ 当前批次还没有 SKU 信息。
) : ( -
+
- + {[ "型号", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/SampleMeasurementWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/SampleMeasurementWorkspace.tsx index 7fe185e4..e596999d 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/SampleMeasurementWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/SampleMeasurementWorkspace.tsx @@ -15,7 +15,7 @@ type SampleMeasurementWorkspaceProps = { displayedRows: ProductSampleMeasurementDisplayRow[]; inheritedSkuBatchTabs: ReactNode; isReadOnly: boolean; - isProductFactorySkuComplete: (skuId: string) => boolean; + isProductFactorySkuConfirmed: (skuId: string) => boolean; isSampleMeasurementSkuLocked: (skuId: string) => boolean; onUpdateRow: ( skuId: string, @@ -50,7 +50,7 @@ export function SampleMeasurementWorkspace({ displayedRows, inheritedSkuBatchTabs, isReadOnly, - isProductFactorySkuComplete, + isProductFactorySkuConfirmed, isSampleMeasurementSkuLocked, onUpdateRow, }: SampleMeasurementWorkspaceProps) { @@ -64,7 +64,7 @@ export function SampleMeasurementWorkspace({ placeholder={placeholder} readOnly={ isReadOnly || - !isProductFactorySkuComplete(row.skuId) || + !isProductFactorySkuConfirmed(row.skuId) || isSampleMeasurementSkuLocked(row.skuId) } mode="singleLineResizable" @@ -85,7 +85,7 @@ export function SampleMeasurementWorkspace({ -
+
{allRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再进行样包测量。 @@ -95,9 +95,9 @@ export function SampleMeasurementWorkspace({ 当前批次还没有 SKU 信息。
) : ( -
+
- + {[ "型号", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/ComponentSkuTable.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/ComponentSkuTable.tsx index 47d3525e..41db02eb 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/ComponentSkuTable.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/ComponentSkuTable.tsx @@ -67,10 +67,9 @@ export function ComponentSkuTable({ onSelectComponentCode: (code: string) => void; }) { return ( -
-
+
- + {COMPONENT_SKU_TABLE_HEADERS.map((label) => (
-
); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoTableShell.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoTableShell.tsx index 0dc637d1..0c28bd25 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoTableShell.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoTableShell.tsx @@ -50,14 +50,13 @@ export function SkuInfoTableShell({ (showActionColumn ? 1 : 0); return ( -
-
+
- + {headers.map((label) => (
-
); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/productFactoryHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/productFactoryHelpers.ts index f02d173d..4b09b66a 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/productFactoryHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/productFactoryHelpers.ts @@ -11,6 +11,7 @@ import { MODEL_SHARED_PRODUCT_FACTORY_KEYS, PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS, PRODUCT_FACTORY_REQUIRED_FIELDS, + SPU_SHARED_PRODUCT_FACTORY_KEYS, } from "./skuFieldConfig"; import { buildAutoBarcode, shouldKeepCustomBarcode } from "./skuAutoHelpers"; import { SUPPLIER_CODE_BY_NAME } from "./supplierDirectory"; @@ -84,6 +85,8 @@ export function normalizeModelSharedProductFactoryRows( }); return rows.map((row) => { + if (row.confirmed) return row; + const modelKey = getSkuFactoryModelKey(row); if (!modelKey) return row; @@ -101,6 +104,40 @@ export function normalizeModelSharedProductFactoryRows( }); } +export function normalizeSpuSharedProductFactoryRows( + rows: NonIntegratedFactoryRow[], +) { + const sharedValueByKey = new Map(); + SPU_SHARED_PRODUCT_FACTORY_KEYS.forEach((key) => { + const sharedValue = rows.find((row) => row[key].trim())?.[key].trim() ?? ""; + sharedValueByKey.set(key, sharedValue); + }); + + return rows.map((row) => { + if (row.confirmed) return row; + + let nextRow = row; + SPU_SHARED_PRODUCT_FACTORY_KEYS.forEach((key) => { + const sharedValue = sharedValueByKey.get(key); + if (sharedValue === undefined || nextRow[key] === sharedValue) return; + nextRow = { + ...nextRow, + [key]: sharedValue, + }; + }); + + return nextRow; + }); +} + +export function normalizeSharedProductFactoryRows( + rows: NonIntegratedFactoryRow[], +) { + return normalizeSpuSharedProductFactoryRows( + normalizeModelSharedProductFactoryRows(rows), + ); +} + export function getProductFactoryMissingFieldLabels( row: NonIntegratedFactoryRow, ) { @@ -123,7 +160,7 @@ export function getProductFactoryStatusMessage({ if (rows.length === 0) return null; if (isRowsComplete) { return { - message: "产品定厂已完成", + message: "资料已填完核对无误请确认。", type: "success" as const, }; } @@ -249,7 +286,7 @@ export function buildProductFactoryRows( ); }); - return normalizeModelSharedProductFactoryRows(rows); + return normalizeSharedProductFactoryRows(rows); } export function normalizeFactoryRowsForPayload( @@ -294,5 +331,5 @@ export function normalizeFactoryRowsForPayload( }; }); - return normalizeModelSharedProductFactoryRows(normalizedRows); + return normalizeSharedProductFactoryRows(normalizedRows); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldConfig.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldConfig.ts index 68c9272f..8685cdec 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldConfig.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldConfig.ts @@ -161,10 +161,13 @@ export const PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS = "assemblyMode", ]); export const MODEL_SHARED_PRODUCT_FACTORY_KEYS = new Set([ + "installationService", +]); +export const SPU_SHARED_PRODUCT_FACTORY_KEYS = new Set([ "warehouse", "shippingMethod", + "installMethod", "productionType", - "installationService", "supplierCategory", "purchaseOrderMethod", ]); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/bom/BomRowsTableShell.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/bom/BomRowsTableShell.tsx index d21cba17..a9ea7611 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/bom/BomRowsTableShell.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/bom/BomRowsTableShell.tsx @@ -28,7 +28,7 @@ export function BomRowsTableShell({ className="w-full min-w-full text-left text-xs" style={{ minWidth: `${tableMinWidth}px` }} > - + {columns.map((column) => ( ([ - PRODUCT_FACTORY_WORKSPACE_CATEGORY, - SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, ]); export const FACTORY_QUALITY_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryHeaderActions.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryHeaderActions.tsx index 8c559917..1cc9132f 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryHeaderActions.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryHeaderActions.tsx @@ -10,6 +10,7 @@ type ProductFactoryHeaderActionsProps = { isReadOnly: boolean; rowCount: number; hasPendingRows: boolean; + isConfirmed: boolean; hasSystemEnteredPricingRows: boolean; isReopenPickerOpen: boolean; reopenField: ProductFactoryEditableKey; @@ -26,6 +27,7 @@ export function ProductFactoryHeaderActions({ isReadOnly, rowCount, hasPendingRows, + isConfirmed, hasSystemEnteredPricingRows, isReopenPickerOpen, reopenField, @@ -98,10 +100,15 @@ export function ProductFactoryHeaderActions({ ) : null}
); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryInfoTable.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryInfoTable.tsx index d3ad91f2..86be723e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryInfoTable.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryInfoTable.tsx @@ -96,9 +96,9 @@ export function ProductFactoryInfoTable({ }; return ( -
+
- + {[ "定厂", @@ -141,14 +141,18 @@ export function ProductFactoryInfoTable({
- {isProductFactoryRowComplete(row) - ? "已完成" - : "当前资料未填完"} + {row.confirmed + ? "已确认" + : isProductFactoryRowComplete(row) + ? "资料已填完" + : "当前资料未填完"} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactorySkuInfoTable.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactorySkuInfoTable.tsx index 5c3371c4..7502a136 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactorySkuInfoTable.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactorySkuInfoTable.tsx @@ -8,9 +8,9 @@ export function ProductFactorySkuInfoTable({ rows: NonIntegratedFactoryRow[]; }) { return ( -
+
- + {[ "定厂", @@ -40,14 +40,18 @@ export function ProductFactorySkuInfoTable({ diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryWorkspace.tsx index d21bab4f..4a185372 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/ProductFactoryWorkspace.tsx @@ -91,7 +91,7 @@ export function ProductFactoryWorkspace({ /> -
+
{allRows.length === 0 ? (
当前还没有 SKU 信息,先维护 SKU 后再定厂。 diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/productFactoryTargetSkuHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/productFactoryTargetSkuHelpers.ts index bfb2d1cc..43037ec2 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/productFactoryTargetSkuHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/productFactoryTargetSkuHelpers.ts @@ -6,6 +6,7 @@ import { MODEL_AUTOFILL_PRODUCT_FACTORY_KEYS, MODEL_SHARED_PRODUCT_FACTORY_KEYS, PACKAGE_SHARED_PRODUCT_FACTORY_KEYS, + SPU_SHARED_PRODUCT_FACTORY_KEYS, type ProductFactoryEditableKey, type ProductFactoryModelAutofillEntry, } from "../basic-info/skuFieldConfig"; @@ -48,6 +49,10 @@ export function getProductFactoryTargetSkuIds( const targetItem = items.find((item) => item.id === targetId); if (!targetItem) return new Set([targetId]); + if (SPU_SHARED_PRODUCT_FACTORY_KEYS.has(key)) { + return new Set(items.map((item) => item.id)); + } + if (MODEL_SHARED_PRODUCT_FACTORY_KEYS.has(key)) { const targetModelKey = getSkuFactoryModelKey(targetItem); if (!targetModelKey) return new Set([targetId]); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts index 2bc298c1..b7bb17c0 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts @@ -4,6 +4,7 @@ import type { SetStateAction, } from "react"; +import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes"; import type { NonIntegratedDocumentWorkspaceApprovalMap, NonIntegratedDocumentWorkspaceConfirmationMap, @@ -16,10 +17,15 @@ import { type ProductFactoryEditableKey, type ProductFactoryModelAutofillEntry, } from "../basic-info/skuFieldConfig"; -import { normalizeFactorySupplier, getProductFactoryStatusMessage } from "../basic-info/productFactoryHelpers"; +import { + getProductFactoryStatusMessage, + normalizeFactoryRowsForPayload, + normalizeFactorySupplier, +} from "../basic-info/productFactoryHelpers"; import { buildSkuItemsAfterSetCountCommit } from "../basic-info/skuSetCountCommitHelpers"; import { getStyleKeyFromModel } from "../style-assets/styleFileHelpers"; import { PRODUCT_FACTORY_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig"; +import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers"; import { confirmProductFactorySupplierRowsForSku, reopenProductFactoryRows, @@ -35,18 +41,30 @@ type ApplySkuSupplierName = ( value: string, ) => NonIntegratedSkuItem; +type PersistEditedProduct = (params: { + successMessage: string; + errorFallbackMessage: string; + overrides?: PersistEditedProductOverrides; + suppressSuccessMessage?: boolean; +}) => Promise; + type UseProductFactoryRowControllerParams = { applySkuSupplierName: ApplySkuSupplierName; derivedSkuItems: NonIntegratedSkuItem[]; displayedProductFactoryHasPendingRows: boolean; displayedProductFactoryHasSystemEnteredPricingRows: boolean; + displayedProductFactoryIsConfirmed: boolean; displayedProductFactoryRows: NonIntegratedFactoryRow[]; displayedProductFactoryRowsComplete: boolean; documentWorkspaceApprovedAt: NonIntegratedDocumentWorkspaceApprovalMap; documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap; + documentWorkspaceRevisionSignatureRef: MutableRefObject< + Record | null + >; isCurrentUserAdmin: boolean; isReadOnly: boolean; isSkuItemFactoryConfirmed: (item: NonIntegratedSkuItem) => boolean; + persistEditedProduct: PersistEditedProduct; productFactoryModelAutofillRef: MutableRefObject< Map >; @@ -73,13 +91,16 @@ export function useProductFactoryRowController({ derivedSkuItems, displayedProductFactoryHasPendingRows, displayedProductFactoryHasSystemEnteredPricingRows, + displayedProductFactoryIsConfirmed, displayedProductFactoryRows, displayedProductFactoryRowsComplete, documentWorkspaceApprovedAt, documentWorkspaceConfirmations, + documentWorkspaceRevisionSignatureRef, isCurrentUserAdmin, isReadOnly, isSkuItemFactoryConfirmed, + persistEditedProduct, productFactoryModelAutofillRef, productFactoryReopenField, productFactoryRows, @@ -156,32 +177,32 @@ export function useProductFactoryRowController({ if (key === "installMethod") { setSkuItems((current) => { - const targetItem = workflowSkuItems.find((item) => item.id === skuId); - if (!targetItem) return current; + const targetSkuIds = getProductFactoryTargetSkuIds( + workflowSkuItems, + skuId, + key, + ); + const targetStyleKeys = new Set( + workflowSkuItems + .filter((item) => targetSkuIds.has(item.id)) + .map((item) => getStyleKeyFromModel(item.model)) + .filter(Boolean), + ); - const targetStyleKey = getStyleKeyFromModel(targetItem.model); - if (targetStyleKey) { - const targetSkuIds = new Set( - workflowSkuItems - .filter((item) => getStyleKeyFromModel(item.model) === targetStyleKey) - .map((item) => item.id), - ); - - setStyleDetails((currentDetails) => - updateStyleDetailEntry(currentDetails, targetStyleKey, { - installMethod: value, - }), - ); - - return current.map((item) => - targetSkuIds.has(item.id) - ? { ...item, installMethod: value } - : item, - ); + if (targetStyleKeys.size > 0) { + setStyleDetails((currentDetails) => { + let nextDetails = currentDetails; + targetStyleKeys.forEach((styleKey) => { + nextDetails = updateStyleDetailEntry(nextDetails, styleKey, { + installMethod: value, + }); + }); + return nextDetails; + }); } return current.map((item) => - item.id === skuId ? { ...item, installMethod: value } : item, + targetSkuIds.has(item.id) ? { ...item, installMethod: value } : item, ); }); } @@ -235,13 +256,65 @@ export function useProductFactoryRowController({ setSaveMessageType("success"); } - function handleProductFactoryStatusClick() { + async function handleProductFactoryStatusClick() { const statusMessage = getProductFactoryStatusMessage({ isRowsComplete: displayedProductFactoryRowsComplete, rows: displayedProductFactoryRows, }); if (!statusMessage) return; + if ( + !isReadOnly && + displayedProductFactoryRowsComplete && + !displayedProductFactoryIsConfirmed + ) { + const targetSkuIds = new Set( + displayedProductFactoryRows.map((row) => row.skuId), + ); + const normalizedRows = normalizeFactoryRowsForPayload( + productFactoryRows, + supplierCodeByName, + ); + const nextRows = normalizedRows.map((row) => + targetSkuIds.has(row.skuId) ? { ...row, confirmed: true } : row, + ); + const allRowsConfirmed = + nextRows.length > 0 && nextRows.every((row) => row.confirmed); + const approvedAt = new Date().toISOString(); + const nextConfirmations = { + ...documentWorkspaceConfirmations, + [PRODUCT_FACTORY_WORKSPACE_CATEGORY]: allRowsConfirmed, + }; + const nextApprovedAt = { ...documentWorkspaceApprovedAt }; + if (allRowsConfirmed) { + nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY] = approvedAt; + } else { + delete nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY]; + } + + setFactoryRows(nextRows); + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); + if (documentWorkspaceRevisionSignatureRef.current) { + documentWorkspaceRevisionSignatureRef.current = { + ...documentWorkspaceRevisionSignatureRef.current, + [PRODUCT_FACTORY_WORKSPACE_CATEGORY]: JSON.stringify(nextRows), + }; + } + await persistEditedProduct({ + successMessage: allRowsConfirmed + ? "产品定厂已确认" + : "当前批次产品定厂已确认", + errorFallbackMessage: "产品定厂确认保存失败", + overrides: { + factoryRows: nextRows, + documentWorkspaceConfirmations: nextConfirmations, + documentWorkspaceApprovedAt: nextApprovedAt, + }, + }); + return; + } + setSaveMessage(statusMessage.message); setSaveMessageType(statusMessage.type); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/useProductWorkflowRowController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/useProductWorkflowRowController.ts index 760071d5..44f90cb7 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/useProductWorkflowRowController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-workflow/useProductWorkflowRowController.ts @@ -20,7 +20,10 @@ import type { NonIntegratedSkuItem, } from "../../nonIntegratedStorage"; import type { FinishedSampleShipmentEditableKey } from "../basic-info/skuFieldConfig"; -import { PRODUCT_PRICING_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig"; +import { + PRODUCT_PRICING_WORKSPACE_CATEGORY, + SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, +} from "../documents/documentWorkspaceConfig"; import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers"; import { updateFirstQualityRowsForSku, @@ -77,7 +80,7 @@ type UseProductWorkflowRowControllerParams = { documentWorkspaceRevisionSignatureRef: MutableRefObject< Record | null >; - isProductFactorySkuComplete: (skuId: string) => boolean; + isProductFactorySkuConfirmed: (skuId: string) => boolean; isProductLimitNodeLocked: boolean; isProductPricingNodeLocked: boolean; isReadOnly: boolean; @@ -89,6 +92,8 @@ type UseProductWorkflowRowControllerParams = { planningLayoutRowsRef: MutableRefObject; pricingRows: NonIntegratedPricingRow[]; productId?: string; + productSampleMeasurementRows: NonIntegratedSampleMeasurementRow[]; + sampleMeasurementRows: NonIntegratedSampleMeasurementRow[]; scheduleAutoSave: (overrides?: PersistEditedProductOverrides) => void; setDocumentWorkspaceApprovedAt: Dispatch< SetStateAction @@ -128,7 +133,7 @@ export function useProductWorkflowRowController({ documentWorkspaceApprovedAt, documentWorkspaceConfirmations, documentWorkspaceRevisionSignatureRef, - isProductFactorySkuComplete, + isProductFactorySkuConfirmed, isProductLimitNodeLocked, isProductPricingNodeLocked, isReadOnly, @@ -140,6 +145,8 @@ export function useProductWorkflowRowController({ planningLayoutRowsRef, pricingRows, productId, + productSampleMeasurementRows, + sampleMeasurementRows, scheduleAutoSave, setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations, @@ -233,16 +240,25 @@ export function useProductWorkflowRowController({ }); const nextProductPricingCanBeConfirmed = canConfirmProductPricingRows(nextPricingRows); + const nextSampleMeasurementCanBeConfirmedByPricing = + nextProductPricingCanBeConfirmed && + productSampleMeasurementRows.length > 0; const nextApprovedAt = nextProductPricingCanBeConfirmed ? { ...documentWorkspaceApprovedAt, [PRODUCT_PRICING_WORKSPACE_CATEGORY]: pushedAt, + ...(nextSampleMeasurementCanBeConfirmedByPricing + ? { [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: pushedAt } + : {}), } : documentWorkspaceApprovedAt; const nextConfirmations = nextProductPricingCanBeConfirmed ? { ...documentWorkspaceConfirmations, [PRODUCT_PRICING_WORKSPACE_CATEGORY]: true, + ...(nextSampleMeasurementCanBeConfirmedByPricing + ? { [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: true } + : {}), } : documentWorkspaceConfirmations; const pushSuccessMessage = @@ -257,6 +273,12 @@ export function useProductWorkflowRowController({ ...documentWorkspaceRevisionSignatureRef.current, [PRODUCT_PRICING_WORKSPACE_CATEGORY]: JSON.stringify(nextPricingRows), + ...(nextSampleMeasurementCanBeConfirmedByPricing + ? { + [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: + JSON.stringify(sampleMeasurementRows), + } + : {}), }; } } @@ -456,7 +478,7 @@ export function useProductWorkflowRowController({ ) { if ( isReadOnly || - !isProductFactorySkuComplete(skuId) || + !isProductFactorySkuConfirmed(skuId) || isSampleMeasurementSkuLocked(skuId) ) return; diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/SampleMeasurementHeaderActions.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/SampleMeasurementHeaderActions.tsx index 039f526e..689eddf0 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/SampleMeasurementHeaderActions.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/SampleMeasurementHeaderActions.tsx @@ -4,6 +4,7 @@ type SampleMeasurementHeaderActionsProps = { actionGroupClassName: string; rowCount: number; isProductFactoryConfirmed: boolean; + hasPendingRows: boolean; isSampleMeasurementConfirmed: boolean; onStatusClick: () => void; }; @@ -12,6 +13,7 @@ export function SampleMeasurementHeaderActions({ actionGroupClassName, rowCount, isProductFactoryConfirmed, + hasPendingRows, isSampleMeasurementConfirmed, onStatusClick, }: SampleMeasurementHeaderActionsProps) { @@ -19,6 +21,7 @@ export function SampleMeasurementHeaderActions({
); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts index 64e384a3..9a635cda 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/sampleWorkflowHelpers.ts @@ -153,7 +153,7 @@ export function getSampleMeasurementStatusMessage({ } return { - message: "样包测量已完成", + message: "资料已填完核对无误请确认。", type: "success" as const, }; } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/useSampleWorkflowStatusController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/useSampleWorkflowStatusController.ts index f77eb1de..4a2cb5b1 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/useSampleWorkflowStatusController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/sample-workflow/useSampleWorkflowStatusController.ts @@ -1,37 +1,77 @@ import type { Dispatch, + MutableRefObject, SetStateAction, } from "react"; +import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes"; +import type { + NonIntegratedDocumentWorkspaceApprovalMap, + NonIntegratedDocumentWorkspaceConfirmationMap, +} from "../../nonIntegratedStorage"; +import { SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig"; +import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers"; import { getFinishedSampleShipmentStatusMessage, getSampleMeasurementStatusMessage, + normalizeSampleMeasurementRowsForPayload, type ProductFinishedSampleShipmentDisplayRow, type ProductSampleMeasurementDisplayRow, } from "./sampleWorkflowHelpers"; type SaveMessageType = "success" | "error"; +type PersistEditedProduct = (params: { + successMessage: string; + errorFallbackMessage: string; + overrides?: PersistEditedProductOverrides; + suppressSuccessMessage?: boolean; +}) => Promise; + type UseSampleWorkflowStatusControllerParams = { + documentWorkspaceApprovedAt: NonIntegratedDocumentWorkspaceApprovalMap; + documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap; displayedFinishedSampleShipmentRows: ProductFinishedSampleShipmentDisplayRow[]; displayedFinishedSampleShipmentRowsComplete: boolean; displayedProductFactoryIsConfirmed: boolean; displayedProductSampleMeasurementRows: ProductSampleMeasurementDisplayRow[]; + displayedSampleMeasurementIsConfirmed: boolean; displayedSampleMeasurementRowsComplete: boolean; + documentWorkspaceRevisionSignatureRef: MutableRefObject< + Record | null + >; + isReadOnly: boolean; + persistEditedProduct: PersistEditedProduct; + productSampleMeasurementRows: ProductSampleMeasurementDisplayRow[]; + setDocumentWorkspaceApprovedAt: Dispatch< + SetStateAction + >; + setDocumentWorkspaceConfirmations: Dispatch< + SetStateAction + >; setSaveMessage: Dispatch>; setSaveMessageType: Dispatch>; }; export function useSampleWorkflowStatusController({ + documentWorkspaceApprovedAt, + documentWorkspaceConfirmations, displayedFinishedSampleShipmentRows, displayedFinishedSampleShipmentRowsComplete, displayedProductFactoryIsConfirmed, displayedProductSampleMeasurementRows, + displayedSampleMeasurementIsConfirmed, displayedSampleMeasurementRowsComplete, + documentWorkspaceRevisionSignatureRef, + isReadOnly, + persistEditedProduct, + productSampleMeasurementRows, + setDocumentWorkspaceApprovedAt, + setDocumentWorkspaceConfirmations, setSaveMessage, setSaveMessageType, }: UseSampleWorkflowStatusControllerParams) { - function handleSampleMeasurementStatusClick() { + async function handleSampleMeasurementStatusClick() { const statusMessage = getSampleMeasurementStatusMessage({ isFactoryConfirmed: displayedProductFactoryIsConfirmed, isRowsComplete: displayedSampleMeasurementRowsComplete, @@ -39,6 +79,47 @@ export function useSampleWorkflowStatusController({ }); if (!statusMessage) return; + if ( + !isReadOnly && + displayedProductFactoryIsConfirmed && + displayedSampleMeasurementRowsComplete && + !displayedSampleMeasurementIsConfirmed + ) { + const approvedAt = new Date().toISOString(); + const nextConfirmations = { + ...documentWorkspaceConfirmations, + [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: true, + }; + const nextApprovedAt = { + ...documentWorkspaceApprovedAt, + [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: approvedAt, + }; + const nextSampleMeasurementRows = normalizeSampleMeasurementRowsForPayload( + productSampleMeasurementRows, + ); + + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); + if (documentWorkspaceRevisionSignatureRef.current) { + documentWorkspaceRevisionSignatureRef.current = { + ...documentWorkspaceRevisionSignatureRef.current, + [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: JSON.stringify( + nextSampleMeasurementRows, + ), + }; + } + await persistEditedProduct({ + successMessage: "样包测量已确认", + errorFallbackMessage: "样包测量确认保存失败", + overrides: { + sampleMeasurementRows: nextSampleMeasurementRows, + documentWorkspaceConfirmations: nextConfirmations, + documentWorkspaceApprovedAt: nextApprovedAt, + }, + }); + return; + } + setSaveMessage(statusMessage.message); setSaveMessageType(statusMessage.type); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceHeaderActionsSlot.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceHeaderActionsSlot.tsx index 7ee99435..f9e1e4ac 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceHeaderActionsSlot.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceHeaderActionsSlot.tsx @@ -54,6 +54,7 @@ export function DocumentWorkspaceHeaderActionsSlot({ displayedProductPricingRowsComplete, displayedProductPricingRowsReadyForPush, displayedProductSampleMeasurementRowCount, + displayedSampleMeasurementHasPendingRows, displayedSampleMeasurementIsConfirmed, documentWorkspaceConfirmationAction, documentWorkspaceUploadInputRef, @@ -157,6 +158,7 @@ export function DocumentWorkspaceHeaderActionsSlot({ displayedProductPricingRowsComplete: boolean; displayedProductPricingRowsReadyForPush: boolean; displayedProductSampleMeasurementRowCount: number; + displayedSampleMeasurementHasPendingRows: boolean; displayedSampleMeasurementIsConfirmed: boolean; documentWorkspaceConfirmationAction: ReactNode; documentWorkspaceUploadInputRef: RefObject; @@ -262,6 +264,7 @@ export function DocumentWorkspaceHeaderActionsSlot({ isReadOnly={isReadOnly} rowCount={displayedProductFactoryRowCount} hasPendingRows={displayedProductFactoryHasPendingRows} + isConfirmed={displayedProductFactoryIsConfirmed} hasSystemEnteredPricingRows={ displayedProductFactoryHasSystemEnteredPricingRows } @@ -282,6 +285,7 @@ export function DocumentWorkspaceHeaderActionsSlot({ actionGroupClassName={actionGroupClassName} rowCount={displayedProductSampleMeasurementRowCount} isProductFactoryConfirmed={displayedProductFactoryIsConfirmed} + hasPendingRows={displayedSampleMeasurementHasPendingRows} isSampleMeasurementConfirmed={displayedSampleMeasurementIsConfirmed} onStatusClick={onSampleMeasurementStatusClick} /> diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceSidebar.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceSidebar.tsx index e953b106..ce93db11 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceSidebar.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/shared/DocumentWorkspaceSidebar.tsx @@ -10,8 +10,10 @@ import { import { DocumentWorkspaceSidebarStatusIcon } from "../../components/DocumentWorkspaceControls"; import type { NonIntegratedFactoryRow, + NonIntegratedSampleMeasurementRow, NonIntegratedUploadFile, } from "../../nonIntegratedStorage"; +import { SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig"; type DocumentWorkspaceView = "detail" | "document"; @@ -27,9 +29,10 @@ export function DocumentWorkspaceSidebar({ basicInfoNeedsConfirmation, documentWorkspaceCountByCategory, documentWorkspaceView, - isProductFactoryRowComplete, isWorkflowNodeConfirmed, productFactoryRows, + sampleMeasurementRows, + isSampleMeasurementConfirmed, visibleGroupedUploadFiles, onOpenBasicInfo, onOpenDocumentWorkspace, @@ -42,12 +45,13 @@ export function DocumentWorkspaceSidebar({ Record >; documentWorkspaceView: DocumentWorkspaceView; - isProductFactoryRowComplete: (row: NonIntegratedFactoryRow) => boolean; isWorkflowNodeConfirmed: ( category: DocumentWorkspaceCategory, hasContent: boolean, ) => boolean; productFactoryRows: NonIntegratedFactoryRow[]; + sampleMeasurementRows: NonIntegratedSampleMeasurementRow[]; + isSampleMeasurementConfirmed: boolean; visibleGroupedUploadFiles: GroupedWorkspaceFiles[]; onOpenBasicInfo: () => void; onOpenDocumentWorkspace: (category: DocumentWorkspaceCategory) => void; @@ -74,13 +78,14 @@ export function DocumentWorkspaceSidebar({ const categoryHasContent = category === PRODUCT_FACTORY_WORKSPACE_CATEGORY ? productFactoryRows.length > 0 + : category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY + ? sampleMeasurementRows.length > 0 : hasFiles; const categoryNeedsConfirmation = category === PRODUCT_FACTORY_WORKSPACE_CATEGORY - ? !( - productFactoryRows.length > 0 && - productFactoryRows.every(isProductFactoryRowComplete) - ) + ? !productFactoryRows.every((row) => row.confirmed) + : category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY + ? !isSampleMeasurementConfirmed : needsConfirmation; const shouldInsertBasicInfoTrigger = index === basicInfoInsertIndex; const basicInfoIsActive = documentWorkspaceView === "detail"; diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 4db844d6..2d0494ac 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -231,7 +231,8 @@ export default async function IntakePage({ materialLibraryFinishes, materialLibraryCategories, materialLibraryDictionaries, - initialNonIntegratedProductPage, + initialProductNonIntegratedProductPage, + initialStockNonIntegratedProductPage, initialSeriesNonIntegratedProductPage, nodePermissionSettings, initialOpenProduct, @@ -247,10 +248,17 @@ export default async function IntakePage({ page: 1, pageSize: 50, productDevelopmentKind: 'product', + productKind: 'new', }), intakeNonIntegratedProductService.listPage(authUser, { page: 1, - pageSize: 500, + pageSize: 50, + productDevelopmentKind: 'product', + productKind: 'stock', + }), + intakeNonIntegratedProductService.listPage(authUser, { + page: 1, + pageSize: 50, productDevelopmentKind: 'series', }), intakeNodePermissionSettingsService.getSettings(), @@ -263,11 +271,17 @@ export default async function IntakePage({ ]) const initialNonIntegratedProducts = appendInitialOpenProduct( mergeInitialNonIntegratedProducts( - initialNonIntegratedProductPage.products, + initialProductNonIntegratedProductPage.products, + initialStockNonIntegratedProductPage.products, initialSeriesNonIntegratedProductPage.products, ), initialOpenProduct, ) + const initialNonIntegratedPageInfo = { + product: initialProductNonIntegratedProductPage.pageInfo, + stock: initialStockNonIntegratedProductPage.pageInfo, + series: initialSeriesNonIntegratedProductPage.pageInfo, + } const initialOpenProductWorkspace = initialOpenProduct ? { product: initialOpenProduct, @@ -292,43 +306,50 @@ export default async function IntakePage({ ? selectedDetail : null - const eligibleDraftGroupDetails = await Promise.all( - eligibleDraftGroups.map((group) => draftGroupService.getById(group.id)), + const eligibleDraftGroupById = new Map( + eligibleDraftGroups.map((group) => [group.id, group]), ) + const eligiblePreSkus = await draftGroupService.listPreSkusByDraftGroupIds( + eligibleDraftGroups.map((group) => group.id), + ) + const eligiblePreSkuSequenceByDraftGroup = new Map() - const eligibleSkuList = eligibleDraftGroupDetails.flatMap((detail) => - detail.preSkus.map((preSku, index) => { - const skuFields = readPreSkuSkuFields(preSku) - return { - id: preSku.id, - draftGroupId: detail.draftGroup.id, - spuCode: - readPreSkuTextField(preSku, 'spuCode') || - readPreSkuTextField(preSku, 'spu') || - detail.draftGroup.code || - `SPU-${String(index + 1).padStart(3, '0')}`, - code: - skuFields.skuCode || - preSku.customName || - preSku.defaultName, - name: preSku.customName || preSku.defaultName, - seriesName: - readPreSkuTextField(preSku, 'seriesName') || - detail.draftGroup.assetSeriesName || - detail.draftGroup.name, - draftGroupName: detail.draftGroup.name, - status: mapWorkbenchStatus(preSku.status), - componentCodes: preSku.componentCodes ?? [], - componentDetails: readPreSkuComponentDetails(preSku), - packagingPlan: preSku.packagingPlan, - holePoints: buildSkuHolePointsFromVariantSelection(preSku), - skuFields, - model3dPreviewNodes: preSku.previewNodes.length - ? preSku.previewNodes - : preSku.assemblyWorkspace?.nodes ?? [], - } - }), - ) + const eligibleSkuList = eligiblePreSkus.flatMap((preSku) => { + const draftGroup = eligibleDraftGroupById.get(preSku.draftGroupId) + if (!draftGroup) return [] + + const index = eligiblePreSkuSequenceByDraftGroup.get(draftGroup.id) ?? 0 + eligiblePreSkuSequenceByDraftGroup.set(draftGroup.id, index + 1) + const skuFields = readPreSkuSkuFields(preSku) + return { + id: preSku.id, + draftGroupId: draftGroup.id, + spuCode: + readPreSkuTextField(preSku, 'spuCode') || + readPreSkuTextField(preSku, 'spu') || + draftGroup.code || + `SPU-${String(index + 1).padStart(3, '0')}`, + code: + skuFields.skuCode || + preSku.customName || + preSku.defaultName, + name: preSku.customName || preSku.defaultName, + seriesName: + readPreSkuTextField(preSku, 'seriesName') || + draftGroup.assetSeriesName || + draftGroup.name, + draftGroupName: draftGroup.name, + status: mapWorkbenchStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], + componentDetails: readPreSkuComponentDetails(preSku), + packagingPlan: preSku.packagingPlan, + holePoints: buildSkuHolePointsFromVariantSelection(preSku), + skuFields, + model3dPreviewNodes: preSku.previewNodes.length + ? preSku.previewNodes + : preSku.assemblyWorkspace?.nodes ?? [], + } + }) const sourceWorkspace = eligibleSelectedDetail?.preSkus.find((preSku) => preSku.assemblyWorkspace) @@ -363,6 +384,7 @@ export default async function IntakePage({ materialLibraryCategories={materialLibraryCategories} materialLibraryDictionaries={materialLibraryDictionaries} initialNonIntegratedProducts={initialNonIntegratedProducts} + initialNonIntegratedPageInfo={initialNonIntegratedPageInfo} initialNodePermissionOverrides={nodePermissionSettings.overrides} initialActiveSection={initialActiveSection} initialActiveRawMaterialLibrary={initialActiveRawMaterialLibrary} @@ -390,6 +412,7 @@ export default async function IntakePage({ materialLibraryCategories={materialLibraryCategories} materialLibraryDictionaries={materialLibraryDictionaries} initialNonIntegratedProducts={initialNonIntegratedProducts} + initialNonIntegratedPageInfo={initialNonIntegratedPageInfo} initialNodePermissionOverrides={nodePermissionSettings.overrides} initialActiveSection={initialActiveSection} initialActiveRawMaterialLibrary={initialActiveRawMaterialLibrary} diff --git a/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx b/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx index a49cb548..91aba545 100644 --- a/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx +++ b/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx @@ -4,7 +4,6 @@ import { useRouter } from "next/navigation"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react"; import Button from "@/components/ui/Button"; -import { Badge } from "@/components/ui/Badge"; import { CopyProductDialog, ImagePreviewDialog, @@ -33,6 +32,7 @@ import { import type { ProductDevelopmentKind, WorkspaceProductMode } from "@/app/intake/intakeWorkspaceTypes"; import { buildNonIntegratedListStateStorageKey, + createDefaultNonIntegratedListQuery, getUniqueSortedStrings, normalizeNonIntegratedListQuery, normalizePersistedPage, @@ -102,8 +102,39 @@ const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-95 const INTAKE_PRIMARY_BUTTON_CLASS = "!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400"; +function isDefaultInitialListRequest(params: { + activeQuickScope: NonIntegratedQuickScope; + appliedQuery: NonIntegratedListQuery; + currentPage: number; + effectiveProductKind: NonIntegratedProductKindFilter; + initialPageInfo?: NonIntegratedProductPageInfo | null; + initialProductKind: NonIntegratedProductKindFilter; +}) { + const { + activeQuickScope, + appliedQuery, + currentPage, + effectiveProductKind, + initialPageInfo, + initialProductKind, + } = params; + + if (!initialPageInfo) return false; + if (activeQuickScope !== "all") return false; + if (currentPage !== initialPageInfo.page) return false; + if (initialPageInfo.pageSize !== PENDING_PAGE_SIZE) return false; + if (effectiveProductKind !== initialProductKind) return false; + + const defaultQuery = createDefaultNonIntegratedListQuery(initialProductKind); + return Object.entries(defaultQuery).every( + ([key, value]) => + appliedQuery[key as keyof NonIntegratedListQuery] === value, + ); +} + export function NonIntegratedPendingDetail({ initialProducts, + initialPageInfo, canDeleteProducts, currentUser, productDevelopmentKind = "product", @@ -117,6 +148,7 @@ export function NonIntegratedPendingDetail({ onOpenSecondaryNewNonIntegratedProductWorkspace, }: { initialProducts: NonIntegratedProductRecord[]; + initialPageInfo?: NonIntegratedProductPageInfo | null; canDeleteProducts: boolean; currentUser: NonIntegratedListPermissionUser; productDevelopmentKind?: ProductDevelopmentKind; @@ -151,7 +183,7 @@ export function NonIntegratedPendingDetail({ String(normalizePersistedPage(initialPersistedListState?.currentPage)), ); const [pageInfo, setPageInfo] = - useState(null); + useState(initialPageInfo ?? null); const [isListLoading, setIsListLoading] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(""); const [previewProduct, setPreviewProduct] = useState<{ @@ -187,6 +219,7 @@ export function NonIntegratedPendingDetail({ useState(false); const listStateStorageKeyRef = useRef(listStateStorageKey); const skipNextListStatePersistRef = useRef(false); + const initialListRequestHandledRef = useRef(false); const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; const effectiveProductKind = fixedProductKind ?? appliedQuery.productKind; const hasCreateAction = Boolean( @@ -310,6 +343,22 @@ export function NonIntegratedPendingDetail({ useEffect(() => { if (!shouldUseSharedNonIntegratedApi) return; + if ( + !initialListRequestHandledRef.current && + isDefaultInitialListRequest({ + activeQuickScope, + appliedQuery, + currentPage, + effectiveProductKind, + initialPageInfo, + initialProductKind, + }) + ) { + initialListRequestHandledRef.current = true; + return; + } + + initialListRequestHandledRef.current = true; let cancelled = false; setIsListLoading(true); setListErrorMessage(""); @@ -351,6 +400,8 @@ export function NonIntegratedPendingDetail({ appliedQuery, currentPage, effectiveProductKind, + initialPageInfo, + initialProductKind, productDevelopmentKind, shouldUseSharedNonIntegratedApi, ]); @@ -847,8 +898,8 @@ export function NonIntegratedPendingDetail({ (isSeriesDevelopmentList ? 1 : 0); return ( -
-
+
+
{quickScopes.map((scope) => ( @@ -871,14 +922,14 @@ export function NonIntegratedPendingDetail({ ))}
{filterFields.map((filter) => (
- {isProductFactoryRowComplete(row) - ? "已完成" - : "当前资料未填完"} + {row.confirmed + ? "已确认" + : isProductFactoryRowComplete(row) + ? "资料已填完" + : "当前资料未填完"} {row.model || "-"}
{ + await onDeleteProduct() + }} title="删除产品" description={ deletingProduct @@ -431,7 +433,9 @@ export default function HomeMobileView({ { + await onDeleteSKU() + }} title="删除 SKU" description={ deletingSku diff --git a/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx b/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx index 929928d2..9d93cb68 100644 --- a/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx +++ b/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx @@ -46,6 +46,7 @@ type SpuQualityGroup = { } const CONCLUSION_OPTIONS = ['待检', '合格', '让步接收', '返工', '不合格'] as const +const INITIAL_SPU_GROUP_RENDER_LIMIT = 80 function createEmptyForm(inspectorName: string): QualityFormState { return { @@ -256,6 +257,7 @@ export default function WarehouseQualityClient({ const [isSaving, setIsSaving] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [message, setMessage] = useState('') + const [showAllGroups, setShowAllGroups] = useState(false) const filteredArchives = useMemo(() => { const keyword = search.trim().toLowerCase() @@ -278,11 +280,32 @@ export default function WarehouseQualityClient({ selectedGroup?.skuArchives[0] ?? null + const visibleSpuGroups = useMemo(() => { + if (showAllGroups || spuGroups.length <= INITIAL_SPU_GROUP_RENDER_LIMIT) { + return spuGroups + } + + const initialGroups = spuGroups.slice(0, INITIAL_SPU_GROUP_RENDER_LIMIT) + if ( + selectedGroup && + !initialGroups.some((group) => group.key === selectedGroup.key) + ) { + return [...initialGroups, selectedGroup] + } + + return initialGroups + }, [selectedGroup, showAllGroups, spuGroups]) + const hiddenSpuGroupCount = Math.max(0, spuGroups.length - visibleSpuGroups.length) + const records = useMemo( () => sortRecordsForDisplay(selectedArchive?.records ?? []), [selectedArchive], ) + useEffect(() => { + setShowAllGroups(false) + }, [search]) + useEffect(() => { if (!selectedGroup && spuGroups[0]) { setSelectedGroupKey(spuGroups[0].key) @@ -420,7 +443,7 @@ export default function WarehouseQualityClient({ ) : (
- {spuGroups.map((group) => { + {visibleSpuGroups.map((group) => { const isActive = group.key === selectedGroup?.key return ( + ) : null}
)} diff --git a/furniture-3d-system/src/app/warehouse-quality/loading.tsx b/furniture-3d-system/src/app/warehouse-quality/loading.tsx new file mode 100644 index 00000000..928c3b25 --- /dev/null +++ b/furniture-3d-system/src/app/warehouse-quality/loading.tsx @@ -0,0 +1,44 @@ +const GROUPS = Array.from({ length: 9 }, (_, index) => index) +const ROWS = Array.from({ length: 6 }, (_, index) => index) + +export default function WarehouseQualityLoading() { + return ( +
+ +
+
+
+
+
+
+
+
+ {ROWS.map((row) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+
+ ) +} diff --git a/furniture-3d-system/src/components/layout/AppTopNav.tsx b/furniture-3d-system/src/components/layout/AppTopNav.tsx index dd3d1d62..3734e234 100644 --- a/furniture-3d-system/src/components/layout/AppTopNav.tsx +++ b/furniture-3d-system/src/components/layout/AppTopNav.tsx @@ -57,6 +57,7 @@ interface AppTopNavProps { export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) { const pathname = usePathname() ?? '/' const [mobileWorkbenchNavOpen, setMobileWorkbenchNavOpen] = useState(false) + const [pendingHref, setPendingHref] = useState(null) if (variant === 'workbench') { const shouldRestrictNavigation = @@ -68,6 +69,21 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) { const activeNavItem = visibleNavItems.find((item) => matchesWorkbenchHref(pathname, item.href)) ?? visibleNavItems[0] + const visiblePendingHref = + pendingHref && !matchesWorkbenchHref(pathname, pendingHref) + ? pendingHref + : null + const pendingNavItem = visiblePendingHref + ? visibleNavItems.find((item) => item.href === visiblePendingHref) + : null + + function handleNavIntent(href: string) { + if (matchesWorkbenchHref(pathname, href)) { + setPendingHref(null) + return + } + setPendingHref(href) + } return ( ) } diff --git a/furniture-3d-system/src/lib/open-product/asset-sku-group-legacy.ts b/furniture-3d-system/src/lib/open-product/asset-sku-group-legacy.ts new file mode 100644 index 00000000..50e58218 --- /dev/null +++ b/furniture-3d-system/src/lib/open-product/asset-sku-group-legacy.ts @@ -0,0 +1,178 @@ +export const LEGACY_ASSET_SKU_GROUP_KEY = 'legacySkuGroup' +export const LEGACY_ASSET_SKU_GROUPS_KEY = 'legacySkuGroups' + +export interface LegacyAssetSkuGroupSnapshot { + id: string + assetFamilyId: string | null + code: string + name: string + description: string | null + sortOrder: number + metadata: Record | null +} + +export function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readText(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function readNullableText(value: unknown): string | null { + const text = readText(value) + return text || null +} + +function readSortOrder(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value) + if (typeof value === 'string' && value.trim()) { + const parsed = Number.parseInt(value.trim(), 10) + if (Number.isFinite(parsed)) return parsed + } + return fallback +} + +export function buildLegacyAssetSkuGroupId(assetFamilyId: string, code: string): string { + const normalizedFamilyId = assetFamilyId.trim() + const normalizedCode = code.trim().toUpperCase() + return `legacy-sku-group:${normalizedFamilyId}:${normalizedCode}` +} + +export function normalizeLegacyAssetSkuGroupSnapshot( + value: unknown, + fallback: { + assetFamilyId?: string | null + id?: string | null + code?: string | null + name?: string | null + sortOrder?: number + metadata?: Record | null + } = {}, +): LegacyAssetSkuGroupSnapshot | null { + const record = isPlainRecord(value) ? value : {} + const assetFamilyId = readText(record.assetFamilyId) || fallback.assetFamilyId?.trim() || null + const code = (readText(record.code) || fallback.code?.trim() || '').toUpperCase() + if (!code) return null + + const id = + readText(record.id) || + fallback.id?.trim() || + (assetFamilyId ? buildLegacyAssetSkuGroupId(assetFamilyId, code) : `legacy-sku-group:${code}`) + const name = readText(record.name) || fallback.name?.trim() || code + const metadata = isPlainRecord(record.metadata) + ? { ...record.metadata } + : fallback.metadata + ? { ...fallback.metadata } + : null + + return { + id, + assetFamilyId, + code, + name, + description: readNullableText(record.description), + sortOrder: readSortOrder(record.sortOrder, fallback.sortOrder ?? Number.MAX_SAFE_INTEGER), + metadata, + } +} + +export function createLegacyAssetSkuGroupSnapshot(input: { + assetFamilyId: string + code: string + name?: string | null + description?: string | null + sortOrder?: number + metadata?: Record | null + id?: string | null +}): LegacyAssetSkuGroupSnapshot { + const assetFamilyId = input.assetFamilyId.trim() + const code = input.code.trim().toUpperCase() + const id = input.id?.trim() || buildLegacyAssetSkuGroupId(assetFamilyId, code) + return { + id, + assetFamilyId, + code, + name: input.name?.trim() || code, + description: input.description?.trim() || null, + sortOrder: input.sortOrder ?? Number.MAX_SAFE_INTEGER, + metadata: input.metadata ? { ...input.metadata } : null, + } +} + +export function readLegacyAssetSkuGroupFromBusinessData( + businessData: unknown, + fallback: Parameters[1] = {}, +): LegacyAssetSkuGroupSnapshot | null { + if (!isPlainRecord(businessData)) return null + const rawSnapshot = businessData[LEGACY_ASSET_SKU_GROUP_KEY] + if (!isPlainRecord(rawSnapshot)) return null + return normalizeLegacyAssetSkuGroupSnapshot(rawSnapshot, fallback) +} + +export function readLegacyAssetSkuGroupsFromFamilyMetadata( + metadata: unknown, + assetFamilyId?: string | null, +): LegacyAssetSkuGroupSnapshot[] { + if (!isPlainRecord(metadata) || !Array.isArray(metadata[LEGACY_ASSET_SKU_GROUPS_KEY])) { + return [] + } + + return metadata[LEGACY_ASSET_SKU_GROUPS_KEY] + .map((item, index) => + normalizeLegacyAssetSkuGroupSnapshot(item, { + assetFamilyId, + sortOrder: index, + }), + ) + .filter((item): item is LegacyAssetSkuGroupSnapshot => item !== null) +} + +export function serializeLegacyAssetSkuGroupSnapshot( + snapshot: LegacyAssetSkuGroupSnapshot, +): Record { + return { + id: snapshot.id, + assetFamilyId: snapshot.assetFamilyId, + code: snapshot.code, + name: snapshot.name, + description: snapshot.description, + sortOrder: snapshot.sortOrder, + metadata: snapshot.metadata, + source: 'AssetSkuGroup.retired', + } +} + +export function writeLegacyAssetSkuGroupToBusinessData( + businessData: unknown, + snapshot: LegacyAssetSkuGroupSnapshot, +): Record { + return { + ...(isPlainRecord(businessData) ? businessData : {}), + [LEGACY_ASSET_SKU_GROUP_KEY]: serializeLegacyAssetSkuGroupSnapshot(snapshot), + } +} + +export function writeLegacyAssetSkuGroupsToFamilyMetadata( + metadata: unknown, + snapshots: LegacyAssetSkuGroupSnapshot[], +): Record | null { + const next = { ...(isPlainRecord(metadata) ? metadata : {}) } + if (snapshots.length > 0) { + const serializedSnapshots = snapshots + .map((snapshot, index) => ({ + ...serializeLegacyAssetSkuGroupSnapshot(snapshot), + sortOrder: snapshot.sortOrder === Number.MAX_SAFE_INTEGER ? index : snapshot.sortOrder, + })) + .sort((left, right) => { + const leftSort = typeof left.sortOrder === 'number' ? left.sortOrder : Number.MAX_SAFE_INTEGER + const rightSort = typeof right.sortOrder === 'number' ? right.sortOrder : Number.MAX_SAFE_INTEGER + return leftSort - rightSort || String(left['code']).localeCompare(String(right['code']), 'zh-CN', { numeric: true }) + }) + next[LEGACY_ASSET_SKU_GROUPS_KEY] = serializedSnapshots + } else { + delete next[LEGACY_ASSET_SKU_GROUPS_KEY] + } + + return Object.keys(next).length > 0 ? next : null +} diff --git a/furniture-3d-system/src/server/repositories/asset-center.repository.ts b/furniture-3d-system/src/server/repositories/asset-center.repository.ts index e44d6a21..c16825c8 100644 --- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts +++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts @@ -1,5 +1,6 @@ -import { AssetFamily, AssetSeries, AssetSkuGroup, AssetVariant, Prisma, Substrate } from '@prisma/client' +import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' +import { prisma as prismaClient } from '@/lib/prisma' import { buildDeletedAssetVariantUniqueValue, buildSoftDeletedAssetVariantBusinessData, @@ -9,6 +10,15 @@ import { getRestorableAssetVariantIdentityKey, } from '@/lib/open-product/asset-variant-soft-delete' import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding' +import { + LegacyAssetSkuGroupSnapshot, + createLegacyAssetSkuGroupSnapshot, + isPlainRecord, + readLegacyAssetSkuGroupFromBusinessData, + readLegacyAssetSkuGroupsFromFamilyMetadata, + writeLegacyAssetSkuGroupToBusinessData, + writeLegacyAssetSkuGroupsToFamilyMetadata, +} from '@/lib/open-product/asset-sku-group-legacy' export interface AssetCenterFinishRelation { id: string @@ -76,7 +86,10 @@ export interface AssetSkuGroupFilter { assetFamilyId?: string } -export interface AssetSkuGroupWithRelations extends AssetSkuGroup { +export interface AssetSkuGroupWithRelations extends LegacyAssetSkuGroupSnapshot { + assetFamilyId: string + createdAt: Date + updatedAt: Date assetFamily: { id: string code: string @@ -99,7 +112,8 @@ export interface AssetVariantWithRelations extends AssetVariant { deletedAt: Date | null assetSeries: { id: string; code: string; name: string } | null } - skuGroup: { + skuGroupId?: string | null + skuGroup?: { id: string assetFamilyId: string code: string @@ -181,17 +195,6 @@ export const assetVariantRelationInclude = { assetSeries: { select: { id: true, code: true, name: true, metadata: true } }, }, }, - skuGroup: { - select: { - id: true, - assetFamilyId: true, - code: true, - name: true, - description: true, - sortOrder: true, - metadata: true, - }, - }, substrate: { select: { id: true, code: true, name: true } }, finish: { select: { @@ -221,21 +224,6 @@ export const assetVariantRelationInclude = { lastModifier: { select: { id: true, name: true } }, } satisfies Prisma.AssetVariantInclude -export const assetSkuGroupRelationInclude = { - assetFamily: { - select: { - id: true, - code: true, - name: true, - type: true, - assetSeriesId: true, - deletedAt: true, - assetSeries: { select: { id: true, code: true, name: true, metadata: true } }, - }, - }, - _count: { select: { variants: true } }, -} satisfies Prisma.AssetSkuGroupInclude - export const substrateRelationInclude = { category: { select: { id: true, name: true } }, environmentGrade: { select: { id: true, name: true } }, @@ -449,107 +437,306 @@ class AssetSeriesRepositoryClass extends BaseRepository< } } -class AssetSkuGroupRepositoryClass extends BaseRepository< - AssetSkuGroup, - Prisma.AssetSkuGroupCreateInput, - Prisma.AssetSkuGroupUpdateInput, - AssetSkuGroupFilter -> { - protected modelName = 'assetSkuGroup' +class AssetSkuGroupRepositoryClass { + private prisma = prismaClient + + private toRecord(input: { + family: AssetFamilyWithRelations + snapshot: LegacyAssetSkuGroupSnapshot + variantCount?: number + }): AssetSkuGroupWithRelations { + return { + ...input.snapshot, + assetFamilyId: input.family.id, + assetFamily: { + id: input.family.id, + code: input.family.code, + name: input.family.name, + type: input.family.type, + assetSeriesId: input.family.assetSeriesId, + deletedAt: input.family.deletedAt, + assetSeries: input.family.assetSeries + ? { + id: input.family.assetSeries.id, + code: input.family.assetSeries.code, + name: input.family.assetSeries.name, + } + : null, + }, + _count: { variants: input.variantCount ?? 0 }, + createdAt: input.family.createdAt, + updatedAt: input.family.updatedAt, + } + } + + private sortRecords(records: AssetSkuGroupWithRelations[]): AssetSkuGroupWithRelations[] { + return [...records].sort( + (left, right) => + left.assetFamilyId.localeCompare(right.assetFamilyId) || + left.sortOrder - right.sortOrder || + left.code.localeCompare(right.code, 'zh-CN', { numeric: true }), + ) + } + + private getVariantSnapshot(record: AssetVariantWithRelations): LegacyAssetSkuGroupSnapshot | null { + return readLegacyAssetSkuGroupFromBusinessData(record.businessData, { + assetFamilyId: record.assetFamilyId, + code: record.code, + name: record.name, + id: `variant:${record.id}`, + sortOrder: Number.MAX_SAFE_INTEGER, + }) + } async findByIdWithRelations(id: string): Promise { - return this.prisma.assetSkuGroup.findUnique({ - where: { id }, - include: assetSkuGroupRelationInclude, - }) as Promise + const families = await this.prisma.assetFamily.findMany({ + where: { deletedAt: null }, + include: assetFamilyRelationInclude, + }) as AssetFamilyWithRelations[] + + for (const family of families) { + const snapshot = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id) + .find((group) => group.id === id) ?? null + if (!snapshot) continue + + const variantCount = await this.prisma.assetVariant.count({ + where: { + assetFamilyId: family.id, + deletedAt: null, + businessData: { + path: ['legacySkuGroup', 'id'], + equals: id, + } as Prisma.JsonFilter, + }, + }) + return this.toRecord({ family, snapshot, variantCount }) + } + + const variant = await this.prisma.assetVariant.findFirst({ + where: { + deletedAt: null, + businessData: { + path: ['legacySkuGroup', 'id'], + equals: id, + } as Prisma.JsonFilter, + assetFamily: { is: { deletedAt: null } }, + }, + include: assetVariantRelationInclude, + }) as AssetVariantWithRelations | null + if (!variant) return null + + const snapshot = this.getVariantSnapshot(variant) + return snapshot + ? this.toRecord({ family: variant.assetFamily as unknown as AssetFamilyWithRelations, snapshot, variantCount: 1 }) + : null } async findByFamilyAndCode( assetFamilyId: string, code: string, ): Promise { - return this.prisma.assetSkuGroup.findUnique({ - where: { - assetFamilyId_code: { - assetFamilyId, - code: normalizeCoreIdentity(code), - }, - }, - include: assetSkuGroupRelationInclude, - }) as Promise + const normalizedCode = normalizeCoreIdentity(code) + if (!assetFamilyId || !normalizedCode) return null + + const groups = await this.findManyByFamilyIds([assetFamilyId]) + return groups.find((group) => normalizeCoreIdentity(group.code) === normalizedCode) ?? null } async findManyByFamilyIds(assetFamilyIds: string[]): Promise { - if (assetFamilyIds.length === 0) return [] + const ids = Array.from(new Set(assetFamilyIds.map((id) => id.trim()).filter(Boolean))) + if (ids.length === 0) return [] - return this.prisma.assetSkuGroup.findMany({ - where: { - assetFamilyId: { in: assetFamilyIds }, - assetFamily: { is: { deletedAt: null } }, - }, - orderBy: [ - { assetFamilyId: 'asc' }, - { sortOrder: 'asc' }, - { code: 'asc' }, - ], - include: assetSkuGroupRelationInclude, - }) as Promise + const [families, variants] = await Promise.all([ + this.prisma.assetFamily.findMany({ + where: { id: { in: ids }, deletedAt: null }, + include: assetFamilyRelationInclude, + }) as Promise, + this.prisma.assetVariant.findMany({ + where: { + assetFamilyId: { in: ids }, + deletedAt: null, + assetFamily: { is: { deletedAt: null } }, + }, + include: assetVariantRelationInclude, + }) as Promise, + ]) + + const familyById = new Map(families.map((family) => [family.id, family])) + const variantCountByGroupId = new Map() + const snapshotById = new Map() + + variants.forEach((variant) => { + const snapshot = this.getVariantSnapshot(variant) + if (!snapshot) return + snapshotById.set(snapshot.id, snapshot) + variantCountByGroupId.set(snapshot.id, (variantCountByGroupId.get(snapshot.id) ?? 0) + 1) + }) + + families.forEach((family) => { + readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id).forEach((snapshot) => { + snapshotById.set(snapshot.id, snapshot) + }) + }) + + const records = Array.from(snapshotById.values()) + .map((snapshot) => { + const familyId = snapshot.assetFamilyId ?? '' + const family = familyById.get(familyId) + return family + ? this.toRecord({ + family, + snapshot, + variantCount: variantCountByGroupId.get(snapshot.id) ?? 0, + }) + : null + }) + .filter((item): item is AssetSkuGroupWithRelations => item !== null) + + return this.sortRecords(records) } async findManyBySeriesCode(seriesCode: string): Promise { const normalizedSeriesCode = normalizeCoreIdentity(seriesCode) if (!normalizedSeriesCode) return [] - return this.prisma.assetSkuGroup.findMany({ + const families = await this.prisma.assetFamily.findMany({ where: { - assetFamily: { - is: { - deletedAt: null, - assetSeries: { - is: { code: normalizedSeriesCode }, - }, - }, - }, + deletedAt: null, + assetSeries: { is: { code: normalizedSeriesCode } }, }, - orderBy: [ - { assetFamilyId: 'asc' }, - { sortOrder: 'asc' }, - { code: 'asc' }, - ], - include: assetSkuGroupRelationInclude, - }) as Promise + select: { id: true }, + }) + return this.findManyByFamilyIds(families.map((family) => family.id)) } - async createWithRelations(data: Prisma.AssetSkuGroupCreateInput): Promise { - return this.prisma.assetSkuGroup.create({ - data, - include: assetSkuGroupRelationInclude, - }) as Promise + async createWithRelations(data: { + assetFamily: { connect: { id: string } } + code: string + name: string + description?: string | null + sortOrder?: number + metadata?: Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | null + }): Promise { + const familyId = data.assetFamily.connect.id + const family = await assetFamilyRepository.findWithRelationsById(familyId) + if (!family) { + throw new Error('Asset family not found') + } + + const snapshot = createLegacyAssetSkuGroupSnapshot({ + assetFamilyId: family.id, + code: data.code, + name: data.name, + description: data.description, + sortOrder: data.sortOrder, + metadata: isPlainRecord(data.metadata) ? data.metadata : null, + }) + const existing = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id) + .filter((group) => group.id !== snapshot.id) + const metadata = writeLegacyAssetSkuGroupsToFamilyMetadata(family.metadata, [...existing, snapshot]) + + const updatedFamily = await this.prisma.assetFamily.update({ + where: { id: family.id }, + data: { metadata: metadata === null ? Prisma.JsonNull : metadata as Prisma.InputJsonValue }, + include: assetFamilyRelationInclude, + }) as AssetFamilyWithRelations + + return this.toRecord({ family: updatedFamily, snapshot }) } async updateWithRelations( id: string, - data: Prisma.AssetSkuGroupUpdateInput, + data: { + code?: string + name?: string + description?: string | null + sortOrder?: number + metadata?: Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | null + }, ): Promise { - return this.prisma.assetSkuGroup.update({ - where: { id }, - data, - include: assetSkuGroupRelationInclude, - }) as Promise + const existing = await this.findByIdWithRelations(id) + if (!existing) { + throw new Error('Asset SKU group not found') + } + const family = await assetFamilyRepository.findWithRelationsById(existing.assetFamilyId) + if (!family) { + throw new Error('Asset family not found') + } + + const nextSnapshot = createLegacyAssetSkuGroupSnapshot({ + assetFamilyId: family.id, + id: existing.id, + code: data.code ?? existing.code, + name: data.name ?? existing.name, + description: data.description === undefined ? existing.description : data.description, + sortOrder: data.sortOrder ?? existing.sortOrder, + metadata: data.metadata === undefined + ? existing.metadata + : isPlainRecord(data.metadata) + ? data.metadata + : null, + }) + const groups = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id) + .filter((group) => group.id !== id) + const metadata = writeLegacyAssetSkuGroupsToFamilyMetadata(family.metadata, [...groups, nextSnapshot]) + + const variants = await this.prisma.assetVariant.findMany({ + where: { + assetFamilyId: family.id, + deletedAt: null, + businessData: { + path: ['legacySkuGroup', 'id'], + equals: id, + } as Prisma.JsonFilter, + }, + select: { + id: true, + businessData: true, + }, + }) + + const updatedFamily = await this.prisma.$transaction(async (tx) => { + const nextFamily = await tx.assetFamily.update({ + where: { id: family.id }, + data: { metadata: metadata === null ? Prisma.JsonNull : metadata as Prisma.InputJsonValue }, + include: assetFamilyRelationInclude, + }) + await Promise.all( + variants.map((variant) => + tx.assetVariant.update({ + where: { id: variant.id }, + data: { + businessData: writeLegacyAssetSkuGroupToBusinessData( + variant.businessData, + nextSnapshot, + ) as Prisma.InputJsonValue, + }, + }), + ), + ) + return nextFamily + }) as AssetFamilyWithRelations + + return this.toRecord({ family: updatedFamily, snapshot: nextSnapshot }) } async deleteById(id: string): Promise { - await this.prisma.assetSkuGroup.delete({ where: { id } }) - } + const existing = await this.findByIdWithRelations(id) + if (!existing) return + const family = await assetFamilyRepository.findWithRelationsById(existing.assetFamilyId) + if (!family) return - protected buildWhereClause(filter?: AssetSkuGroupFilter): Prisma.AssetSkuGroupWhereInput { - return { - assetFamilyId: filter?.assetFamilyId, - assetFamily: { is: { deletedAt: null } }, - } + const groups = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id) + .filter((group) => group.id !== id) + const metadata = writeLegacyAssetSkuGroupsToFamilyMetadata(family.metadata, groups) + await this.prisma.assetFamily.update({ + where: { id: family.id }, + data: { metadata: metadata === null ? Prisma.JsonNull : metadata as Prisma.InputJsonValue }, + }) } } + class AssetVariantRepositoryClass extends BaseRepository< AssetVariant, Prisma.AssetVariantCreateInput, @@ -558,6 +745,42 @@ class AssetVariantRepositoryClass extends BaseRepository< > { protected modelName = 'assetVariant' + private attachLegacySkuGroup(record: AssetVariantWithRelations): AssetVariantWithRelations { + const snapshot = readLegacyAssetSkuGroupFromBusinessData(record.businessData, { + assetFamilyId: record.assetFamilyId, + code: record.code, + name: record.name, + id: `variant:${record.id}`, + sortOrder: Number.MAX_SAFE_INTEGER, + }) + + if (!snapshot) { + return { + ...record, + skuGroupId: null, + skuGroup: null, + } + } + + return { + ...record, + skuGroupId: snapshot.id, + skuGroup: { + id: snapshot.id, + assetFamilyId: snapshot.assetFamilyId ?? record.assetFamilyId, + code: snapshot.code, + name: snapshot.name, + description: snapshot.description, + sortOrder: snapshot.sortOrder, + metadata: snapshot.metadata as Prisma.JsonValue | null, + }, + } + } + + private attachLegacySkuGroups(records: AssetVariantWithRelations[]): AssetVariantWithRelations[] { + return records.map((record) => this.attachLegacySkuGroup(record)) + } + async findByCode(code: string): Promise { return this.prisma.assetVariant.findFirst({ where: { @@ -668,16 +891,17 @@ class AssetVariantRepositoryClass extends BaseRepository< } async findByIdWithRelations(id: string): Promise { - return this.prisma.assetVariant.findUnique({ + const record = await this.prisma.assetVariant.findUnique({ where: { id }, include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations | null + return record ? this.attachLegacySkuGroup(record) : null } async findByIdsWithRelations(ids: string[]): Promise { if (ids.length === 0) return [] - return this.prisma.assetVariant.findMany({ + const records = await this.prisma.assetVariant.findMany({ where: { id: { in: ids }, deletedAt: null, @@ -685,19 +909,21 @@ class AssetVariantRepositoryClass extends BaseRepository< }, orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }], include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations[] + return this.attachLegacySkuGroups(records) } async findByIdsWithRelationsIncludingDeleted(ids: string[]): Promise { if (ids.length === 0) return [] - return this.prisma.assetVariant.findMany({ + const records = await this.prisma.assetVariant.findMany({ where: { id: { in: ids }, }, orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }], include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations[] + return this.attachLegacySkuGroups(records) } async findManyByFamilyIds( @@ -706,7 +932,7 @@ class AssetVariantRepositoryClass extends BaseRepository< ): Promise { if (assetFamilyIds.length === 0) return [] - return this.prisma.assetVariant.findMany({ + const records = await this.prisma.assetVariant.findMany({ where: { assetFamilyId: { in: assetFamilyIds }, status: filter?.status, @@ -715,13 +941,14 @@ class AssetVariantRepositoryClass extends BaseRepository< }, orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }], include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations[] + return this.attachLegacySkuGroups(records) } async findAvailableByFamilyIds(assetFamilyIds: string[]): Promise { if (assetFamilyIds.length === 0) return [] - return this.prisma.assetVariant.findMany({ + const records = await this.prisma.assetVariant.findMany({ where: { assetFamilyId: { in: assetFamilyIds }, status: 'AVAILABLE', @@ -730,14 +957,15 @@ class AssetVariantRepositoryClass extends BaseRepository< }, orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }], include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations[] + return this.attachLegacySkuGroups(records) } async findActiveBySeriesCode(seriesCode: string): Promise { const normalizedSeriesCode = seriesCode.trim().toUpperCase() if (!normalizedSeriesCode) return [] - return this.prisma.assetVariant.findMany({ + const records = await this.prisma.assetVariant.findMany({ where: { deletedAt: null, assetFamily: { @@ -751,25 +979,28 @@ class AssetVariantRepositoryClass extends BaseRepository< }, orderBy: [{ code: 'asc' }, { updatedAt: 'asc' }], include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations[] + return this.attachLegacySkuGroups(records) } async createWithRelations(data: Prisma.AssetVariantCreateInput): Promise { - return this.prisma.assetVariant.create({ + const record = await this.prisma.assetVariant.create({ data, include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations + return this.attachLegacySkuGroup(record) } async updateWithRelations( id: string, data: Prisma.AssetVariantUpdateInput, ): Promise { - return this.prisma.assetVariant.update({ + const record = await this.prisma.assetVariant.update({ where: { id }, data, include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations + return this.attachLegacySkuGroup(record) } async deleteById(id: string): Promise { @@ -777,14 +1008,15 @@ class AssetVariantRepositoryClass extends BaseRepository< } async restoreById(id: string): Promise { - return this.prisma.assetVariant.update({ + const record = await this.prisma.assetVariant.update({ where: { id }, data: { deletedAt: null, deleter: { disconnect: true }, }, include: assetVariantRelationInclude, - }) as Promise + }) as AssetVariantWithRelations + return this.attachLegacySkuGroup(record) } protected buildWhereClause(filter?: AssetVariantFilter): Prisma.AssetVariantWhereInput { diff --git a/furniture-3d-system/src/server/repositories/draft-group.repository.ts b/furniture-3d-system/src/server/repositories/draft-group.repository.ts index 488f5e47..465e5b06 100644 --- a/furniture-3d-system/src/server/repositories/draft-group.repository.ts +++ b/furniture-3d-system/src/server/repositories/draft-group.repository.ts @@ -150,6 +150,27 @@ class PreSkuRepositoryClass extends BaseRepository< }) as Promise } + async findByDraftGroupIds(draftGroupIds: string[]): Promise { + if (draftGroupIds.length === 0) return [] + + return this.prisma.preSku.findMany({ + where: { + draftGroupId: { + in: draftGroupIds, + }, + }, + orderBy: [{ draftGroupId: 'asc' }, { sequence: 'asc' }], + include: { + basicInfo: true, + packagingPlan: { + include: { + packagedItems: true, + }, + }, + }, + }) as Promise + } + async findActiveByAssetSeriesId( assetSeriesId: string, excludeDraftGroupId?: string, diff --git a/furniture-3d-system/src/server/repositories/index.ts b/furniture-3d-system/src/server/repositories/index.ts index d8f76302..164af7da 100644 --- a/furniture-3d-system/src/server/repositories/index.ts +++ b/furniture-3d-system/src/server/repositories/index.ts @@ -116,7 +116,6 @@ export { assetFamilyRepository, assetSeriesRepository, assetSkuGroupRepository, - assetSkuGroupRelationInclude, assetVariantRelationInclude, assetVariantRepository, substrateRelationInclude, diff --git a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts index 7411dc52..239890cf 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts @@ -866,15 +866,17 @@ describe('assetCenterService', () => { lossFactor: 1, }) - expect(mockedAssetSkuGroupRepository.createWithRelations).toHaveBeenCalledWith( - expect.objectContaining({ - code: 'DBZ-SPJ03', - }), - ) + expect(mockedAssetSkuGroupRepository.createWithRelations).not.toHaveBeenCalled() expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith( expect.objectContaining({ code: 'DBZ-SPJ03模型1', - skuGroup: { connect: { id: 'sku-group-DBZ-SPJ03' } }, + businessData: expect.objectContaining({ + legacySkuGroup: expect.objectContaining({ + id: 'legacy-sku-group:family-board-top:DBZ-SPJ03', + assetFamilyId: 'family-board-top', + code: 'DBZ-SPJ03', + }), + }), }), ) expect(variant.code).toBe('DBZ-SPJ03模型1') @@ -986,7 +988,13 @@ describe('assetCenterService', () => { expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith( expect.objectContaining({ code: 'DBZ-SPJ02模型3', - skuGroup: { connect: { id: 'sku-group-02' } }, + businessData: expect.objectContaining({ + legacySkuGroup: expect.objectContaining({ + id: 'sku-group-02', + assetFamilyId: 'family-board-top', + code: 'DBZ-SPJ02', + }), + }), }), ) expect(variant.code).toBe('DBZ-SPJ02模型3') @@ -2422,7 +2430,7 @@ describe('assetCenterService', () => { 1, 'variant-board-top-uv', expect.objectContaining({ - businessData: { + businessData: expect.objectContaining({ renderConfig: { uv: { version: 1, @@ -2435,7 +2443,7 @@ describe('assetCenterService', () => { }, }, }, - }, + }), }), ) expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith( @@ -2537,11 +2545,11 @@ describe('assetCenterService', () => { expect(mockedAssetVariantRepository.updateWithRelations).toHaveBeenCalledWith( 'variant-board-top-uv-fast', expect.objectContaining({ - businessData: { + businessData: expect.objectContaining({ renderConfig: { uv: uvConfig, }, - }, + }), }), ) expect(mockedAssetVariantThumbnailService.generateForVariant).not.toHaveBeenCalled() diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts index 3375f979..4c0f13ec 100644 --- a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts @@ -51,6 +51,7 @@ jest.mock('../../repositories', () => ({ createManyForDraftGroup: jest.fn(), findActiveByAssetSeriesId: jest.fn(), findByDraftGroupId: jest.fn(), + findByDraftGroupIds: jest.fn(), findByIdWithRelations: jest.fn(), updateDisplaySummary: jest.fn(), upsertBasicInfo: jest.fn(), @@ -376,6 +377,7 @@ describe('draftGroupService', () => { mockedPreSkuRepository.findByBasicInfoField.mockResolvedValue([]) mockedPreSkuRepository.findByBasicInfoFields.mockResolvedValue([]) mockedPreSkuRepository.findActiveByAssetSeriesId.mockResolvedValue([]) + mockedPreSkuRepository.findByDraftGroupIds.mockResolvedValue([]) mockedIntakeNonIntegratedProductRepository.findActiveSeriesProductByNames.mockResolvedValue(null) mockedSkuRepository.findBySkuCode.mockResolvedValue(null) mockedSkuRepository.findByAssetProfileBarcode.mockResolvedValue(null) @@ -1469,6 +1471,84 @@ describe('draftGroupService', () => { ) }) + it('批量读取草稿组准 SKU 时也会把资产货号对应条码补到部件快照', async () => { + mockedPreSkuRepository.findByDraftGroupIds.mockResolvedValue([ + { + id: 'pre-sku-batch-1', + draftGroupId: 'draft-group-1', + sequence: 1, + defaultName: '单板桌', + customName: null, + status: 'READY_FOR_INTAKE', + sourceCandidateKey: 'candidate-1', + familySelection: {}, + variantSelection: { + slots: [ + { + slotKey: 'desktop', + role: 'desktop', + assetFamilyId: 'family-desktop-a', + variantId: 'variant-desktop-a-white', + code: 'DBZ-SPJ01', + name: '桌面板', + assetType: 'BOARD', + dimensions: { length: 1400, width: 600, thickness: 25 }, + weightKg: 14.5, + costPrice: 210, + modelUrl: '/models/top-a-1200.glb', + mountPoints: [], + assemblyBasis: {}, + }, + ], + hardwareSelections: [], + }, + recommendedHardware: [], + previewNodes: [], + displaySummary: null, + bomPreview: [], + weightPreview: null, + componentCodes: ['DBZ-SPJ01'], + installStepPreview: [], + notes: null, + createdAt: new Date('2026-03-30T12:00:00.000Z'), + updatedAt: new Date('2026-03-30T12:00:00.000Z'), + packagingPlan: null, + }, + ] as never) + mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([ + { + id: 'variant-desktop-a-white', + barcode: 'fallback-barcode', + skuGroup: { + metadata: { + skuContentFields: { + correspondingBarcode: 'DNZ038401N1440V01', + }, + }, + }, + }, + ] as never) + + const result = await draftGroupService.listPreSkusByDraftGroupIds([ + 'draft-group-1', + 'draft-group-1', + ' ', + ]) + + expect(mockedPreSkuRepository.findByDraftGroupIds).toHaveBeenCalledWith([ + 'draft-group-1', + ]) + expect(mockedAssetVariantRepository.findByIdsWithRelations).toHaveBeenCalledWith([ + 'variant-desktop-a-white', + ]) + expect(result[0].variantSelection.slots[0]).toEqual( + expect.objectContaining({ + barcode: 'FALLBACK-BARCODE', + correspondingBarcode: 'DNZ038401N1440V01', + }), + ) + }) + it('读取草稿组详情时优先返回 displaySummary 中的装配状态和 warnings', async () => { mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([ { diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index 3999abe0..abe52600 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -141,6 +141,12 @@ import { normalizeObjectStorageUrlForPersistence, resolveObjectStorageUrlForClientAccess, } from '@/lib/storage' +import { + createLegacyAssetSkuGroupSnapshot, + readLegacyAssetSkuGroupFromBusinessData, + writeLegacyAssetSkuGroupToBusinessData, + type LegacyAssetSkuGroupSnapshot, +} from '@/lib/open-product/asset-sku-group-legacy' interface VariantThumbnailRenderOptions { renderBaseUrl?: string | null @@ -2111,6 +2117,13 @@ function toMaterialLikeDTO(record: AssetCenterFinishRelation): MaterialLikeDTO { function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { const businessData = isRecord(record.businessData) ? record.businessData : null + const legacySkuGroup = readLegacyAssetSkuGroupFromBusinessData(businessData, { + assetFamilyId: record.assetFamilyId, + code: record.code, + name: record.name, + id: `variant:${record.id}`, + sortOrder: Number.MAX_SAFE_INTEGER, + }) const uvConfig = readAssetVariantUvConfig(businessData) const assemblyBasis = recoverAssemblyBasis( isRecord(record.assemblyBasis) ? record.assemblyBasis : null, @@ -2122,11 +2135,11 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { assetFamilyId: record.assetFamilyId, assetFamilyCode: record.assetFamily.code, assetFamilyName: record.assetFamily.name, - skuGroupId: record.skuGroupId, - skuGroupCode: record.skuGroup?.code ?? null, - skuGroupName: record.skuGroup?.name ?? null, - skuGroupSortOrder: record.skuGroup?.sortOrder ?? null, - skuGroupMetadata: isRecord(record.skuGroup?.metadata) ? record.skuGroup.metadata : null, + skuGroupId: legacySkuGroup?.id ?? record.skuGroupId ?? null, + skuGroupCode: legacySkuGroup?.code ?? record.skuGroup?.code ?? null, + skuGroupName: legacySkuGroup?.name ?? record.skuGroup?.name ?? null, + skuGroupSortOrder: legacySkuGroup?.sortOrder ?? record.skuGroup?.sortOrder ?? null, + skuGroupMetadata: legacySkuGroup?.metadata ?? (isRecord(record.skuGroup?.metadata) ? record.skuGroup.metadata : null), assetType: record.assetType, code: record.code, name: record.name, @@ -2409,7 +2422,7 @@ async function ensureAssetSkuGroupForVariant(input: { skuGroupId?: string | null defaultCode: string defaultName: string -}): Promise { +}): Promise { const requestedId = input.skuGroupId?.trim() if (requestedId) { const group = await assetSkuGroupRepository.findByIdWithRelations(requestedId) @@ -2419,26 +2432,42 @@ async function ensureAssetSkuGroupForVariant(input: { if (group.assetFamilyId !== input.assetFamilyId) { throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组必须属于当前型号') } - return group + return createLegacyAssetSkuGroupSnapshot({ + assetFamilyId: group.assetFamilyId, + id: group.id, + code: group.code, + name: group.name, + description: group.description, + sortOrder: group.sortOrder, + metadata: group.metadata, + }) } const code = normalizeAssetSkuGroupCode(input.defaultCode) const existing = await assetSkuGroupRepository.findByFamilyAndCode(input.assetFamilyId, code) - if (existing) return existing + if (existing) { + return createLegacyAssetSkuGroupSnapshot({ + assetFamilyId: existing.assetFamilyId, + id: existing.id, + code: existing.code, + name: existing.name, + description: existing.description, + sortOrder: existing.sortOrder, + metadata: existing.metadata, + }) + } - const maxSortOrder = await prisma.assetSkuGroup.aggregate({ - where: { assetFamilyId: input.assetFamilyId }, - _max: { sortOrder: true }, - }) + const existingGroups = await assetSkuGroupRepository.findManyByFamilyIds([input.assetFamilyId]) + const maxSortOrder = Math.max(0, ...existingGroups.map((group) => group.sortOrder)) - return assetSkuGroupRepository.createWithRelations({ - assetFamily: { connect: { id: input.assetFamilyId } }, + return createLegacyAssetSkuGroupSnapshot({ + assetFamilyId: input.assetFamilyId, code, name: input.defaultName.trim() || code, - sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1, + sortOrder: maxSortOrder + 1, metadata: { source: 'variant-default', - } as Prisma.InputJsonValue, + }, }) } @@ -2452,7 +2481,15 @@ async function ensureVariantBarcodeAvailable( if ( existing && existing.id !== currentId && - (!allowedSkuGroupId || existing.skuGroupId !== allowedSkuGroupId) + ( + !allowedSkuGroupId || + readLegacyAssetSkuGroupFromBusinessData(existing.businessData, { + assetFamilyId: existing.assetFamilyId, + code: existing.code, + name: existing.name, + id: `variant:${existing.id}`, + })?.id !== allowedSkuGroupId + ) ) { throw new AppError(ErrorCode.VALIDATION_FAILED, '条码已存在') } @@ -2912,7 +2949,7 @@ class AssetCenterService { private async generateNextVariantModelCodeForSkuGroup(input: { assetFamilyId: string - skuGroup: AssetSkuGroupWithRelations + skuGroup: Pick currentVariantId?: string | null }): Promise { const variants = await assetVariantRepository.findManyByFamilyIds([input.assetFamilyId]) @@ -3736,11 +3773,8 @@ class AssetCenterService { }) const sortOrder = input.sortOrder ?? await (async () => { - const maxSortOrder = await prisma.assetSkuGroup.aggregate({ - where: { assetFamilyId: family.id }, - _max: { sortOrder: true }, - }) - return (maxSortOrder._max.sortOrder ?? 0) + 1 + const existingGroups = await assetSkuGroupRepository.findManyByFamilyIds([family.id]) + return Math.max(0, ...existingGroups.map((group) => group.sortOrder)) + 1 })() const created = await assetSkuGroupRepository.createWithRelations({ @@ -3806,8 +3840,11 @@ class AssetCenterService { } const activeVariantCount = await prisma.assetVariant.count({ where: { - skuGroupId: id, deletedAt: null, + businessData: { + path: ['legacySkuGroup', 'id'], + equals: id, + } as Prisma.JsonFilter, }, }) if (activeVariantCount > 0) { @@ -4455,7 +4492,7 @@ class AssetCenterService { } const requestedCode = input.code?.trim().toUpperCase() ?? '' - let skuGroup: AssetSkuGroupWithRelations + let skuGroup: LegacyAssetSkuGroupSnapshot let code: string if (family.type === 'PACKAGING') { code = requestedCode || await this.generateNextSkuGroupCodeForFamily(family) @@ -4554,11 +4591,15 @@ class AssetCenterService { dimensions: resolvedDimensions, lossFactor: resolvedLossFactor, }) - const resolvedBusinessData = resolveBusinessDataForVariantCreate({ + const rawResolvedBusinessData = resolveBusinessDataForVariantCreate({ businessData: input.businessData, uvConfig: input.uvConfig, inheritedBusinessData, }) + const resolvedBusinessData = writeLegacyAssetSkuGroupToBusinessData( + rawResolvedBusinessData ?? {}, + skuGroup, + ) const resolvedAssemblyBasis = resolveInheritedJsonRecord( input.assemblyBasis, inheritedVariant?.assemblyBasis, @@ -4582,7 +4623,6 @@ class AssetCenterService { const created = await assetVariantRepository.createWithRelations({ assetFamily: { connect: { id: input.assetFamilyId } }, - skuGroup: { connect: { id: skuGroup.id } }, code, name, identityKey, @@ -4729,9 +4769,14 @@ class AssetCenterService { input.skuGroupId !== undefined ? input.skuGroupId : nextFamilyId === existing.assetFamilyId - ? existing.skuGroupId + ? readLegacyAssetSkuGroupFromBusinessData(existing.businessData, { + assetFamilyId: existing.assetFamilyId, + code: existing.code, + name: existing.name, + id: `variant:${existing.id}`, + })?.id ?? null : null - let nextSkuGroup: AssetSkuGroupWithRelations + let nextSkuGroup: LegacyAssetSkuGroupSnapshot let nextCode: string if (nextFamily.type === 'PACKAGING') { @@ -4835,6 +4880,10 @@ class AssetCenterService { uvConfig: input.uvConfig, }) : undefined + const persistedNextBusinessData = writeLegacyAssetSkuGroupToBusinessData( + nextBusinessData ?? existingBusinessData ?? {}, + nextSkuGroup, + ) const existingUvConfig = readAssetVariantUvConfig(existingBusinessData) const nextUvConfig = input.uvConfig !== undefined @@ -4931,7 +4980,6 @@ class AssetCenterService { input.assetFamilyId === undefined ? undefined : { connect: { id: input.assetFamilyId } }, - skuGroup: { connect: { id: nextSkuGroup.id } }, code: nextCode, name: nextName, identityKey, @@ -4976,7 +5024,7 @@ class AssetCenterService { namingFields: input.namingFields !== undefined ? toJsonValue(input.namingFields) : undefined, businessData: - nextBusinessData !== undefined ? toJsonValue(nextBusinessData) : undefined, + toJsonValue(persistedNextBusinessData), weightKg: input.weightKg === undefined ? undefined : input.weightKg, costPrice: input.costPrice === undefined ? undefined : input.costPrice, lossFactor: input.lossFactor === undefined ? undefined : input.lossFactor, diff --git a/furniture-3d-system/src/server/services/draft-group.service.ts b/furniture-3d-system/src/server/services/draft-group.service.ts index b0e0922d..b8fdce58 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.ts @@ -555,6 +555,17 @@ class DraftGroupService { } } + async listPreSkusByDraftGroupIds(draftGroupIds: string[]): Promise { + const uniqueDraftGroupIds = Array.from( + new Set(draftGroupIds.map((id) => id.trim()).filter(Boolean)), + ) + if (uniqueDraftGroupIds.length === 0) return [] + + const preSkus = await preSkuRepository.findByDraftGroupIds(uniqueDraftGroupIds) + const preSkuDTOs = preSkus.map((item) => toDraftGroupPreSkuDTO(item)) + return this.enrichSelectedVariantBarcodes(preSkuDTOs) + } + private async enrichSelectedVariantBarcodes( preSkus: DraftGroupPreSkuDTO[], ): Promise {