fix(intake): scope product factory writes by sku owner

This commit is contained in:
WuYongJie 2026-07-10 11:41:24 +08:00
parent d37bfcdde1
commit 3c97d2b774
17 changed files with 491 additions and 20 deletions

View File

@ -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";

View File

@ -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");

View File

@ -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;

View File

@ -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])

View File

@ -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) {

View File

@ -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:

View File

@ -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)),

View File

@ -77,6 +77,7 @@ export type PersistEditedProductOverrides = {
planningLayoutRows?: NonIntegratedPlanningLayoutRow[];
productMetadata?: NonIntegratedProductRecord["metadata"];
workspaceCategory?: DocumentWorkspaceCategory;
targetSkuIds?: string[];
allowReadOnly?: boolean;
};

View File

@ -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,
],
);

View File

@ -14,6 +14,8 @@ export function createEmptySkuItem(): NonIntegratedSkuItem {
id: buildNonIntegratedSkuId(),
skuSequence: 1,
batchNo: 1,
demandFlowNo: "",
purchaseOwner: "",
packageGroupId: "",
packageSuffix: "",
image: "",

View File

@ -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,

View File

@ -310,6 +310,8 @@ export function useProductFactoryRowController({
factoryRows: nextRows,
documentWorkspaceConfirmations: nextConfirmations,
documentWorkspaceApprovedAt: nextApprovedAt,
workspaceCategory: PRODUCT_FACTORY_WORKSPACE_CATEGORY,
targetSkuIds: Array.from(targetSkuIds),
},
});
return;

View File

@ -37,6 +37,7 @@ export type IntakeNonIntegratedPermissionProduct = Partial<
>
> & {
openingReference?: Partial<NonIntegratedProductRecord['openingReference']> | 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

View File

@ -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',
}),
]),
)
})
})

View File

@ -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)
})
})

View File

@ -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<string>(
type PreserveWorkspaceCategoryOptions = {
allowProductFactorySkuSync?: boolean
productFactoryTargetSkuIds?: ReadonlySet<string>
}
function getSourcePayloadValue(
@ -789,6 +791,7 @@ function mergeStyleDetailFieldsFromProduct(
function mergeProductFactorySkuFields(
nextSkuItems: unknown,
sourceSkuItems: unknown,
targetSkuIds?: ReadonlySet<string>,
) {
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<string>,
) {
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<string>()
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<string>,
) {
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<NonIntegratedWorkspaceCategoryKey>()
@ -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),

View File

@ -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<AuthUser['role']>(['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<boolean> {
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<void> {
if (await this.canWriteWorkspaceRecordWithSettings(record, user, category)) {
if (
await this.canWriteWorkspaceRecordWithSettings(
record,
user,
category,
targetSkuItemIds,
)
) {
return
}