fix(jushuitan): batch fixed virtual inventory pushes
This commit is contained in:
parent
e8f8087f78
commit
fc12269838
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 =
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -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 }),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user