diff --git a/furniture-3d-system/prisma/migrations/20260626104500_align_asset_sku_group_replay_to_schema/migration.sql b/furniture-3d-system/prisma/migrations/20260626104500_align_asset_sku_group_replay_to_schema/migration.sql new file mode 100644 index 00000000..e32fc9fd --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260626104500_align_asset_sku_group_replay_to_schema/migration.sql @@ -0,0 +1,84 @@ +-- 20260613151000 temporarily restored AssetSkuGroup for legacy-data recovery. +-- The current schema deliberately keeps this grouping as JSON compatibility data, +-- so replaying the migration chain must retire the transient relational shape again. + +DO $$ +BEGIN + IF to_regclass('public."AssetSkuGroup"') IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'AssetVariant' + AND column_name = 'skuGroupId' + ) + THEN + EXECUTE $SQL$ + UPDATE "AssetVariant" AS variant + SET "businessData" = jsonb_set( + CASE + WHEN variant."businessData" IS NULL OR jsonb_typeof(variant."businessData") <> 'object' + THEN '{}'::jsonb + ELSE variant."businessData" + END, + '{legacySkuGroup}', + jsonb_build_object( + 'id', sku_group.id, + 'code', sku_group.code, + 'name', sku_group.name, + 'description', sku_group.description, + 'sortOrder', sku_group."sortOrder", + 'metadata', sku_group.metadata, + 'assetFamilyId', sku_group."assetFamilyId", + 'source', 'AssetSkuGroup.replay-aligned' + ), + true + ) + FROM "AssetSkuGroup" AS sku_group + WHERE variant."skuGroupId" = sku_group.id + $SQL$; + + EXECUTE $SQL$ + UPDATE "AssetFamily" AS family + SET metadata = jsonb_set( + CASE + WHEN family.metadata IS NULL OR jsonb_typeof(family.metadata) <> 'object' + THEN '{}'::jsonb + ELSE family.metadata + END, + '{legacySkuGroups}', + grouped.groups, + true + ) + FROM ( + SELECT + "assetFamilyId", + jsonb_agg( + jsonb_build_object( + 'id', id, + 'code', code, + 'name', name, + 'description', description, + 'sortOrder', "sortOrder", + 'metadata', metadata, + 'source', 'AssetSkuGroup.replay-aligned' + ) + ORDER BY "sortOrder", code + ) AS groups + FROM "AssetSkuGroup" + GROUP BY "assetFamilyId" + ) AS grouped + WHERE family.id = grouped."assetFamilyId" + $SQL$; + END IF; +END $$; + +ALTER TABLE "AssetVariant" + DROP CONSTRAINT IF EXISTS "AssetVariant_skuGroupId_fkey"; + +DROP INDEX IF EXISTS "AssetVariant_skuGroupId_idx"; + +ALTER TABLE "AssetVariant" + DROP COLUMN IF EXISTS "skuGroupId"; + +DROP TABLE IF EXISTS "AssetSkuGroup"; diff --git a/furniture-3d-system/prisma/migrations/20260710100000_add_intake_sku_flow_owner/migration.sql b/furniture-3d-system/prisma/migrations/20260710100000_add_intake_sku_flow_owner/migration.sql new file mode 100644 index 00000000..00c1deec --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260710100000_add_intake_sku_flow_owner/migration.sql @@ -0,0 +1,26 @@ +ALTER TABLE "IntakeNonIntegratedSkuItem" +ADD COLUMN "demandFlowNo" TEXT NOT NULL DEFAULT '', +ADD COLUMN "purchaseOwner" TEXT NOT NULL DEFAULT ''; + +UPDATE "IntakeNonIntegratedSkuItem" AS sku +SET + "demandFlowNo" = COALESCE( + NULLIF(BTRIM(product."payload" -> 'skuBatchFlowNos' ->> sku."batchNo"::text), ''), + NULLIF(BTRIM(profile."demandFlowNo"), ''), + '' + ), + "purchaseOwner" = COALESCE( + NULLIF(BTRIM(profile."purchaseOwner"), ''), + NULLIF(BTRIM(product."purchaser"), ''), + '' + ) +FROM "IntakeNonIntegratedProduct" AS product +LEFT JOIN "IntakeNonIntegratedProductProfile" AS profile + ON profile."productId" = product."id" +WHERE sku."productId" = product."id"; + +CREATE INDEX "IntakeNonIntegratedSkuItem_productId_purchaseOwner_idx" +ON "IntakeNonIntegratedSkuItem"("productId", "purchaseOwner"); + +CREATE INDEX "IntakeNonIntegratedSkuItem_demandFlowNo_idx" +ON "IntakeNonIntegratedSkuItem"("demandFlowNo"); diff --git a/furniture-3d-system/prisma/migrations/20260710101500_align_replay_updated_at_defaults/migration.sql b/furniture-3d-system/prisma/migrations/20260710101500_align_replay_updated_at_defaults/migration.sql new file mode 100644 index 00000000..fe7418e1 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260710101500_align_replay_updated_at_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Prisma @updatedAt columns are maintained by application writes, not by a +-- database default. Align fresh migration replay with the current schema and +-- the existing local database. + +ALTER TABLE "AiRenderWorkbenchSku" + ALTER COLUMN "updatedAt" DROP DEFAULT; + +ALTER TABLE "AiRenderWorkbenchSpu" + ALTER COLUMN "updatedAt" DROP DEFAULT; + +ALTER TABLE "BarcodePrintImportRecord" + ALTER COLUMN "updatedAt" DROP DEFAULT; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 9872e11e..26acde08 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -359,6 +359,8 @@ model IntakeNonIntegratedSkuItem { sortOrder Int skuSequence Int @default(0) batchNo Int @default(1) + demandFlowNo String @default("") + purchaseOwner String @default("") packageGroupId String @default("") image String? @db.Text imageName String @default("") @@ -392,6 +394,8 @@ model IntakeNonIntegratedSkuItem { @@unique([productId, skuItemId]) @@index([productId]) + @@index([productId, purchaseOwner]) + @@index([demandFlowNo]) @@index([skuCode]) @@index([barcode]) @@index([model]) diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts index 60780cec..040b6d67 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts @@ -49,6 +49,19 @@ export async function PUT(request: NextRequest, context: RouteContext) { typeof (rawBody as { workspaceCategory?: unknown }).workspaceCategory === 'string' ? ((rawBody as { workspaceCategory: string }).workspaceCategory.trim() || null) : null + const targetSkuIds = + rawBody && + typeof rawBody === 'object' && + Array.isArray((rawBody as { targetSkuIds?: unknown }).targetSkuIds) + ? Array.from( + new Set( + (rawBody as { targetSkuIds: unknown[] }).targetSkuIds + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean), + ), + ) + : [] const formalSpuAssignmentProductCategory = rawBody && typeof rawBody === 'object' && @@ -80,7 +93,12 @@ export async function PUT(request: NextRequest, context: RouteContext) { id: decodedId, }, ctx.user, - { workspaceCategory, formalSpuAssignmentProductCategory, existingFormalSpu }, + { + workspaceCategory, + targetSkuIds, + formalSpuAssignmentProductCategory, + existingFormalSpu, + }, ) return jsonSuccess({ product: savedProduct }, 200, ctx.requestId) } catch (error) { diff --git a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts index 067fb06e..98ab9978 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts @@ -434,6 +434,7 @@ export async function saveSharedNonIntegratedProduct( product: NonIntegratedProductRecord, options: { workspaceCategory?: string | null + targetSkuIds?: string[] formalSpuAssignmentProductCategory?: string | null existingFormalSpu?: string | null } = {}, @@ -452,6 +453,9 @@ export async function saveSharedNonIntegratedProduct( ...(options.workspaceCategory ? { workspaceCategory: options.workspaceCategory } : {}), + ...(options.targetSkuIds?.length + ? { targetSkuIds: options.targetSkuIds } + : {}), ...(options.formalSpuAssignmentProductCategory ? { formalSpuAssignmentProductCategory: diff --git a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedStorage.ts index b842d9b9..ce99960a 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedStorage.ts @@ -357,6 +357,8 @@ export type NonIntegratedSkuItem = { id: string; skuSequence: number; batchNo: number; + demandFlowNo?: string; + purchaseOwner?: string; packageGroupId: string; packageSuffix: string; image: string; @@ -1645,6 +1647,8 @@ function normalizeSkuItem( typeof item.batchNo === "number" && Number.isFinite(item.batchNo) ? Math.max(1, Math.floor(item.batchNo)) : 1, + demandFlowNo: normalizeString(item.demandFlowNo), + purchaseOwner: normalizeString(item.purchaseOwner), packageGroupId: normalizeString(item.packageGroupId), packageSuffix: normalizeString(item.packageSuffix), image: normalizeNonIntegratedAssetUrl(normalizeString(item.image)), 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 e3cc5bf7..1a5f3c43 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedDetailTypes.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedDetailTypes.ts @@ -77,6 +77,7 @@ export type PersistEditedProductOverrides = { planningLayoutRows?: NonIntegratedPlanningLayoutRow[]; productMetadata?: NonIntegratedProductRecord["metadata"]; workspaceCategory?: DocumentWorkspaceCategory; + targetSkuIds?: string[]; allowReadOnly?: boolean; }; diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts index 11cd0b52..8ab209cf 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts @@ -117,6 +117,9 @@ export function useNonIntegratedPermissionState({ designer: formValues.designer, purchaser: formValues.purchaser, purchaseOwner: formValues.purchaseOwner, + skuPurchaseOwners: existingProduct?.skuItems + .map((item) => item.purchaseOwner?.trim() ?? "") + .filter(Boolean), productPlanning: formValues.productPlanning, brandPlanning: formValues.brandPlanning, spuOwner: formValues.spuOwner, @@ -138,6 +141,7 @@ export function useNonIntegratedPermissionState({ formValues.spuOwner, formValues.visualDirector, formValues.visualPlanning, + existingProduct?.skuItems, openingReference, ], ); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts index 827cb64b..eea87360 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts @@ -14,6 +14,8 @@ export function createEmptySkuItem(): NonIntegratedSkuItem { id: buildNonIntegratedSkuId(), skuSequence: 1, batchNo: 1, + demandFlowNo: "", + purchaseOwner: "", packageGroupId: "", packageSuffix: "", image: "", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts index fa22f420..b32d8e06 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts @@ -112,6 +112,12 @@ export function deriveSkuItem( Number.isFinite(item.batchNo) && item.batchNo > 0 ? Math.floor(item.batchNo) : 1, + demandFlowNo: + item.demandFlowNo?.trim() || formValues.demandFlowNo.trim(), + purchaseOwner: + item.purchaseOwner?.trim() || + formValues.purchaseOwner.trim() || + formValues.purchaser.trim(), skuCode: resolvedSkuCode, specName: resolvedSpecName, barcode: resolvedBarcode, 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 b7bb17c0..9fb0222a 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 @@ -310,6 +310,8 @@ export function useProductFactoryRowController({ factoryRows: nextRows, documentWorkspaceConfirmations: nextConfirmations, documentWorkspaceApprovedAt: nextApprovedAt, + workspaceCategory: PRODUCT_FACTORY_WORKSPACE_CATEGORY, + targetSkuIds: Array.from(targetSkuIds), }, }); return; diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.ts b/furniture-3d-system/src/lib/non-integrated-permissions.ts index eb511a77..f1b2b173 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.ts @@ -37,6 +37,7 @@ export type IntakeNonIntegratedPermissionProduct = Partial< > > & { openingReference?: Partial | null + skuPurchaseOwners?: readonly string[] | null } export type IntakeNonIntegratedWorkspaceCategory = @@ -460,6 +461,21 @@ export function isCurrentUserNonIntegratedPurchaseOwner( ) } +function isCurrentUserNonIntegratedSkuPurchaseOwner( + product: IntakeNonIntegratedPermissionProduct, + user: IntakeNonIntegratedPermissionUser, +) { + const skuPurchaseOwners = (product.skuPurchaseOwners ?? []) + .map(normalizeText) + .filter(Boolean) + + return ( + isIntakeNonIntegratedSupplyChainUser(user) && + skuPurchaseOwners.length > 0 && + hasMatchingPerson(skuPurchaseOwners, user) + ) +} + export function isCurrentUserNonIntegratedOperationOwner( product: IntakeNonIntegratedPermissionProduct, user: IntakeNonIntegratedPermissionUser, @@ -528,7 +544,12 @@ export function canEditNonIntegratedWorkspace( if ( PURCHASE_OWNER_WORKSPACE_CATEGORIES.has(normalizedCategory) && - (isCurrentUserNonIntegratedPurchaseOwner(product, user) || + ((normalizedCategory === PRODUCT_FACTORY_WORKSPACE_CATEGORY && + ((product.skuPurchaseOwners?.length ?? 0) > 0 + ? isCurrentUserNonIntegratedSkuPurchaseOwner(product, user) + : isCurrentUserNonIntegratedPurchaseOwner(product, user))) || + (normalizedCategory !== PRODUCT_FACTORY_WORKSPACE_CATEGORY && + isCurrentUserNonIntegratedPurchaseOwner(product, user)) || isIntakeNonIntegratedMerchandiserGroupUser(user)) ) { return true diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index 2fcc4fc3..3c2e23ac 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -5261,4 +5261,43 @@ describe('intakeNonIntegratedProductService', () => { }), ) }) + + it('stores each SKU flow and purchasing owner independently', () => { + const payload = createPayload({ + demandFlowNo: 'FLOW-ROOT', + purchaseOwner: 'RootBuyer', + skuBatchFlowNos: { + '1': 'FLOW-A', + '2': 'FLOW-B', + }, + skuItems: [ + createSkuItem({ + id: 'sku-a', + batchNo: 1, + demandFlowNo: 'FLOW-A', + purchaseOwner: 'BuyerA', + }), + createSkuItem({ + id: 'sku-b', + batchNo: 2, + purchaseOwner: 'BuyerB', + }), + ], + }) + + expect(buildStructuredSkuItemInputs(payload)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + skuItemId: 'sku-a', + demandFlowNo: 'FLOW-A', + purchaseOwner: 'BuyerA', + }), + expect.objectContaining({ + skuItemId: 'sku-b', + demandFlowNo: 'FLOW-B', + purchaseOwner: 'BuyerB', + }), + ]), + ) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts index 24bcf6cd..a1d60da0 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts @@ -444,4 +444,62 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { expect(service.canWriteWorkspaceRecord(record, purchaseUser, '产品定厂')).toBe(false) }) + + it('checks product factory write ownership at the SKU level', () => { + const record = { + id: 'same-spu-multiple-purchasers', + createdBy: 'designer-id', + designer: 'Designer', + operator: '', + purchaser: 'LegacyBuyer', + visibility: 'DEPARTMENT', + departmentId: null, + teamId: null, + payload: {}, + profile: { + purchaseOwner: 'LegacyBuyer', + }, + skuItems: [ + { skuItemId: 'sku-buyer-a', purchaseOwner: 'BuyerA' }, + { skuItemId: 'sku-buyer-b', purchaseOwner: 'BuyerB' }, + ], + } + const buyerA: AuthUser = { + id: 'buyer-a-id', + email: 'buyer-a@example.local', + name: 'BuyerA', + role: 'USER', + status: 'ACTIVE', + businessRoles: ['PURCHASING'], + departmentPaths: [], + } + + expect( + service.canWriteWorkspaceRecord( + record, + buyerA, + '\u4ea7\u54c1\u5b9a\u5382', + ['sku-buyer-a'], + ), + ).toBe(true) + expect( + service.canWriteWorkspaceRecord( + record, + buyerA, + '\u4ea7\u54c1\u5b9a\u5382', + ['sku-buyer-b'], + ), + ).toBe(false) + expect( + service.canWriteWorkspaceRecord( + record, + buyerA, + '\u4ea7\u54c1\u5b9a\u5382', + ['sku-buyer-a', 'sku-buyer-b'], + ), + ).toBe(false) + expect( + service.canWriteWorkspaceRecord(record, buyerA, '\u4ea7\u54c1\u5b9a\u5382'), + ).toBe(false) + }) }) diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 81c9d866..cba827c9 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -180,6 +180,7 @@ type RemovedSourceSkuDraftGroupTarget = { type SaveNonIntegratedProductOptions = { workspaceCategory?: string | null + targetSkuIds?: string[] formalSpuAssignmentProductCategory?: string | null existingFormalSpu?: string | null } @@ -733,6 +734,7 @@ const PRODUCT_FACTORY_SKU_SYNC_FIELD_KEY_SET = new Set( type PreserveWorkspaceCategoryOptions = { allowProductFactorySkuSync?: boolean + productFactoryTargetSkuIds?: ReadonlySet } function getSourcePayloadValue( @@ -789,6 +791,7 @@ function mergeStyleDetailFieldsFromProduct( function mergeProductFactorySkuFields( nextSkuItems: unknown, sourceSkuItems: unknown, + targetSkuIds?: ReadonlySet, ) { if (!Array.isArray(sourceSkuItems)) return sourceSkuItems if (!Array.isArray(nextSkuItems)) return sourceSkuItems @@ -807,6 +810,13 @@ function mergeProductFactorySkuFields( return sourceSkuItems.map((sku, index) => { if (!isRecord(sku)) return sku + if ( + targetSkuIds && + targetSkuIds.size > 0 && + !targetSkuIds.has(trimSkuText(sku.id)) + ) { + return sku + } const matchedNext = (typeof sku.id === 'string' ? nextByKey.get(sku.id) : undefined) ?? (typeof sku.skuCode === 'string' ? nextByKey.get(sku.skuCode) : undefined) ?? @@ -823,6 +833,61 @@ function mergeProductFactorySkuFields( }) } +function mergeProductFactoryRowsForTargetSkuIds( + nextRows: unknown, + sourceRows: unknown, + targetSkuIds: ReadonlySet, +) { + if (targetSkuIds.size === 0 || !Array.isArray(sourceRows)) return nextRows + if (!Array.isArray(nextRows)) return sourceRows + + const nextRowsBySkuId = new Map( + nextRows + .filter(isRecord) + .map((row) => [trimSkuText(row.skuId), row]), + ) + const sourceSkuIds = new Set() + const mergedRows = sourceRows.map((row) => { + if (!isRecord(row)) return row + const skuId = trimSkuText(row.skuId) + sourceSkuIds.add(skuId) + if (!targetSkuIds.has(skuId)) return row + return nextRowsBySkuId.get(skuId) ?? row + }) + + nextRows.forEach((row) => { + if (!isRecord(row)) return + const skuId = trimSkuText(row.skuId) + if (targetSkuIds.has(skuId) && !sourceSkuIds.has(skuId)) { + mergedRows.push(row) + } + }) + + return mergedRows +} + +function scopeProductFactoryWriteToTargetSkuIds( + nextProduct: SharedNonIntegratedProductRecord, + sourceProduct: SharedNonIntegratedProductRecord, + targetSkuIds: ReadonlySet, +) { + if (targetSkuIds.size === 0) return nextProduct + + return { + ...nextProduct, + skuItems: mergeProductFactorySkuFields( + nextProduct.skuItems, + sourceProduct.skuItems, + targetSkuIds, + ), + factoryRows: mergeProductFactoryRowsForTargetSkuIds( + nextProduct.factoryRows, + sourceProduct.factoryRows, + targetSkuIds, + ), + } as SharedNonIntegratedProductRecord +} + function omitProductFactorySkuSyncFields(skuItems: unknown) { if (!Array.isArray(skuItems)) return skuItems @@ -865,7 +930,11 @@ function preserveBasicInfoFromProduct( return { ...preservedProduct, - skuItems: mergeProductFactorySkuFields(nextProduct.skuItems, sourceProduct.skuItems), + skuItems: mergeProductFactorySkuFields( + nextProduct.skuItems, + sourceProduct.skuItems, + options.productFactoryTargetSkuIds, + ), } as SharedNonIntegratedProductRecord } @@ -889,6 +958,7 @@ function preserveBasicInfoFromPayload( skuItems: mergeProductFactorySkuFields( nextProduct.skuItems, getSourcePayloadValue(sourcePayload, fallbackProduct, 'skuItems'), + options.productFactoryTargetSkuIds, ), } as SharedNonIntegratedProductRecord } @@ -1836,19 +1906,35 @@ async function prepareProductForPermittedWrite( return { product: nextProduct } } + const requestedCategory = toDocumentWorkspaceCategory( + options.workspaceCategory ?? undefined, + ) + const productFactoryTargetSkuIds = new Set( + (options.targetSkuIds ?? []).map(trimSkuText).filter(Boolean), + ) + const nextProductForWrite = + requestedCategory === PRODUCT_FACTORY_WORKSPACE_CATEGORY + ? scopeProductFactoryWriteToTargetSkuIds( + nextProduct, + existingProduct, + productFactoryTargetSkuIds, + ) + : nextProduct + const targetSkuItemIdsForCategory = (category: string) => + category === PRODUCT_FACTORY_WORKSPACE_CATEGORY && productFactoryTargetSkuIds.size > 0 + ? Array.from(productFactoryTargetSkuIds) + : undefined const isStockProduct = isStockNonIntegratedProduct(existingProduct) - const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProduct) + const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProductForWrite) if (changedCategories.size === 0) { - const requestedCategory = toDocumentWorkspaceCategory( - options.workspaceCategory ?? undefined, - ) if ( requestedCategory && await intakeNonIntegratedScopeService.canWriteWorkspaceRecordWithSettings( record, user, requestedCategory, + targetSkuItemIdsForCategory(requestedCategory), ) ) { return { @@ -1871,7 +1957,7 @@ async function prepareProductForPermittedWrite( } await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) - return { product: nextProduct } + return { product: nextProductForWrite } } const writableCategories = new Set() @@ -1885,6 +1971,7 @@ async function prepareProductForPermittedWrite( record, user, category, + targetSkuItemIdsForCategory(category), ) ) { writableCategories.add(category) @@ -1915,12 +2002,13 @@ async function prepareProductForPermittedWrite( !isStockProduct && changedCategories.has(PRODUCT_FACTORY_WORKSPACE_CATEGORY) && effectivePermittedCategories.has(PRODUCT_FACTORY_WORKSPACE_CATEGORY), + productFactoryTargetSkuIds, } if ( deniedChangedCategories.length > 0 && !hasAtLeastOnePermittedChangedCategory( existingProduct, - nextProduct, + nextProductForWrite, changedCategories, effectivePermittedCategories, preserveOptions, @@ -1957,8 +2045,9 @@ async function prepareProductForPermittedWrite( record, user, deniedChangedCategories[0], + targetSkuItemIdsForCategory(deniedChangedCategories[0]), ) - return { product: nextProduct } + return { product: nextProductForWrite } } const categoriesToPreserve = TRACKED_WORKSPACE_CATEGORIES.filter( @@ -1972,7 +2061,7 @@ async function prepareProductForPermittedWrite( existingProduct, preserveOptions, ), - nextProduct, + nextProductForWrite, ) const scopedChangedCategories = getChangedWorkspaceCategories( existingProduct, @@ -1998,12 +2087,22 @@ async function prepareProductForPermittedWrite( throwNonIntegratedWriteDenied() } - await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) + await intakeNonIntegratedScopeService.assertCanWriteRecord( + record, + user, + requestedCategory, + requestedCategory ? targetSkuItemIdsForCategory(requestedCategory) : undefined, + ) return { product: scopedNextProduct } } for (const category of scopedChangedCategories) { - await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category) + await intakeNonIntegratedScopeService.assertCanWriteRecord( + record, + user, + category, + targetSkuItemIdsForCategory(category), + ) } return { @@ -2270,6 +2369,18 @@ function buildStructuredSkuItemOverlay( id: skuItemId, skuSequence: readStructuredNumber(row.skuSequence, legacy?.skuSequence ?? index + 1), batchNo: readStructuredNumber(row.batchNo, legacy?.batchNo ?? 1), + demandFlowNo: + readStructuredText(row.demandFlowNo) || + legacy?.demandFlowNo || + product.skuBatchFlowNos?.[ + String(readStructuredNumber(row.batchNo, legacy?.batchNo ?? 1)) + ] || + product.demandFlowNo, + purchaseOwner: + readStructuredText(row.purchaseOwner) || + legacy?.purchaseOwner || + product.purchaseOwner || + product.purchaser, packageGroupId: readStructuredText(row.packageGroupId) || legacy?.packageGroupId || '', packageSuffix: legacy?.packageSuffix ?? '', image: readStructuredText(row.image) || legacy?.image || '', @@ -4987,6 +5098,9 @@ export function buildStructuredSkuItemInputs( }) const resolvedSkuCode = modularSkuIdentity || normalizedSkuCode const resolvedBarcode = modularSkuIdentity || normalizedBarcode + const batchNo = Number.isFinite(item.batchNo) + ? Math.max(1, Math.trunc(item.batchNo)) + : 1 const normalizedItem = { ...item, skuCode: resolvedSkuCode, @@ -5006,7 +5120,15 @@ export function buildStructuredSkuItemInputs( skuItemId, sortOrder: index + 1, skuSequence: Number.isFinite(item.skuSequence) ? Math.trunc(item.skuSequence) : 0, - batchNo: Number.isFinite(item.batchNo) ? Math.max(1, Math.trunc(item.batchNo)) : 1, + batchNo, + demandFlowNo: + trimSkuText(item.demandFlowNo) || + trimSkuText(product.skuBatchFlowNos?.[String(batchNo)]) || + trimSkuText(product.demandFlowNo), + purchaseOwner: + trimSkuText(item.purchaseOwner) || + trimSkuText(product.purchaseOwner) || + trimSkuText(product.purchaser), packageGroupId: trimSkuText(item.packageGroupId), image: trimSkuText(item.image) || null, imageName: trimSkuText(item.imageName), diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts index 43f63d51..2c6c0964 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts @@ -37,11 +37,14 @@ type IntakeNonIntegratedPermissionTarget = Pick< payload?: Prisma.JsonValue | null profile?: IntakeNonIntegratedProductProfile | null skuItems?: Array<{ + skuItemId?: string | null + purchaseOwner?: string | null manufacturerCode?: string | null }> } const ADMIN_ROLES = new Set(['SUPER_ADMIN', 'ADMIN']) +const PRODUCT_FACTORY_WORKSPACE_CATEGORY = '\u4ea7\u54c1\u5b9a\u5382' export const DEFAULT_NON_INTEGRATED_VISIBILITY = 'DEPARTMENT' export const NON_INTEGRATED_SCOPE_ENABLED = false @@ -228,6 +231,7 @@ class IntakeNonIntegratedScopeService { private buildPermissionProduct( record: IntakeNonIntegratedPermissionTarget, + skuItemIds?: readonly string[], ): IntakeNonIntegratedPermissionProduct { const profile = record.profile ?? null const readProfile = (key: keyof IntakeNonIntegratedProductProfile) => { @@ -238,6 +242,23 @@ class IntakeNonIntegratedScopeService { const openingDesigner = readOpeningReferenceField(record.payload, 'designer') const persistedDesigner = readText(record.designer) const productPlanning = readProfile('productPlanning') + const targetSkuItemIds = new Set( + (skuItemIds ?? []).map(readText).filter(Boolean), + ) + const fallbackPurchaseOwner = + readProfile('purchaseOwner') || readText(record.purchaser) + const skuPurchaseOwners = (record.skuItems ?? []) + .filter( + (item) => + targetSkuItemIds.size === 0 || + targetSkuItemIds.has(readText(item.skuItemId)), + ) + .map((item) => { + const skuPurchaseOwner = readText(item.purchaseOwner) + return skuPurchaseOwner || + (targetSkuItemIds.size === 0 ? fallbackPurchaseOwner : '') + }) + .filter(Boolean) const nextOpeningReference = { productName: readProfile('productName'), productImage: readProfile('productImageUrl'), @@ -264,6 +285,7 @@ class IntakeNonIntegratedScopeService { productPlanning, visualPlanning: readProfile('visualPlanning'), visualDirector: readProfile('visualDirector'), + skuPurchaseOwners, openingReference: nextOpeningReference, } } @@ -287,16 +309,49 @@ class IntakeNonIntegratedScopeService { record: IntakeNonIntegratedPermissionTarget, user: AuthUser, category?: string | null, + targetSkuItemIds?: readonly string[], ): boolean { if (!category) { return this.canWriteRecord(record, user) } - return canEditNonIntegratedWorkspace( - this.buildPermissionProduct(record), - user, - category, + const normalizedTargetSkuItemIds = Array.from( + new Set((targetSkuItemIds ?? []).map(readText).filter(Boolean)), ) + if ( + category === PRODUCT_FACTORY_WORKSPACE_CATEGORY && + normalizedTargetSkuItemIds.length > 0 + ) { + const skuItemsById = new Map( + (record.skuItems ?? []).map((item) => [readText(item.skuItemId), item]), + ) + if ( + normalizedTargetSkuItemIds.some((skuItemId) => !skuItemsById.has(skuItemId)) + ) { + return false + } + + return normalizedTargetSkuItemIds.every((skuItemId) => + canEditNonIntegratedWorkspace( + this.buildPermissionProduct(record, [skuItemId]), + user, + category, + ), + ) + } + + if (category === PRODUCT_FACTORY_WORKSPACE_CATEGORY) { + const fallbackPurchaseOwner = + readText(record.profile?.purchaseOwner) || readText(record.purchaser) + const skuPurchaseOwners = new Set( + (record.skuItems ?? []) + .map((item) => readText(item.purchaseOwner) || fallbackPurchaseOwner) + .filter(Boolean), + ) + if (skuPurchaseOwners.size > 1) return false + } + + return canEditNonIntegratedWorkspace(this.buildPermissionProduct(record), user, category) } canDownloadWorkspaceRecord( @@ -319,6 +374,7 @@ class IntakeNonIntegratedScopeService { record: IntakeNonIntegratedPermissionTarget, user: AuthUser, category?: string | null, + targetSkuItemIds?: readonly string[], ): Promise { if (isIntakeNonIntegratedFullEditor(user)) { return true @@ -345,7 +401,7 @@ class IntakeNonIntegratedScopeService { return true } - return this.canWriteWorkspaceRecord(record, user, category) + return this.canWriteWorkspaceRecord(record, user, category, targetSkuItemIds) } async canDownloadWorkspaceRecordWithSettings( @@ -418,8 +474,16 @@ class IntakeNonIntegratedScopeService { record: IntakeNonIntegratedPermissionTarget, user: AuthUser, category?: string | null, + targetSkuItemIds?: readonly string[], ): Promise { - if (await this.canWriteWorkspaceRecordWithSettings(record, user, category)) { + if ( + await this.canWriteWorkspaceRecordWithSettings( + record, + user, + category, + targetSkuItemIds, + ) + ) { return }