fix: show fixed virtual inventory task status

This commit is contained in:
WuYongJie 2026-08-11 08:58:37 +08:00
parent 50317a3c0f
commit 6858161f3f
9 changed files with 250 additions and 2 deletions

View File

@ -586,6 +586,13 @@ export type NonIntegratedPricingRow = {
jstVirtualInventoryLastAttemptAt?: string;
jstVirtualInventoryError?: string;
jstVirtualInventorySoId?: string;
/** Read-only status from the separate fixed virtual-inventory task queue. */
fixedVirtualInventoryStatus?: "pending" | "pushed";
fixedVirtualInventoryScheduledAt?: string;
fixedVirtualInventoryPushedAt?: string;
fixedVirtualInventoryRetryCount?: string;
fixedVirtualInventoryLastAttemptAt?: string;
fixedVirtualInventoryError?: string;
};
export type NonIntegratedProductLimitRow = {

View File

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import { ProductPricingWorkspace } from "./ProductPricingWorkspace";
import type { ProductPricingDisplayRow } from "./product-workflow/productWorkflowRowHelpers";
function createPricingRow(): ProductPricingDisplayRow {
return {
skuId: "sku-1",
model: "MODEL-001",
skuCode: "SKU-001",
barcode: "BAR-001",
setCount: "1",
specName: "规格一",
supplierName: "供应商",
costPrice: "88",
productTag: "",
systemEnteredAt: "2026-08-10T08:00:00.000Z",
jstVirtualInventoryStatus: "failed",
jstVirtualInventoryScheduledAt: "",
jstVirtualInventoryPushedAt: "",
jstVirtualInventoryRetryCount: "0",
jstVirtualInventoryLastAttemptAt: "",
jstVirtualInventoryError: "聚水潭未找到对应仓库",
jstVirtualInventorySoId: "",
fixedVirtualInventoryStatus: "pushed",
fixedVirtualInventoryScheduledAt: "2026-08-10T09:00:00.000Z",
fixedVirtualInventoryPushedAt: "2026-08-10T09:00:01.000Z",
fixedVirtualInventoryRetryCount: "0",
fixedVirtualInventoryLastAttemptAt: "2026-08-10T09:00:01.000Z",
fixedVirtualInventoryError: "",
};
}
describe("ProductPricingWorkspace", () => {
it("shows the fixed virtual inventory result before a failed legacy result", () => {
const row = createPricingRow();
render(
<ProductPricingWorkspace
allRows={[row]}
displayedRows={[row]}
inheritedSkuBatchTabs={null}
isReadOnly={false}
isNodeLocked={false}
canUsePushControls={false}
isRowReadyForPush={() => true}
onUpdateRow={jest.fn()}
onConfirmPushRows={jest.fn()}
/>,
);
expect(screen.getByText("库存已推")).toHaveAttribute(
"title",
"固定虚拟库存已于 2026-08-10T09:00:01.000Z 推送",
);
expect(screen.queryByText("库存失败")).not.toBeInTheDocument();
});
});

View File

@ -38,6 +38,29 @@ function ReadOnlyCell({ value }: { value: string }) {
}
function getVirtualInventoryStatusMeta(row: ProductPricingDisplayRow) {
if (row.fixedVirtualInventoryStatus === "pending") {
const retryCount = Number(row.fixedVirtualInventoryRetryCount || "0");
return {
label: retryCount > 0 ? "库存重试中" : "库存待推",
tone: "bg-[#e7f1ff] text-[#1764ad]",
title: row.fixedVirtualInventoryError
? `固定虚拟库存推送失败,将于 ${row.fixedVirtualInventoryScheduledAt || "稍后"} 重试:${row.fixedVirtualInventoryError}`
: row.fixedVirtualInventoryScheduledAt
? `固定虚拟库存已入队,预计 ${row.fixedVirtualInventoryScheduledAt} 发起`
: "固定虚拟库存已入推送队列",
};
}
if (row.fixedVirtualInventoryStatus === "pushed") {
return {
label: "库存已推",
tone: "bg-[#dff7e8] text-[#137a38]",
title: row.fixedVirtualInventoryPushedAt
? `固定虚拟库存已于 ${row.fixedVirtualInventoryPushedAt} 推送`
: "固定虚拟库存已推送",
};
}
const virtualInventorySkipTitle =
row.jstVirtualInventoryError || "无需推虚拟库存";
const normalizedVirtualInventoryError =

View File

@ -6,6 +6,7 @@ import type {
import {
applyProductLimitWorkbookRows,
buildProductLimitRows,
normalizePricingRowsForPayload,
} from "./productWorkflowRowHelpers"
function createSkuItem(
@ -111,4 +112,26 @@ describe("productWorkflowRowHelpers", () => {
},
])
})
it("does not include fixed virtual inventory task fields in pricing saves", () => {
expect(
normalizePricingRowsForPayload([
{
skuId: "sku-test-1",
costPrice: "88",
productTag: "",
fixedVirtualInventoryStatus: "pushed",
fixedVirtualInventoryPushedAt: "2026-08-10T09:00:01.000Z",
fixedVirtualInventoryRetryCount: "2",
fixedVirtualInventoryError: "previous failure",
},
]),
).toEqual([
{
skuId: "sku-test-1",
costPrice: "88",
productTag: "",
},
])
})
})

