merge: sync non-integrated upload permission foundation
This commit is contained in:
commit
b2837c6426
@ -256,6 +256,7 @@ function auditSteps(outputDir, options = {}) {
|
||||
id: 'foundation-preflight',
|
||||
label: '结构变更前置预检',
|
||||
outputFile: path.join(outputDir, 'foundation-preflight.json'),
|
||||
usesShadowDatabase: true,
|
||||
args: [
|
||||
'scripts/preflight-database-foundation-change.js',
|
||||
'--output',
|
||||
@ -266,6 +267,7 @@ function auditSteps(outputDir, options = {}) {
|
||||
id: 'foundation-migrate-plan',
|
||||
label: '治理迁移执行计划',
|
||||
outputFile: path.join(outputDir, 'foundation-migrate-plan.json'),
|
||||
usesShadowDatabase: true,
|
||||
args: [
|
||||
'scripts/run-database-foundation-migrations.js',
|
||||
'--output',
|
||||
@ -432,16 +434,25 @@ function auditSteps(outputDir, options = {}) {
|
||||
return steps
|
||||
}
|
||||
|
||||
function environmentForAuditStep(step, env = process.env) {
|
||||
const stepEnv = { ...env }
|
||||
if (step?.usesShadowDatabase === true) return stepEnv
|
||||
|
||||
delete stepEnv.SHADOW_DATABASE_URL
|
||||
return stepEnv
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
function runAuditStep(step, env = process.env) {
|
||||
const stepEnv = environmentForAuditStep(step, env)
|
||||
const result = spawnSync(process.execPath, step.args, {
|
||||
cwd: ROOT_DIR,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
env: { ...process.env, ...stepEnv },
|
||||
windowsHide: true,
|
||||
})
|
||||
const outputExists = fs.existsSync(step.outputFile)
|
||||
@ -1766,6 +1777,7 @@ module.exports = {
|
||||
countByField,
|
||||
createDockerShadowDatabase,
|
||||
defaultOutputDir,
|
||||
environmentForAuditStep,
|
||||
parseArgs,
|
||||
redactStepPreview,
|
||||
runAudit,
|
||||
|
||||
@ -1239,13 +1239,19 @@ describe("NonIntegratedDetailClient", () => {
|
||||
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({ name: "remote-preview.png" }),
|
||||
undefined,
|
||||
{
|
||||
category: undefined,
|
||||
onProgress: undefined,
|
||||
},
|
||||
);
|
||||
const [, uploadedFile, uploadProgressHandler] =
|
||||
const [, uploadedFile, uploadOptions] =
|
||||
mockedUploadDetachedNonIntegratedFile.mock.calls[0] ?? [];
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
expect(uploadedFile.name).toBe("remote-preview.png");
|
||||
expect(uploadProgressHandler).toBeUndefined();
|
||||
expect(uploadOptions).toEqual({
|
||||
category: undefined,
|
||||
onProgress: undefined,
|
||||
});
|
||||
expect(mockedSaveSharedNonIntegratedProduct.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
previewImage: "http://test.local/remote-preview.png",
|
||||
@ -1256,6 +1262,47 @@ describe("NonIntegratedDetailClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the active workspace category when uploading SKU images on an existing product", async () => {
|
||||
(globalThis as NonIntegratedRemoteAssetStorageTestGlobal)
|
||||
.__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true;
|
||||
const actor = userEvent.setup();
|
||||
const productId = "DNZ0001-sku-image-upload";
|
||||
|
||||
const { container } = render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId={productId}
|
||||
initialProduct={createProductRecord({
|
||||
id: productId,
|
||||
spu: "DNZ0001",
|
||||
previewImage: "",
|
||||
skuItems: [createSkuItem({ id: "sku-image-row-1" })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const skuImageInput = container.querySelector<HTMLInputElement>(
|
||||
"#sku-image-input-sku-image-row-1",
|
||||
);
|
||||
expect(skuImageInput).not.toBeNull();
|
||||
await actor.upload(
|
||||
skuImageInput!,
|
||||
new File(["sku-image"], "DNZ000101.png", { type: "image/png" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledWith(
|
||||
productId,
|
||||
expect.objectContaining({ name: "DNZ000101.png" }),
|
||||
expect.objectContaining({
|
||||
category: "基础信息",
|
||||
onProgress: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the opening reference image as the shared product main image", async () => {
|
||||
const actor = userEvent.setup();
|
||||
saveNonIntegratedProduct(
|
||||
|
||||
@ -9510,6 +9510,7 @@ async function uploadFileToServer(
|
||||
file: File,
|
||||
options?: {
|
||||
productId?: string;
|
||||
category?: string | null;
|
||||
},
|
||||
onProgress?: NonIntegratedUploadProgressHandler,
|
||||
) {
|
||||
@ -9524,7 +9525,10 @@ async function uploadFileToServer(
|
||||
const payload = await uploadDetachedNonIntegratedFile(
|
||||
options?.productId,
|
||||
uploadFile,
|
||||
{
|
||||
category: options?.category,
|
||||
onProgress,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@ -13659,6 +13663,7 @@ export default function NonIntegratedDetailClient({
|
||||
const detachedUploadOptions = sharedUploadProductId
|
||||
? {
|
||||
productId: sharedUploadProductId,
|
||||
category: activePermissionWorkspaceCategory,
|
||||
}
|
||||
: undefined;
|
||||
const uploadProgressPercent = uploadProgress
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { uploadDetachedNonIntegratedFile } from './nonIntegratedApi'
|
||||
|
||||
type MutableGlobal = typeof globalThis & { fetch?: typeof fetch }
|
||||
type MutableGlobal = typeof globalThis & {
|
||||
fetch?: typeof fetch
|
||||
XMLHttpRequest?: typeof XMLHttpRequest
|
||||
}
|
||||
|
||||
const originalFetch = (globalThis as MutableGlobal).fetch
|
||||
const originalXMLHttpRequest = (globalThis as MutableGlobal).XMLHttpRequest
|
||||
|
||||
function setFetch(fetchMock: typeof fetch) {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
@ -19,6 +23,24 @@ function restoreFetch() {
|
||||
}
|
||||
}
|
||||
|
||||
function useFetchUploadBranch() {
|
||||
Object.defineProperty(globalThis, 'XMLHttpRequest', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function restoreXMLHttpRequest() {
|
||||
if (originalXMLHttpRequest) {
|
||||
Object.defineProperty(globalThis, 'XMLHttpRequest', {
|
||||
configurable: true,
|
||||
value: originalXMLHttpRequest,
|
||||
})
|
||||
} else {
|
||||
delete (globalThis as MutableGlobal).XMLHttpRequest
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
@ -27,6 +49,7 @@ function jsonResponse(data: unknown, status = 200) {
|
||||
get: () => null,
|
||||
},
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
} as Response
|
||||
}
|
||||
|
||||
@ -41,6 +64,7 @@ function emptyResponse(
|
||||
get: (name: string) => headers[name] ?? headers[name.toLowerCase()] ?? null,
|
||||
},
|
||||
json: async () => null,
|
||||
text: async () => '',
|
||||
} as Response
|
||||
}
|
||||
|
||||
@ -48,6 +72,7 @@ describe('nonIntegratedApi uploads', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
restoreFetch()
|
||||
restoreXMLHttpRequest()
|
||||
})
|
||||
|
||||
it('uploads detached files without a product id through the temporary upload endpoint', async () => {
|
||||
@ -63,6 +88,7 @@ describe('nonIntegratedApi uploads', () => {
|
||||
}),
|
||||
)
|
||||
setFetch(fetchMock as typeof fetch)
|
||||
useFetchUploadBranch()
|
||||
|
||||
const payload = await uploadDetachedNonIntegratedFile(
|
||||
undefined,
|
||||
@ -101,14 +127,21 @@ describe('nonIntegratedApi uploads', () => {
|
||||
}),
|
||||
)
|
||||
setFetch(fetchMock as typeof fetch)
|
||||
useFetchUploadBranch()
|
||||
|
||||
const file = new File(['tiny'], 'tiny.png', { type: 'image/png' })
|
||||
await uploadDetachedNonIntegratedFile('product-1', file)
|
||||
await uploadDetachedNonIntegratedFile('product-1', file, {
|
||||
category: '基础信息',
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'/api/intake/non-integrated/product-1/upload',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0][1]?.body).toBeInstanceOf(FormData)
|
||||
expect((fetchMock.mock.calls[0][1]?.body as FormData).get('category')).toBe(
|
||||
'基础信息',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses chunk upload before production request body limits can reject images', async () => {
|
||||
@ -137,16 +170,25 @@ describe('nonIntegratedApi uploads', () => {
|
||||
),
|
||||
)
|
||||
setFetch(fetchMock as typeof fetch)
|
||||
useFetchUploadBranch()
|
||||
|
||||
const file = new File([new Uint8Array(600 * 1024)], 'large.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await uploadDetachedNonIntegratedFile('product-1', file)
|
||||
await uploadDetachedNonIntegratedFile('product-1', file, {
|
||||
category: '产品实拍图',
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/api/intake/non-integrated/product-1/upload/chunk',
|
||||
'/api/intake/non-integrated/product-1/upload/chunk',
|
||||
])
|
||||
expect((fetchMock.mock.calls[0][1]?.body as FormData).get('category')).toBe(
|
||||
'产品实拍图',
|
||||
)
|
||||
expect((fetchMock.mock.calls[1][1]?.body as FormData).get('category')).toBe(
|
||||
'产品实拍图',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,6 +24,11 @@ export type NonIntegratedUploadProgressHandler = (
|
||||
progress: NonIntegratedUploadProgress,
|
||||
) => void
|
||||
|
||||
export type NonIntegratedDetachedUploadOptions = {
|
||||
category?: string | null
|
||||
onProgress?: NonIntegratedUploadProgressHandler
|
||||
}
|
||||
|
||||
export type NonIntegratedProductPageInfo = {
|
||||
page: number
|
||||
pageSize: number
|
||||
@ -385,15 +390,28 @@ export async function uploadSharedNonIntegratedFile(
|
||||
export async function uploadDetachedNonIntegratedFile(
|
||||
productId: string | undefined,
|
||||
file: File,
|
||||
onProgress?: NonIntegratedUploadProgressHandler,
|
||||
optionsOrProgress?: NonIntegratedDetachedUploadOptions | NonIntegratedUploadProgressHandler,
|
||||
) {
|
||||
const options =
|
||||
typeof optionsOrProgress === 'function'
|
||||
? { onProgress: optionsOrProgress }
|
||||
: (optionsOrProgress ?? {})
|
||||
const category =
|
||||
typeof options.category === 'string' && options.category.trim()
|
||||
? options.category.trim()
|
||||
: undefined
|
||||
const onProgress = options.onProgress
|
||||
|
||||
if (productId && file.size > NON_INTEGRATED_UPLOAD_CHUNK_BYTES) {
|
||||
return uploadDetachedNonIntegratedFileInChunks(productId, file, onProgress)
|
||||
return uploadDetachedNonIntegratedFileInChunks(productId, file, category, onProgress)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('attach', 'false')
|
||||
if (category) {
|
||||
formData.append('category', category)
|
||||
}
|
||||
|
||||
reportFileUploadProgress(onProgress, file.size, 0)
|
||||
|
||||
@ -432,12 +450,14 @@ async function uploadSharedNonIntegratedFileInChunks(
|
||||
async function uploadDetachedNonIntegratedFileInChunks(
|
||||
productId: string,
|
||||
file: File,
|
||||
category?: string,
|
||||
onProgress?: NonIntegratedUploadProgressHandler,
|
||||
) {
|
||||
return uploadNonIntegratedFileInChunks<UploadDetachedNonIntegratedFileResponse>({
|
||||
productId,
|
||||
file,
|
||||
attach: false,
|
||||
category,
|
||||
onProgress,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
buildAssetSkuSnapshotFromProfile,
|
||||
buildProductSkuAssetProfileData,
|
||||
resolveAssetSkuSnapshotSyncPolicy,
|
||||
type AssetSkuSnapshot,
|
||||
} from '../asset-sku-snapshot'
|
||||
|
||||
@ -154,4 +155,84 @@ describe('asset-sku-snapshot', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('资产同步策略会尊重人工锁定图片并保留可审计规则', () => {
|
||||
const snapshot: AssetSkuSnapshot = {
|
||||
sourceAssetVariantId: 'variant-primary',
|
||||
sourceAssetFamilyId: 'family-1',
|
||||
sourceAssetSeriesId: 'series-1',
|
||||
model: 'DBZ-SPJK001',
|
||||
modelName: '桌面板',
|
||||
specCode: 'DBZ-SPJ00001',
|
||||
specName: '1200 柚木色',
|
||||
barcode: 'DBZ-SPJ00001N05V01',
|
||||
size: { length: 1200 },
|
||||
color: '柚木色',
|
||||
material: '颗粒板',
|
||||
imageUrl: '/new-thumb.webp',
|
||||
sourceStatus: 'ACTIVE',
|
||||
sourceAssetVariantIds: ['variant-primary'],
|
||||
sourceVariants: [],
|
||||
syncedAt: '2026-05-31T10:00:00.000Z',
|
||||
lockedFields: [' imageUrl ', 'barcode'],
|
||||
lockedReasons: {
|
||||
imageUrl: '保留人工图',
|
||||
barcode: '人工条码',
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveAssetSkuSnapshotSyncPolicy({
|
||||
reason: 'THUMBNAIL_REGENERATED',
|
||||
isPrimarySource: true,
|
||||
next: snapshot,
|
||||
}),
|
||||
).toEqual({
|
||||
syncsTopLevelFields: true,
|
||||
syncsAssetProfile: true,
|
||||
refreshesImages: false,
|
||||
marksDeletedSource: false,
|
||||
marksMissingSource: false,
|
||||
preservesLockedFields: ['imageUrl', 'barcode'],
|
||||
})
|
||||
})
|
||||
|
||||
it('资产同步策略会把删除来源表达成正式 SKU 副本状态', () => {
|
||||
const snapshot: AssetSkuSnapshot = {
|
||||
sourceAssetVariantId: 'variant-deleted',
|
||||
sourceAssetFamilyId: 'family-1',
|
||||
sourceAssetSeriesId: 'series-1',
|
||||
model: 'DBZ-SPJK001',
|
||||
modelName: '桌面板',
|
||||
specCode: 'DBZ-SPJ00001',
|
||||
specName: '1200 柚木色',
|
||||
barcode: 'DBZ-SPJ00001N05V01',
|
||||
size: { length: 1200 },
|
||||
color: '柚木色',
|
||||
material: '颗粒板',
|
||||
imageUrl: '/old-thumb.webp',
|
||||
sourceStatus: 'DELETED',
|
||||
sourceAssetVariantIds: ['variant-deleted'],
|
||||
sourceVariants: [],
|
||||
syncedAt: '2026-05-31T10:00:00.000Z',
|
||||
lockedFields: [],
|
||||
lockedReasons: {},
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveAssetSkuSnapshotSyncPolicy({
|
||||
reason: 'VARIANT_DELETED',
|
||||
isPrimarySource: true,
|
||||
next: snapshot,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
syncsTopLevelFields: true,
|
||||
syncsAssetProfile: true,
|
||||
refreshesImages: true,
|
||||
marksDeletedSource: true,
|
||||
marksMissingSource: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -927,7 +927,7 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('分配正式 SPU 时允许 SKU 主数据在后续节点补齐', async () => {
|
||||
it('分配正式 SPU 时阻断未补齐的 SKU 主数据,避免生成新的地基阻塞', async () => {
|
||||
const payload = createPayload({
|
||||
id: 'draft-incomplete-before-formal-spu',
|
||||
spu: '',
|
||||
@ -966,21 +966,17 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
}) as never,
|
||||
)
|
||||
|
||||
await intakeNonIntegratedProductService.save(payload, baseUser, {
|
||||
const promise = intakeNonIntegratedProductService.save(payload, baseUser, {
|
||||
formalSpuAssignmentProductCategory: '餐桌',
|
||||
})
|
||||
|
||||
const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as {
|
||||
skuItems?: { createMany?: { data?: Array<Record<string, unknown>> } }
|
||||
}
|
||||
expect(createArg.skuItems?.createMany?.data?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
skuCode: 'CZ001101',
|
||||
barcode: 'CZ001101N01V01',
|
||||
model: '',
|
||||
specName: '',
|
||||
}),
|
||||
)
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_REQUIRED_FIELD,
|
||||
})
|
||||
await expect(promise).rejects.toThrow('正式 SPU 分配存在未补齐的 SKU 主数据')
|
||||
|
||||
expect(mockedRepository.createInTransaction).not.toHaveBeenCalled()
|
||||
expect(mockedRepository.updateInTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('分配正式 SPU 前仍必须补齐产品主数据', async () => {
|
||||
|
||||
@ -259,6 +259,10 @@ describe('syncJstSkuStockProducts', () => {
|
||||
data: expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
source: 'jst_sku',
|
||||
productCategory: '桌类',
|
||||
openingReference: expect.objectContaining({
|
||||
productCategory: '桌类',
|
||||
}),
|
||||
skuItems: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
barcode: 'BAR001',
|
||||
@ -272,6 +276,7 @@ describe('syncJstSkuStockProducts', () => {
|
||||
create: expect.objectContaining({
|
||||
productName: '测试存量商品 1200*600*750',
|
||||
productImageUrl: 'https://cdn.example.com/stock-a.jpg',
|
||||
productCategory: '桌类',
|
||||
productDevelopmentKind: 'product',
|
||||
materialType: '颗粒板',
|
||||
sourceSpu: 'SPU001',
|
||||
@ -343,6 +348,22 @@ describe('syncJstSkuStockProducts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('apply 遇到聚水潭来源缺分类时跳过写库,避免重新生成缺 productCategory 的主数据', async () => {
|
||||
createConnectionMock(
|
||||
createSourceRow({
|
||||
category: '',
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await syncJstSkuStockProducts({ apply: true, batchSize: 1, limit: 1 })
|
||||
|
||||
expect(result.created).toBe(0)
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(result.errors[0]).toContain('聚水潭存量商品同步存在未补齐的产品主数据')
|
||||
expect(mockedPrisma.intakeNonIntegratedProduct.create).not.toHaveBeenCalled()
|
||||
expect(mockedPrisma.intakeNonIntegratedProduct.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('apply 更新存量商品时 upsert 产品 profile 并刷新结构化 SKU 明细', async () => {
|
||||
createConnectionMock(createSourceRow({ name: '更新后的存量商品 1200*600*750' }))
|
||||
mockedPrisma.intakeNonIntegratedProduct.findUnique.mockResolvedValue({
|
||||
@ -363,12 +384,14 @@ describe('syncJstSkuStockProducts', () => {
|
||||
upsert: {
|
||||
create: expect.objectContaining({
|
||||
productName: '更新后的存量商品 1200*600*750',
|
||||
productCategory: '桌类',
|
||||
materialType: '颗粒板',
|
||||
sourceSpu: 'SPU001',
|
||||
sourceSkuId: 'BAR001',
|
||||
}),
|
||||
update: expect.objectContaining({
|
||||
productName: '更新后的存量商品 1200*600*750',
|
||||
productCategory: '桌类',
|
||||
materialType: '颗粒板',
|
||||
sourceSpu: 'SPU001',
|
||||
sourceSkuId: 'BAR001',
|
||||
|
||||
@ -43,6 +43,21 @@ export interface AssetSkuSnapshot extends AssetSkuSnapshotVariant {
|
||||
lockedReasons: Record<string, string>
|
||||
}
|
||||
|
||||
export type AssetSkuSnapshotSyncReason =
|
||||
| 'VARIANT_UPDATED'
|
||||
| 'THUMBNAIL_REGENERATED'
|
||||
| 'VARIANT_DELETED'
|
||||
| 'MANUAL_RETRY'
|
||||
|
||||
export type AssetSkuSnapshotSyncPolicy = {
|
||||
syncsTopLevelFields: boolean
|
||||
syncsAssetProfile: boolean
|
||||
refreshesImages: boolean
|
||||
marksDeletedSource: boolean
|
||||
marksMissingSource: boolean
|
||||
preservesLockedFields: AssetSkuSnapshotLockableField[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@ -348,6 +363,26 @@ export function applyLockedSnapshotFields(input: {
|
||||
return normalizeSnapshot(merged)
|
||||
}
|
||||
|
||||
export function resolveAssetSkuSnapshotSyncPolicy(input: {
|
||||
reason: AssetSkuSnapshotSyncReason
|
||||
isPrimarySource: boolean
|
||||
next: AssetSkuSnapshot
|
||||
}): AssetSkuSnapshotSyncPolicy {
|
||||
const normalizedNext = normalizeSnapshot(input.next)
|
||||
const lockedFields = normalizeLockedFields(normalizedNext.lockedFields)
|
||||
const imageLocked = lockedFields.includes('imageUrl') || lockedFields.includes('images')
|
||||
|
||||
return {
|
||||
syncsTopLevelFields: input.isPrimarySource,
|
||||
syncsAssetProfile: true,
|
||||
refreshesImages: input.isPrimarySource && !imageLocked && Boolean(normalizedNext.imageUrl),
|
||||
marksDeletedSource:
|
||||
input.reason === 'VARIANT_DELETED' || normalizedNext.sourceStatus === 'DELETED',
|
||||
marksMissingSource: normalizedNext.sourceStatus === 'MISSING',
|
||||
preservesLockedFields: lockedFields,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnapshotSyncedAt(value: string): Date {
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
buildMissingAssetSkuSnapshotVariant,
|
||||
buildAssetSkuSnapshotVariant,
|
||||
buildProductSkuAssetProfileData,
|
||||
resolveAssetSkuSnapshotSyncPolicy,
|
||||
type AssetSkuSnapshot,
|
||||
} from './asset-sku-snapshot'
|
||||
|
||||
@ -32,10 +33,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isImageLocked(snapshot: AssetSkuSnapshot | null): boolean {
|
||||
return Boolean(snapshot?.lockedFields.includes('imageUrl') || snapshot?.lockedFields.includes('images'))
|
||||
}
|
||||
|
||||
function upsertSourceVariant(
|
||||
snapshot: AssetSkuSnapshot,
|
||||
updatedVariant: ReturnType<typeof buildAssetSkuSnapshotVariant>,
|
||||
@ -289,8 +286,13 @@ class AssetSkuSyncService {
|
||||
...existingAttributes,
|
||||
assetSkuSnapshot: nextSnapshot,
|
||||
}
|
||||
const syncPolicy = resolveAssetSkuSnapshotSyncPolicy({
|
||||
reason: event.reason as AssetSkuSyncReason,
|
||||
isPrimarySource: shouldUpdateTopLevel,
|
||||
next: nextSnapshot,
|
||||
})
|
||||
const nextImages =
|
||||
shouldUpdateTopLevel && !isImageLocked(nextSnapshot) && sourceVariant.imageUrl
|
||||
syncPolicy.refreshesImages && sourceVariant.imageUrl
|
||||
? [sourceVariant.imageUrl, ...sku.images.filter((item) => item !== sourceVariant.imageUrl)]
|
||||
: sku.images
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ import {
|
||||
preSkuRepository,
|
||||
} from '../repositories/draft-group.repository'
|
||||
import {
|
||||
assertNonIntegratedProductProfileReadyForFormalUse,
|
||||
assertNonIntegratedProductMasterDataReadyForFormalUse,
|
||||
resolveNonIntegratedSkuQuality,
|
||||
} from './non-integrated-sku-quality'
|
||||
import {
|
||||
@ -4172,7 +4172,7 @@ class IntakeNonIntegratedProductService {
|
||||
if (!normalizedProduct) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
|
||||
}
|
||||
assertNonIntegratedProductProfileReadyForFormalUse(normalizedProduct, {
|
||||
assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, {
|
||||
usageLabel: '正式 SPU 分配',
|
||||
})
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
buildStructuredProductProfileInput,
|
||||
buildStructuredSkuItemInputs,
|
||||
} from './intake-non-integrated-product.service'
|
||||
import { assertNonIntegratedProductMasterDataReadyForFormalUse } from './non-integrated-sku-quality'
|
||||
|
||||
type JstSkuSourceField =
|
||||
| 'sku_id'
|
||||
@ -379,7 +380,7 @@ function buildSourceProductPayload(
|
||||
temporaryCode: sourceSpu,
|
||||
brand: BASIC_BRAND,
|
||||
adaptableBrands: [BASIC_BRAND],
|
||||
productCategory: '',
|
||||
productCategory: sourceCategory,
|
||||
salesCategory: '',
|
||||
designer: '',
|
||||
planningOwner: '',
|
||||
@ -403,7 +404,7 @@ function buildSourceProductPayload(
|
||||
year: '',
|
||||
demandFlowNo: '',
|
||||
processTemplate: STOCK_PRODUCT_LABEL,
|
||||
productCategory: '',
|
||||
productCategory: sourceCategory,
|
||||
salesCategory: '',
|
||||
productPlanning: '',
|
||||
demandDepartment: '',
|
||||
@ -517,10 +518,17 @@ function buildSourceProductPayload(
|
||||
} as unknown as Partial<NonIntegratedProductRecord> & Record<string, unknown>
|
||||
const normalizedProduct = normalizeNonIntegratedProductRecord(rawPayload)
|
||||
|
||||
return normalizedProduct
|
||||
const product = normalizedProduct
|
||||
? ({ ...rawPayload, ...normalizedProduct } as NonIntegratedProductRecord &
|
||||
Record<string, unknown>)
|
||||
: null
|
||||
if (!product) return null
|
||||
|
||||
assertNonIntegratedProductMasterDataReadyForFormalUse(product, {
|
||||
usageLabel: '聚水潭存量商品同步',
|
||||
})
|
||||
|
||||
return product
|
||||
}
|
||||
|
||||
function mergeExistingProductPayload(
|
||||
@ -581,6 +589,7 @@ function mergeExistingProductPayload(
|
||||
temporaryCode: sourceOpeningReference.temporaryCode,
|
||||
brand: sourceOpeningReference.brand,
|
||||
adaptableBrands: sourceOpeningReference.adaptableBrands,
|
||||
productCategory: sourceOpeningReference.productCategory,
|
||||
currentStage: sourceOpeningReference.currentStage,
|
||||
size: sourceOpeningReference.size,
|
||||
material: sourceOpeningReference.material,
|
||||
@ -613,6 +622,7 @@ function mergeExistingProductPayload(
|
||||
color: sourcePayload.color,
|
||||
material: sourcePayload.material,
|
||||
materialType: sourcePayload.materialType,
|
||||
productCategory: sourcePayload.productCategory,
|
||||
openingReference: nextOpeningReference,
|
||||
previewImage: sourcePayload.previewImage || existingProduct.previewImage,
|
||||
previewImageName: sourcePayload.previewImage
|
||||
|
||||
@ -26,6 +26,7 @@ const {
|
||||
countByField,
|
||||
countQualityDecisionPlaceholderFields,
|
||||
defaultOutputDir,
|
||||
environmentForAuditStep,
|
||||
parseArgs,
|
||||
timestampForDirectory,
|
||||
writeJsonReport,
|
||||
@ -205,6 +206,26 @@ describe('run-master-data-foundation-audit', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('only passes shadow database URLs to steps that need migration diff evidence', () => {
|
||||
const outputDir = path.resolve(__dirname, '../../.master-data-audits/example')
|
||||
const steps = auditSteps(outputDir)
|
||||
const readinessStep = steps.find((step) => step.id === 'readiness')
|
||||
const preflightStep = steps.find((step) => step.id === 'foundation-preflight')
|
||||
const migratePlanStep = steps.find((step) => step.id === 'foundation-migrate-plan')
|
||||
const env = {
|
||||
DATABASE_URL: 'postgresql://app:secret@localhost:5432/furniture_3d',
|
||||
SHADOW_DATABASE_URL: 'postgresql://shadow:secret@localhost:65436/furniture_3d_shadow',
|
||||
}
|
||||
|
||||
expect(preflightStep).toEqual(expect.objectContaining({ usesShadowDatabase: true }))
|
||||
expect(migratePlanStep).toEqual(expect.objectContaining({ usesShadowDatabase: true }))
|
||||
expect(environmentForAuditStep(readinessStep, env)).toEqual({
|
||||
DATABASE_URL: env.DATABASE_URL,
|
||||
})
|
||||
expect(environmentForAuditStep(preflightStep, env)).toEqual(env)
|
||||
expect(environmentForAuditStep(migratePlanStep, env)).toEqual(env)
|
||||
})
|
||||
|
||||
it('adds the sensitive cleanup template step only when explicitly requested', () => {
|
||||
const outputDir = path.resolve(__dirname, '../../.master-data-audits/example')
|
||||
const defaultSteps = auditSteps(outputDir)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user