fix(jushuitan): batch fixed virtual inventory pushes

This commit is contained in:
WuYongJie 2026-09-18 09:20:17 +08:00
parent e8f8087f78
commit fc12269838
5 changed files with 521 additions and 193 deletions

View File

@ -0,0 +1,85 @@
/** @jest-environment node */
jest.mock("@/lib/prisma", () => ({
prisma: {
intakeJushuitanFixedVirtualInventoryTask: {
updateMany: jest.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
import { JushuitanFixedVirtualInventoryTaskRepository } from "./jushuitan-fixed-virtual-inventory-task.repository";
const updateMany = prisma.intakeJushuitanFixedVirtualInventoryTask
.updateMany as jest.Mock;
describe("JushuitanFixedVirtualInventoryTaskRepository", () => {
beforeEach(() => {
jest.clearAllMocks();
updateMany.mockResolvedValue({ count: 2 });
});
it("marks a successful external batch in one update", async () => {
const attemptedAt = new Date("2026-09-17T10:00:00.000Z");
const repository = new JushuitanFixedVirtualInventoryTaskRepository();
await repository.markManySucceeded({
ids: ["task-1", "task-2"],
attemptedAt,
requestId: "jst-request-1",
});
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: ["task-1", "task-2"] } },
data: {
status: "pushed",
pushedAt: attemptedAt,
lastAttemptAt: attemptedAt,
error: "",
requestId: "jst-request-1",
},
});
});
it("schedules every task in a failed batch in one update", async () => {
const attemptedAt = new Date("2026-09-17T10:00:00.000Z");
const scheduledAt = new Date("2026-09-17T12:00:00.000Z");
const repository = new JushuitanFixedVirtualInventoryTaskRepository();
await repository.scheduleManyRetry({
ids: ["task-1", "task-2"],
attemptedAt,
scheduledAt,
error: "rate limited",
requestId: "local-request-1",
});
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: ["task-1", "task-2"] } },
data: {
status: "pending",
scheduledAt,
retryCount: { increment: 1 },
lastAttemptAt: attemptedAt,
error: "rate limited",
requestId: "local-request-1",
},
});
});
it("does not issue an update for an empty batch", async () => {
const repository = new JushuitanFixedVirtualInventoryTaskRepository();
const attemptedAt = new Date("2026-09-17T10:00:00.000Z");
await expect(
repository.markManySucceeded({
ids: [],
attemptedAt,
requestId: "jst-request-1",
}),
).resolves.toEqual({ count: 0 });
expect(updateMany).not.toHaveBeenCalled();
});
});

View File