View File

@ -130,6 +130,17 @@ export function createProductPricingRowFromSku(
jstVirtualInventoryError:
existingRow?.jstVirtualInventoryError?.trim() ?? "",
jstVirtualInventorySoId: existingRow?.jstVirtualInventorySoId?.trim() ?? "",
fixedVirtualInventoryStatus: existingRow?.fixedVirtualInventoryStatus,
fixedVirtualInventoryScheduledAt:
existingRow?.fixedVirtualInventoryScheduledAt?.trim() ?? "",
fixedVirtualInventoryPushedAt:
existingRow?.fixedVirtualInventoryPushedAt?.trim() ?? "",
fixedVirtualInventoryRetryCount:
existingRow?.fixedVirtualInventoryRetryCount?.trim() ?? "",
fixedVirtualInventoryLastAttemptAt:
existingRow?.fixedVirtualInventoryLastAttemptAt?.trim() ?? "",
fixedVirtualInventoryError:
existingRow?.fixedVirtualInventoryError?.trim() ?? "",
};
}

View File

@ -54,6 +54,22 @@ class JushuitanFixedVirtualInventoryTaskRepository {
});
}
async findByProductId(productId: string) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.findMany({
where: { productId },
orderBy: [{ skuItemId: "asc" }, { createdAt: "asc" }],
select: {
skuItemId: true,
status: true,
scheduledAt: true,
retryCount: true,
lastAttemptAt: true,
pushedAt: true,
error: true,
},
});
}
async markSucceeded(input: FixedVirtualInventorySuccessInput) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.update({
where: { id: input.id },
@ -86,7 +102,11 @@ export type JushuitanFixedVirtualInventoryTaskRecord =
IntakeJushuitanFixedVirtualInventoryTask;
export type JushuitanFixedVirtualInventoryTaskRepositoryContract = Pick<
JushuitanFixedVirtualInventoryTaskRepository,
"createMissing" | "findDue" | "markSucceeded" | "scheduleRetry"
| "createMissing"
| "findDue"
| "findByProductId"
| "markSucceeded"
| "scheduleRetry"
>;
export const jushuitanFixedVirtualInventoryTaskRepository =

View File

@ -94,6 +94,12 @@ jest.mock('../../repositories/draft-group.repository', () => ({
},
}))
jest.mock('../../repositories/jushuitan-fixed-virtual-inventory-task.repository', () => ({
jushuitanFixedVirtualInventoryTaskRepository: {
findByProductId: jest.fn(),
},
}))
jest.mock('../spu-number-reservation.service', () => ({
spuNumberReservationService: {
reserveInTransaction: jest.fn(),
@ -136,6 +142,7 @@ import { intakeNonIntegratedProductRepository } from '../../repositories/intake-
import { userRepository } from '../../repositories/user.repository'
import { assetSkuGroupRepository, assetVariantRepository } from '../../repositories/asset-center.repository'
import { draftGroupRepository, preSkuRepository } from '../../repositories/draft-group.repository'
import { jushuitanFixedVirtualInventoryTaskRepository } from '../../repositories/jushuitan-fixed-virtual-inventory-task.repository'
import { prisma } from '@/lib/prisma'
import { ErrorCode } from '@/server/core/errors'
import {
@ -157,6 +164,9 @@ const mockedAssetSkuGroupRepository = jest.mocked(assetSkuGroupRepository)
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
const mockedDraftGroupRepository = jest.mocked(draftGroupRepository)
const mockedPreSkuRepository = jest.mocked(preSkuRepository)
const mockedFixedVirtualInventoryTaskRepository = jest.mocked(
jushuitanFixedVirtualInventoryTaskRepository,
)
const mockedSpuNumberReservationService = jest.mocked(spuNumberReservationService)
const mockedPrisma = jest.mocked(prisma)
const mockedNodePermissionSettingsService = jest.mocked(intakeNodePermissionSettingsService)
@ -447,6 +457,7 @@ describe('intakeNonIntegratedProductService', () => {
mockedRepository.findActiveRecordsBySpuInTransaction.mockResolvedValue([] as never)
mockedNodePermissionSettingsService.getSettings.mockResolvedValue({ overrides: {} })
mockedUserRepository.findIdsByBusinessRole.mockResolvedValue([])
mockedFixedVirtualInventoryTaskRepository.findByProductId.mockResolvedValue([])
})
it('lists all shared records when permissions are relaxed', async () => {
@ -1237,6 +1248,53 @@ describe('intakeNonIntegratedProductService', () => {
])
})
it('returns fixed virtual inventory task status alongside the legacy status', async () => {
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
payload: createPayload({
skuItems: [createSkuItem({ id: 'sku-1' })],
pricingRows: [
{
skuId: 'sku-1',
costPrice: '199.00',
productTag: '',
jstVirtualInventoryStatus: 'failed',
jstVirtualInventoryError: '聚水潭未找到对应仓库',
},
],
}),
}) as never,
)
mockedFixedVirtualInventoryTaskRepository.findByProductId.mockResolvedValue([
{
skuItemId: 'sku-1',
status: 'pushed',
scheduledAt: new Date('2026-08-10T09:00:00.000Z'),
retryCount: 0,
lastAttemptAt: new Date('2026-08-10T09:00:00.000Z'),
pushedAt: new Date('2026-08-10T09:00:01.000Z'),
error: '',
},
] as never)
const product = await intakeNonIntegratedProductService.getById(
'non-integrated-1',
baseUser,
)
expect(mockedFixedVirtualInventoryTaskRepository.findByProductId).toHaveBeenCalledWith(
'non-integrated-1',
)
expect(product?.pricingRows).toEqual([
expect.objectContaining({
skuId: 'sku-1',
jstVirtualInventoryStatus: 'failed',
fixedVirtualInventoryStatus: 'pushed',
fixedVirtualInventoryPushedAt: '2026-08-10T09:00:01.000Z',
}),
])
})
it('preserves the legacy modular tag after all pricing rows are already entered', async () => {
mockedRepository.findActiveById.mockResolvedValue(
createRecord({

View File

@ -70,6 +70,7 @@ import {
draftGroupRepository,
preSkuRepository,
} from '../repositories/draft-group.repository'
import { jushuitanFixedVirtualInventoryTaskRepository } from '../repositories/jushuitan-fixed-virtual-inventory-task.repository'
import {
assertNonIntegratedProductMasterDataReadyForFormalUse,
resolveNonIntegratedSkuQuality,
@ -1979,6 +1980,51 @@ function buildStructuredProductRecordFromRecord(record: IntakeNonIntegratedRecor
return applyPersistedRecordIdentity(applySharedProductTag(hydratedProduct), record)
}
async function hydrateFixedVirtualInventoryStatuses(
product: SharedNonIntegratedProductRecord,
): Promise<SharedNonIntegratedProductRecord> {
const tasks = await jushuitanFixedVirtualInventoryTaskRepository.findByProductId(
product.id,
)
if (tasks.length === 0 || product.pricingRows.length === 0) return product
const taskBySkuItemId = new Map(tasks.map((task) => [task.skuItemId, task]))
let changed = false
const pricingRows = product.pricingRows.map((row) => {
const task = taskBySkuItemId.get(row.skuId)
const fixedVirtualInventoryStatus: NonIntegratedPricingRow['fixedVirtualInventoryStatus'] =
task?.status === 'pending'
? 'pending'
: task?.status === 'pushed'
? 'pushed'
: undefined
if (!task || !fixedVirtualInventoryStatus) {
return row
}
changed = true
return {
...row,
fixedVirtualInventoryStatus,
...(task.scheduledAt
? { fixedVirtualInventoryScheduledAt: task.scheduledAt.toISOString() }
: {}),
...(task.pushedAt
? { fixedVirtualInventoryPushedAt: task.pushedAt.toISOString() }
: {}),
...(task.retryCount > 0
? { fixedVirtualInventoryRetryCount: String(task.retryCount) }
: {}),
...(task.lastAttemptAt
? { fixedVirtualInventoryLastAttemptAt: task.lastAttemptAt.toISOString() }
: {}),
...(task.error ? { fixedVirtualInventoryError: task.error } : {}),
}
})
return changed ? { ...product, pricingRows } : product
}
function buildBarcodePrintProductFromPricingRecord(record: BarcodePrintPricingRecord) {
return buildStructuredProductRecordFromRecord({
...record.product,
@ -6265,7 +6311,9 @@ class IntakeNonIntegratedProductService {
if (!scopedProduct) return null
const hydratedProduct = await hydrateSeriesProductDisplayName(scopedProduct)
return hydrateProductSkuModelSnapshots(hydratedProduct)
const productWithInventoryStatus =
await hydrateFixedVirtualInventoryStatuses(hydratedProduct)
return hydrateProductSkuModelSnapshots(productWithInventoryStatus)
}
async getDownloadableById(

View File

@ -43,6 +43,7 @@ function createRepository(tasks: JushuitanFixedVirtualInventoryTaskRecord[]) {
return {
createMissing: jest.fn(async () => 1),
findDue: jest.fn(async () => tasks),
findByProductId: jest.fn(async () => []),
markSucceeded: jest.fn(async () => undefined),
scheduleRetry: jest.fn(async () => undefined),
} satisfies JushuitanFixedVirtualInventoryTaskRepositoryContract;