fix(intake): scope product factory writes by sku owner
This commit is contained in:
parent
d37bfcdde1
commit
3c97d2b774
@ -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";
|
||||
@ -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");
|
||||
@ -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;
|
||||
@ -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])
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -77,6 +77,7 @@ export type PersistEditedProductOverrides = {
|
||||
planningLayoutRows?: NonIntegratedPlanningLayoutRow[];
|
||||
productMetadata?: NonIntegratedProductRecord["metadata"];
|
||||
workspaceCategory?: DocumentWorkspaceCategory;
|
||||
targetSkuIds?: string[];
|
||||
allowReadOnly?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@ -14,6 +14,8 @@ export function createEmptySkuItem(): NonIntegratedSkuItem {
|
||||
id: buildNonIntegratedSkuId(),
|
||||
skuSequence: 1,
|
||||
batchNo: 1,
|
||||
demandFlowNo: "",
|
||||
purchaseOwner: "",
|
||||
packageGroupId: "",
|
||||
packageSuffix: "",
|
||||
image: "",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -310,6 +310,8 @@ export function useProductFactoryRowController({
|
||||
factoryRows: nextRows,
|
||||
documentWorkspaceConfirmations: nextConfirmations,
|
||||
documentWorkspaceApprovedAt: nextApprovedAt,
|
||||
workspaceCategory: PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
targetSkuIds: Array.from(targetSkuIds),
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user