@ -11,7 +11,7 @@ export type CreateFixedVirtualInventoryTaskInput = {
};
export type FixedVirtualInventoryRetryInput = {
id: string;
ids: string[];
attemptedAt: Date;
scheduledAt: Date;
error: string;
@ -19,12 +19,12 @@ export type FixedVirtualInventoryRetryInput = {
};
export type FixedVirtualInventorySuccessInput = {
id: string;
ids: string[];
attemptedAt: Date;
requestId: string;
};
class JushuitanFixedVirtualInventoryTaskRepository {
export class JushuitanFixedVirtualInventoryTaskRepository {
async createMissing(tasks: CreateFixedVirtualInventoryTaskInput[]) {
if (tasks.length === 0) return 0;
@ -70,9 +70,11 @@ class JushuitanFixedVirtualInventoryTaskRepository {
});
}
async markSucceeded(input: FixedVirtualInventorySuccessInput) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.update({
where: { id: input.id },
async markManySucceeded(input: FixedVirtualInventorySuccessInput) {
if (input.ids.length === 0) return { count: 0 };
return prisma.intakeJushuitanFixedVirtualInventoryTask.updateMany({
where: { id: { in: input.ids } },
data: {
status: "pushed",
pushedAt: input.attemptedAt,
@ -83,9 +85,11 @@ class JushuitanFixedVirtualInventoryTaskRepository {
});
}
async scheduleRetry(input: FixedVirtualInventoryRetryInput) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.update({
where: { id: input.id },
async scheduleManyRetry(input: FixedVirtualInventoryRetryInput) {
if (input.ids.length === 0) return { count: 0 };
return prisma.intakeJushuitanFixedVirtualInventoryTask.updateMany({
where: { id: { in: input.ids } },
data: {
status: "pending",
scheduledAt: input.scheduledAt,
@ -105,8 +109,8 @@ export type JushuitanFixedVirtualInventoryTaskRepositoryContract = Pick<
| "createMissing"
| "findDue"
| "findByProductId"
| "markSucceeded"
| "scheduleRetry"
| "markManySucceeded"
| "scheduleManyRetry"
>;
export const jushuitanFixedVirtualInventoryTaskRepository =

View File

@ -0,0 +1,48 @@
export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH =
"/open/webapi/itemapi/iteminventory/batchupdatewmsvirtualqtys";
export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID = 15994660;
export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY = 100000;
export const FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS = 2 * 60 * 60 * 1000;
export const FIXED_VIRTUAL_INVENTORY_BATCH_SIZE = 100;
export const DEFAULT_FIXED_VIRTUAL_INVENTORY_REQUEST_INTERVAL_MS = 3000;
export function readFixedVirtualInventoryRequestId(raw: unknown) {
if (!raw || typeof raw !== "object") return "";
const record = raw as Record<string, unknown>;
const data = record.data;
const dataRecord =
data && typeof data === "object" ? (data as Record<string, unknown>) : null;
for (const value of [
record.request_id,
record.requestId,
dataRecord?.request_id,
dataRecord?.requestId,
]) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
export function normalizeFixedVirtualInventoryError(error: unknown) {
return error instanceof Error
? error.message
: "Jushuitan fixed virtual inventory push failed";
}
export function isFixedVirtualInventoryRateLimited(error: unknown) {
return /code\s*=\s*(?:199|200)\b|调用(?:太|过于)频繁|请求(?:太|过于)频繁|too many requests|rate.?limit|\b429\b/i.test(
normalizeFixedVirtualInventoryError(error),
);
}
export function buildFixedVirtualInventoryPayload(barcodes: string[]) {
return {
wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
sku_and_qty: barcodes.map((barcode) => ({
sku_id: barcode,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
})),
};
}

View File

@ -17,15 +17,16 @@ import {
} from "./jushuitan-fixed-virtual-inventory.service";
function createTask(
index = 1,
overrides: Partial<JushuitanFixedVirtualInventoryTaskRecord> = {},
): JushuitanFixedVirtualInventoryTaskRecord {
return {
id: "task-1",
id: `task-${index}`,
productId: "product-1",
productSpu: "SPU-1",
skuItemId: "sku-1",
skuCode: "SKU-1",
barcode: "BARCODE-1",
skuItemId: `sku-${index}`,
skuCode: `SKU-${index}`,
barcode: `BARCODE-${index}`,
status: "pending",
scheduledAt: new Date("2026-08-10T00:00:00.000Z"),
retryCount: 0,
@ -39,13 +40,16 @@ function createTask(
};
}
function createRepository(tasks: JushuitanFixedVirtualInventoryTaskRecord[]) {
function createRepository(
batches: JushuitanFixedVirtualInventoryTaskRecord[][],
) {
const pendingBatches = [...batches];
return {
createMissing: jest.fn(async () => 1),
findDue: jest.fn(async () => tasks),
findDue: jest.fn(async () => pendingBatches.shift() ?? []),
findByProductId: jest.fn(async () => []),
markSucceeded: jest.fn(async () => undefined),
scheduleRetry: jest.fn(async () => undefined),
markManySucceeded: jest.fn(async () => ({ count: 0 })),
scheduleManyRetry: jest.fn(async () => ({ count: 0 })),
} satisfies JushuitanFixedVirtualInventoryTaskRepositoryContract;
}
@ -58,16 +62,41 @@ const actor = {
businessRoles: [],
} as AuthUser;
const runInput = {
actor,
source: "SYSTEM" as const,
requestId: "local-request-1",
now: new Date("2026-08-10T00:00:00.000Z"),
};
function successfulResponse(requestId: string) {
return {
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
code: 0,
message: "ok",
raw: { code: 0, data: { request_id: requestId } },
};
}
describe("Jushuitan fixed virtual inventory service", () => {
beforeEach(() => {
jest
.spyOn(operationLogService, "record")
.mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("builds the fixed warehouse and quantity payload from a barcode", () => {
expect(buildFixedVirtualInventoryPayload("BARCODE-1")).toEqual({
it("builds one fixed warehouse payload for multiple barcodes", () => {
expect(
buildFixedVirtualInventoryPayload(["BARCODE-1", "BARCODE-2"]),
).toEqual({
wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
sku_and_qty: [
{ sku_id: "BARCODE-1", qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY },
{ sku_id: "BARCODE-2", qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY },
],
});
});
@ -97,102 +126,212 @@ describe("Jushuitan fixed virtual inventory service", () => {
]);
});
it("pushes a due task and records the external request id", async () => {
const repository = createRepository([createTask()]);
const post = jest.fn(async () => ({
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
code: 0,
message: "ok",
raw: { code: 0, data: { request_id: "jst-request-1" } },
}));
const record = jest
.spyOn(operationLogService, "record")
.mockResolvedValue(undefined);
it("pushes a due batch and records the external request id", async () => {
const repository = createRepository([[createTask()]]);
const post = jest.fn(async () => successfulResponse("jst-request-1"));
const service = new JushuitanFixedVirtualInventoryService(repository, post);
const result = await service.processDue({
actor,
source: "SYSTEM",
requestId: "local-request-1",
now: new Date("2026-08-10T00:00:00.000Z"),
});
const result = await service.processDue(runInput);
expect(result).toEqual({ processed: 1, pushed: 1, failed: 0 });
expect(post).toHaveBeenCalledWith(
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
buildFixedVirtualInventoryPayload("BARCODE-1"),
buildFixedVirtualInventoryPayload(["BARCODE-1"]),
);
expect(repository.markSucceeded).toHaveBeenCalledWith({
id: "task-1",
attemptedAt: new Date("2026-08-10T00:00:00.000Z"),
expect(repository.markManySucceeded).toHaveBeenCalledWith({
ids: ["task-1"],
attemptedAt: runInput.now,
requestId: "jst-request-1",
});
expect(record).toHaveBeenCalledWith(
expect(operationLogService.record).toHaveBeenCalledWith(
expect.objectContaining({ status: OperationLogStatus.SUCCESS }),
);
});
it("retries a code-zero response without an external request id", async () => {
const repository = createRepository([createTask()]);
it("shares one active run when the scheduler is triggered twice", async () => {
const repository = createRepository([]);
const task = createTask();
let releaseFindDue:
| ((tasks: JushuitanFixedVirtualInventoryTaskRecord[]) => void)
| undefined;
repository.findDue.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseFindDue = resolve;
}),
);
const post = jest.fn(async () => successfulResponse("jst-request-1"));
const service = new JushuitanFixedVirtualInventoryService(repository, post);
const firstRun = service.processDue(runInput);
const secondRun = service.processDue(runInput);
expect(repository.findDue).toHaveBeenCalledTimes(1);
releaseFindDue?.([task]);
await expect(Promise.all([firstRun, secondRun])).resolves.toEqual([
{ processed: 1, pushed: 1, failed: 0 },
{ processed: 1, pushed: 1, failed: 0 },
]);
expect(post).toHaveBeenCalledTimes(1);
});
it("sends 224 tasks as serial batches of 100, 100, and 24", async () => {
const tasks = Array.from({ length: 224 }, (_, index) =>
createTask(index + 1),
);
const repository = createRepository([
tasks.slice(0, 100),
tasks.slice(100, 200),
tasks.slice(200),
]);
const post = jest
.fn()
.mockResolvedValueOnce(successfulResponse("jst-request-1"))
.mockResolvedValueOnce(successfulResponse("jst-request-2"))
.mockResolvedValueOnce(successfulResponse("jst-request-3"));
const sleep = jest.fn(async () => undefined);
const service = new JushuitanFixedVirtualInventoryService(
repository,
post,
{ requestIntervalMs: 3000, sleep },
);
await expect(service.processDue(runInput)).resolves.toEqual({
processed: 224,
pushed: 224,
failed: 0,
});
expect(post).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2);
expect(
post.mock.calls.map(([, payload]) =>
(payload.sku_and_qty as unknown[]).length,
),
).toEqual([100, 100, 24]);
expect(repository.markManySucceeded).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
ids: tasks.slice(200).map((task) => task.id),
requestId: "jst-request-3",
}),
);
});
it.each(["199", "200"])(
"stops after a code %s rate-limited batch and leaves later tasks untouched",
async (code) => {
const tasks = Array.from({ length: 224 }, (_, index) =>
createTask(index + 1),
);
const repository = createRepository([
tasks.slice(0, 100),
tasks.slice(100, 200),
tasks.slice(200),
]);
const post = jest
.fn()
.mockResolvedValueOnce(successfulResponse("jst-request-1"))
.mockRejectedValueOnce(
new Error(`聚水潭接口失败:code=${code},调用太频繁,请稍后再试`),
);
const service = new JushuitanFixedVirtualInventoryService(
repository,
post,
{ requestIntervalMs: 0 },
);
await expect(service.processDue(runInput)).resolves.toEqual({
processed: 200,
pushed: 100,
failed: 100,
});
expect(post).toHaveBeenCalledTimes(2);
expect(repository.findDue).toHaveBeenCalledTimes(2);
expect(repository.scheduleManyRetry).toHaveBeenCalledWith(
expect.objectContaining({
ids: tasks.slice(100, 200).map((task) => task.id),
}),
);
expect(repository.markManySucceeded).not.toHaveBeenCalledWith(
expect.objectContaining({
ids: tasks.slice(200).map((task) => task.id),
}),
);
},
);
it("retries empty barcodes without including them in the batch request", async () => {
const emptyTask = createTask(1, { barcode: " " });
const validTask = createTask(2);
const repository = createRepository([[emptyTask, validTask]]);
const post = jest.fn(async () => successfulResponse("jst-request-1"));
const service = new JushuitanFixedVirtualInventoryService(repository, post);
await expect(service.processDue(runInput)).resolves.toEqual({
processed: 2,
pushed: 1,
failed: 1,
});
expect(post).toHaveBeenCalledWith(
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
buildFixedVirtualInventoryPayload([validTask.barcode]),
);
expect(repository.scheduleManyRetry).toHaveBeenCalledWith(
expect.objectContaining({
ids: [emptyTask.id],
error: "SKU barcode is empty",
}),
);
});
it("retries a code-zero batch response without an external request id", async () => {
const tasks = [createTask(1), createTask(2)];
const repository = createRepository([tasks]);
const post = jest.fn(async () => ({
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
code: 0,
message: "ok",
raw: { code: 0, data: {} },
}));
jest.spyOn(operationLogService, "record").mockResolvedValue(undefined);
const service = new JushuitanFixedVirtualInventoryService(repository, post);
const now = new Date("2026-08-10T00:00:00.000Z");
const result = await service.processDue({
actor,
source: "SYSTEM",
requestId: "local-request-1",
now,
});
const result = await service.processDue(runInput);
expect(result).toEqual({ processed: 1, pushed: 0, failed: 1 });
expect(repository.markSucceeded).not.toHaveBeenCalled();
expect(repository.scheduleRetry).toHaveBeenCalledWith(
expect(result).toEqual({ processed: 2, pushed: 0, failed: 2 });
expect(repository.markManySucceeded).not.toHaveBeenCalled();
expect(repository.scheduleManyRetry).toHaveBeenCalledWith(
expect.objectContaining({
id: "task-1",
attemptedAt: now,
ids: tasks.map((task) => task.id),
attemptedAt: runInput.now,
error: "Jushuitan fixed virtual inventory response is missing request_id",
}),
);
});
it("schedules every failed task exactly two hours later", async () => {
const repository = createRepository([createTask()]);
const tasks = [createTask(1), createTask(2)];
const repository = createRepository([tasks]);
const post = jest.fn(async () => {
throw new Error("Jushuitan rejected the request");
});
const record = jest
.spyOn(operationLogService, "record")
.mockResolvedValue(undefined);
const service = new JushuitanFixedVirtualInventoryService(repository, post);
const now = new Date("2026-08-10T00:00:00.000Z");
const result = await service.processDue({
actor,
source: "SYSTEM",
requestId: "local-request-1",
now,
});
const result = await service.processDue(runInput);
expect(result).toEqual({ processed: 1, pushed: 0, failed: 1 });
expect(repository.scheduleRetry).toHaveBeenCalledWith({
id: "task-1",
attemptedAt: now,
expect(result).toEqual({ processed: 2, pushed: 0, failed: 2 });
expect(repository.scheduleManyRetry).toHaveBeenCalledWith({
ids: tasks.map((task) => task.id),
attemptedAt: runInput.now,
scheduledAt: new Date(
now.getTime() + FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
runInput.now.getTime() + FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
),
error: "Jushuitan rejected the request",
requestId: "local-request-1",
});
expect(record).toHaveBeenCalledWith(
expect(operationLogService.record).toHaveBeenCalledWith(
expect.objectContaining({ status: OperationLogStatus.FAILED }),
);
});
});

View File

@ -14,13 +14,19 @@ import {
type JushuitanFixedVirtualInventoryTaskRepositoryContract,
type JushuitanFixedVirtualInventoryTaskRecord,
} from "@/server/repositories/jushuitan-fixed-virtual-inventory-task.repository";
import {
buildFixedVirtualInventoryPayload,
DEFAULT_FIXED_VIRTUAL_INVENTORY_REQUEST_INTERVAL_MS,
FIXED_VIRTUAL_INVENTORY_BATCH_SIZE,
FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
isFixedVirtualInventoryRateLimited,
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
normalizeFixedVirtualInventoryError,
readFixedVirtualInventoryRequestId,
} from "./jushuitan-fixed-virtual-inventory.helpers";
export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH =
"/open/webapi/itemapi/iteminventory/batchupdatewmsvirtualqtys";
export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID = 15994660;
export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY = 100000;
export const FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS = 2 * 60 * 60 * 1000;
const FIXED_VIRTUAL_INVENTORY_BATCH_SIZE = 100;
const MAX_FIXED_VIRTUAL_INVENTORY_TASKS_PER_RUN = 5000;
type FixedVirtualInventoryProduct = {
@ -28,7 +34,6 @@ type FixedVirtualInventoryProduct = {
spu: string;
skuItems: NonIntegratedSkuItem[];
};
type FixedVirtualInventoryRunInput = {
actor: AuthUser;
source: "USER" | "SYSTEM";
@ -36,53 +41,28 @@ type FixedVirtualInventoryRunInput = {
requestId: string;
now?: Date;
};
type FixedVirtualInventoryApi = (
path: string,
biz: Record<string, unknown>,
) => Promise<JushuitanApiResult>;
type FixedVirtualInventoryRunResult = {
processed: number;
pushed: number;
failed: number;
};
type FixedVirtualInventoryServiceOptions = {
requestIntervalMs?: number;
sleep?: (milliseconds: number) => Promise<void>;
};
function readRequestId(raw: unknown) {
if (!raw || typeof raw !== "object") return "";
const record = raw as Record<string, unknown>;
const data = record.data;
const dataRecord =
data && typeof data === "object" ? (data as Record<string, unknown>) : null;
export class JushuitanFixedVirtualInventoryService {
private activeRun: Promise<FixedVirtualInventoryRunResult> | null = null;
private lastRequestAt = 0;
for (const value of [
record.request_id,
record.requestId,
dataRecord?.request_id,
dataRecord?.requestId,
]) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function normalizeError(error: unknown) {
return error instanceof Error
? error.message
: "Jushuitan fixed virtual inventory push failed";
}
function buildFixedVirtualInventoryPayload(barcode: string) {
return {
wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
sku_and_qty: [
{
sku_id: barcode,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
},
],
};
}
class JushuitanFixedVirtualInventoryService {
constructor(
private readonly repository: JushuitanFixedVirtualInventoryTaskRepositoryContract = jushuitanFixedVirtualInventoryTaskRepository,
private readonly post: FixedVirtualInventoryApi = postJushuitanWithDefaultCredentials,
private readonly options: FixedVirtualInventoryServiceOptions = {},
) {}
async enqueueForProduct(
@ -106,6 +86,20 @@ class JushuitanFixedVirtualInventoryService {
}
async processDue(input: FixedVirtualInventoryRunInput) {
if (this.activeRun) return this.activeRun;
const run = this.processDueInternal(input);
this.activeRun = run;
try {
return await run;
} finally {
if (this.activeRun === run) this.activeRun = null;
}
}
private async processDueInternal(
input: FixedVirtualInventoryRunInput,
): Promise<FixedVirtualInventoryRunResult> {
const now = input.now ?? new Date();
const result = { processed: 0, pushed: 0, failed: 0 };
@ -117,13 +111,13 @@ class JushuitanFixedVirtualInventoryService {
const tasks = await this.repository.findDue(now, take);
if (tasks.length === 0) break;
for (const task of tasks) {
result.processed += 1;
if (await this.processOne(task, input)) {
result.pushed += 1;
} else {
result.failed += 1;
}
const batchResult = await this.processBatch(tasks, input);
result.processed += tasks.length;
result.pushed += batchResult.pushed;
result.failed += batchResult.failed;
if (batchResult.rateLimited) {
break;
}
if (tasks.length < take) break;
@ -132,71 +126,99 @@ class JushuitanFixedVirtualInventoryService {
return result;
}
private async processOne(
task: JushuitanFixedVirtualInventoryTaskRecord,
private async processBatch(
tasks: JushuitanFixedVirtualInventoryTaskRecord[],
input: FixedVirtualInventoryRunInput,
) {
const attemptedAt = input.now ?? new Date();
const barcode = task.barcode.trim();
if (!barcode) {
return this.scheduleRetry(
task,
const missingBarcodeTasks = tasks.filter((task) => !task.barcode.trim());
const pushableTasks = tasks.filter((task) => task.barcode.trim());
if (missingBarcodeTasks.length > 0) {
await this.scheduleRetry(
missingBarcodeTasks,
input,
attemptedAt,
"SKU barcode is empty",
);
}
const payload = buildFixedVirtualInventoryPayload(barcode);
if (pushableTasks.length === 0) {
return {
pushed: 0,
failed: missingBarcodeTasks.length,
rateLimited: false,
};
}
const payload = buildFixedVirtualInventoryPayload(
pushableTasks.map((task) => task.barcode.trim()),
);
try {
await this.waitForRequestTurn();
const response = await this.post(
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
payload,
);
const requestId = readRequestId(response.raw);
const requestId = readFixedVirtualInventoryRequestId(response.raw);
if (!requestId) {
throw new Error(
"Jushuitan fixed virtual inventory response is missing request_id",
);
}
await this.repository.markSucceeded({
id: task.id,
await this.repository.markManySucceeded({
ids: pushableTasks.map((task) => task.id),
attemptedAt,
requestId,
});
await operationLogService.record({
action: OperationLogAction.JUSHUITAN_VIRTUAL_INVENTORY_PUSH,
status: OperationLogStatus.SUCCESS,
source: input.source,
actorId: input.actorId,
actorName: input.actor.name,
productId: task.productId,
productSpu: task.productSpu,
skuId: task.skuItemId,
skuCode: task.skuCode,
message: "Jushuitan fixed virtual inventory request accepted",
requestId,
details: {
flow: "fixed-wms-virtual-inventory",
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
wmsCoId: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
barcode: task.barcode,
},
});
return true;
await Promise.allSettled(
pushableTasks.map((task) =>
operationLogService.record({
action: OperationLogAction.JUSHUITAN_VIRTUAL_INVENTORY_PUSH,
status: OperationLogStatus.SUCCESS,
source: input.source,
actorId: input.actorId,
actorName: input.actor.name,
productId: task.productId,
productSpu: task.productSpu,
skuId: task.skuItemId,
skuCode: task.skuCode,
message: "Jushuitan fixed virtual inventory request accepted",
requestId,
details: {
flow: "fixed-wms-virtual-inventory",
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
wmsCoId: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
barcode: task.barcode,
batchSize: pushableTasks.length,
},
}),
),
);
return {
pushed: pushableTasks.length,
failed: missingBarcodeTasks.length,
rateLimited: false,
};
} catch (error) {
return this.scheduleRetry(
task,
await this.scheduleRetry(
pushableTasks,
input,
attemptedAt,
normalizeError(error),
normalizeFixedVirtualInventoryError(error),
);
return {
pushed: 0,
failed: tasks.length,
rateLimited: isFixedVirtualInventoryRateLimited(error),
};
}
}
private async scheduleRetry(
task: JushuitanFixedVirtualInventoryTaskRecord,
tasks: JushuitanFixedVirtualInventoryTaskRecord[],
input: FixedVirtualInventoryRunInput,
attemptedAt: Date,
reason: string,
@ -204,44 +226,74 @@ class JushuitanFixedVirtualInventoryService {
const scheduledAt = new Date(
attemptedAt.getTime() + FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
);
await this.repository.scheduleRetry({
id: task.id,
await this.repository.scheduleManyRetry({
ids: tasks.map((task) => task.id),
attemptedAt,
scheduledAt,
error: reason,
requestId: input.requestId,
});
await operationLogService.record({
action: OperationLogAction.JUSHUITAN_VIRTUAL_INVENTORY_PUSH,
status: OperationLogStatus.FAILED,
source: input.source,
actorId: input.actorId,
actorName: input.actor.name,
productId: task.productId,
productSpu: task.productSpu,
skuId: task.skuItemId,
skuCode: task.skuCode,
errorCode: "JUSHUITAN_FIXED_VIRTUAL_INVENTORY_FAILED",
message: `Jushuitan fixed virtual inventory push failed; retry at ${scheduledAt.toISOString()}: ${reason}`,
requestId: input.requestId,
details: {
flow: "fixed-wms-virtual-inventory",
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
wmsCoId: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
barcode: task.barcode,
scheduledAt: scheduledAt.toISOString(),
reason,
},
});
return false;
await Promise.allSettled(
tasks.map((task) =>
operationLogService.record({
action: OperationLogAction.JUSHUITAN_VIRTUAL_INVENTORY_PUSH,
status: OperationLogStatus.FAILED,
source: input.source,
actorId: input.actorId,
actorName: input.actor.name,
productId: task.productId,
productSpu: task.productSpu,
skuId: task.skuItemId,
skuCode: task.skuCode,
errorCode: "JUSHUITAN_FIXED_VIRTUAL_INVENTORY_FAILED",
message: `Jushuitan fixed virtual inventory push failed; retry at ${scheduledAt.toISOString()}: ${reason}`,
requestId: input.requestId,
details: {
flow: "fixed-wms-virtual-inventory",
path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
wmsCoId: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
barcode: task.barcode,
batchSize: tasks.length,
scheduledAt: scheduledAt.toISOString(),
reason,
},
}),
),
);
}
private async waitForRequestTurn() {
const intervalMs = this.getRequestIntervalMs();
const waitMs = Math.max(
0,
this.lastRequestAt + intervalMs - Date.now(),
);
if (waitMs > 0) {
const sleep =
this.options.sleep ??
((milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
await sleep(waitMs);
}
this.lastRequestAt = Date.now();
}
private getRequestIntervalMs() {
if (this.options.requestIntervalMs !== undefined) {
return Math.max(0, this.options.requestIntervalMs);
}
const configured = Number.parseInt(
process.env.JST_FIXED_VIRTUAL_INVENTORY_REQUEST_INTERVAL_MS?.trim() ?? "",
10,
);
return Number.isFinite(configured) && configured >= 0
? configured
: DEFAULT_FIXED_VIRTUAL_INVENTORY_REQUEST_INTERVAL_MS;
}
}
export const jushuitanFixedVirtualInventoryService =
new JushuitanFixedVirtualInventoryService();
export {
JushuitanFixedVirtualInventoryService,
buildFixedVirtualInventoryPayload,
};
export * from "./jushuitan-fixed-virtual-inventory.helpers";