Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-cost-dynamic-20260807

This commit is contained in:
WuYongJie 2026-08-10 12:14:30 +08:00
commit c353757d6b
8 changed files with 575 additions and 2 deletions

View File

@ -0,0 +1,31 @@
CREATE TABLE "IntakeJushuitanFixedVirtualInventoryTask" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productSpu" TEXT NOT NULL DEFAULT '',
"skuItemId" TEXT NOT NULL,
"skuCode" TEXT NOT NULL DEFAULT '',
"barcode" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'pending',
"scheduledAt" TIMESTAMP(3) NOT NULL,
"retryCount" INTEGER NOT NULL DEFAULT 0,
"lastAttemptAt" TIMESTAMP(3),
"pushedAt" TIMESTAMP(3),
"error" TEXT NOT NULL DEFAULT '',
"requestId" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeJushuitanFixedVirtualInventoryTask_pkey" PRIMARY KEY ("id"),
CONSTRAINT "IntakeJushuitanFixedVirtualInventoryTask_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "IntakeJushuitanFixedVirtualInventoryTask_productId_skuItemId_key"
ON "IntakeJushuitanFixedVirtualInventoryTask"("productId", "skuItemId");
CREATE INDEX "IntakeJushuitanFixedVirtualInventoryTask_status_scheduledAt_idx"
ON "IntakeJushuitanFixedVirtualInventoryTask"("status", "scheduledAt");
CREATE INDEX "IntakeJushuitanFixedVirtualInventoryTask_productId_idx"
ON "IntakeJushuitanFixedVirtualInventoryTask"("productId");

View File

@ -256,6 +256,7 @@ model IntakeNonIntegratedProduct {
skuItems IntakeNonIntegratedSkuItem[]
pricingRows IntakeNonIntegratedPricingRow[]
productLimitRows IntakeNonIntegratedProductLimitRow[]
fixedVirtualInventoryTasks IntakeJushuitanFixedVirtualInventoryTask[]
profile IntakeNonIntegratedProductProfile?
modelAssets IntakeProductModelAsset[]
@ -671,6 +672,29 @@ model IntakeNonIntegratedPricingRow {
@@index([systemEnteredAt, productId])
}
model IntakeJushuitanFixedVirtualInventoryTask {
id String @id @default(cuid())
productId String
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
productSpu String @default("")
skuItemId String
skuCode String @default("")
barcode String @default("")
status String @default("pending")
scheduledAt DateTime
retryCount Int @default(0)
lastAttemptAt DateTime?
pushedAt DateTime?
error String @default("")
requestId String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, skuItemId])
@@index([status, scheduledAt])
@@index([productId])
}
model IntakeNonIntegratedProductLimitRow {
id String @id
productId String

View File

@ -3,6 +3,7 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
import { jushuitanProductPricingPushService } from '@/server/services/jushuitan-product-pricing-push.service'
import { jushuitanFixedVirtualInventoryService } from '@/server/services/jushuitan-fixed-virtual-inventory.service'
import {
OperationLogAction,
OperationLogStatus,
@ -69,6 +70,11 @@ export async function POST(request: NextRequest, context: RouteContext) {
product,
targetSkuIds,
})
const fixedVirtualInventoryQueuedCount =
await jushuitanFixedVirtualInventoryService.enqueueForProduct(
product,
result.pushedSkuIds,
)
await operationLogService.record({
action: OperationLogAction.JUSHUITAN_PRODUCT_PUSH,
@ -84,10 +90,15 @@ export async function POST(request: NextRequest, context: RouteContext) {
normalItemCount: result.normalItemCount,
combineItemCount: result.combineItemCount,
virtualInventoryPendingCount: result.virtualInventoryPendingCount,
fixedVirtualInventoryQueuedCount,
},
})
return jsonSuccess(result, 200, ctx.requestId)
return jsonSuccess(
{ ...result, fixedVirtualInventoryQueuedCount },
200,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)

View File

@ -14,6 +14,7 @@ import {
applyVirtualInventoryRunFailureToRows,
isDueVirtualInventoryRow,
} from '@/server/services/jushuitan-virtual-inventory-queue.service'
import { jushuitanFixedVirtualInventoryService } from '@/server/services/jushuitan-fixed-virtual-inventory.service'
function getCronUser(request: NextRequest): AuthUser | null {
const cronSecret = process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET?.trim()
@ -225,6 +226,15 @@ export async function POST(request: NextRequest) {
}
}
const fixedVirtualInventory =
await jushuitanFixedVirtualInventoryService.processDue({
actor: user,
source: logSource,
actorId,
requestId: ctx.requestId,
now,
})
return jsonSuccess(
{
processedProductCount: processedProducts.length,
@ -232,6 +242,7 @@ export async function POST(request: NextRequest) {
skippedCount: processedProducts.reduce((sum, item) => sum + item.skipped, 0),
failedCount: processedProducts.reduce((sum, item) => sum + item.failed, 0),
processedProducts,
fixedVirtualInventory,
},
200,
ctx.requestId,

View File

@ -0,0 +1,93 @@
import type { IntakeJushuitanFixedVirtualInventoryTask } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export type CreateFixedVirtualInventoryTaskInput = {
productId: string;
productSpu: string;
skuItemId: string;
skuCode: string;
barcode: string;
scheduledAt: Date;
};
export type FixedVirtualInventoryRetryInput = {
id: string;
attemptedAt: Date;
scheduledAt: Date;
error: string;
requestId: string;
};
export type FixedVirtualInventorySuccessInput = {
id: string;
attemptedAt: Date;
requestId: string;
};
class JushuitanFixedVirtualInventoryTaskRepository {
async createMissing(tasks: CreateFixedVirtualInventoryTaskInput[]) {
if (tasks.length === 0) return 0;
const result =
await prisma.intakeJushuitanFixedVirtualInventoryTask.createMany({
data: tasks.map((task) => ({
...task,
status: "pending",
retryCount: 0,
error: "",
requestId: "",
})),
skipDuplicates: true,
});
return result.count;
}
async findDue(now: Date, take = 100) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.findMany({
where: {
status: "pending",
scheduledAt: { lte: now },
},
orderBy: [{ scheduledAt: "asc" }, { createdAt: "asc" }],
take,
});
}
async markSucceeded(input: FixedVirtualInventorySuccessInput) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.update({
where: { id: input.id },
data: {
status: "pushed",
pushedAt: input.attemptedAt,
lastAttemptAt: input.attemptedAt,
error: "",
requestId: input.requestId,
},
});
}
async scheduleRetry(input: FixedVirtualInventoryRetryInput) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.update({
where: { id: input.id },
data: {
status: "pending",
scheduledAt: input.scheduledAt,
retryCount: { increment: 1 },
lastAttemptAt: input.attemptedAt,
error: input.error,
requestId: input.requestId,
},
});
}
}
export type JushuitanFixedVirtualInventoryTaskRecord =
IntakeJushuitanFixedVirtualInventoryTask;
export type JushuitanFixedVirtualInventoryTaskRepositoryContract = Pick<
JushuitanFixedVirtualInventoryTaskRepository,
"createMissing" | "findDue" | "markSucceeded" | "scheduleRetry"
>;
export const jushuitanFixedVirtualInventoryTaskRepository =
new JushuitanFixedVirtualInventoryTaskRepository();

View File

@ -0,0 +1,166 @@
import type { AuthUser } from "@/server/core/types";
import type {
JushuitanFixedVirtualInventoryTaskRepositoryContract,
JushuitanFixedVirtualInventoryTaskRecord,
} from "@/server/repositories/jushuitan-fixed-virtual-inventory-task.repository";
import {
OperationLogStatus,
operationLogService,
} from "./operation-log.service";
import {
buildFixedVirtualInventoryPayload,
FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
JushuitanFixedVirtualInventoryService,
} from "./jushuitan-fixed-virtual-inventory.service";
function createTask(
overrides: Partial<JushuitanFixedVirtualInventoryTaskRecord> = {},
): JushuitanFixedVirtualInventoryTaskRecord {
return {
id: "task-1",
productId: "product-1",
productSpu: "SPU-1",
skuItemId: "sku-1",
skuCode: "SKU-1",
barcode: "BARCODE-1",
status: "pending",
scheduledAt: new Date("2026-08-10T00:00:00.000Z"),
retryCount: 0,
lastAttemptAt: null,
pushedAt: null,
error: "",
requestId: "",
createdAt: new Date("2026-08-09T00:00:00.000Z"),
updatedAt: new Date("2026-08-09T00:00:00.000Z"),
...overrides,
};
}
function createRepository(tasks: JushuitanFixedVirtualInventoryTaskRecord[]) {
return {
createMissing: jest.fn(async () => 1),
findDue: jest.fn(async () => tasks),
markSucceeded: jest.fn(async () => undefined),
scheduleRetry: jest.fn(async () => undefined),
} satisfies JushuitanFixedVirtualInventoryTaskRepositoryContract;
}
const actor = {
id: "user-1",
email: "user@example.test",
name: "Test User",
role: "SUPER_ADMIN",
status: "ACTIVE",
businessRoles: [],
} as AuthUser;
describe("Jushuitan fixed virtual inventory service", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("builds the fixed warehouse and quantity payload from a barcode", () => {
expect(buildFixedVirtualInventoryPayload("BARCODE-1")).toEqual({
wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
items: [
{ sku_id: "BARCODE-1", qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY },
],
});
});
it("enqueues only the selected sku items", async () => {
const repository = createRepository([]);
const service = new JushuitanFixedVirtualInventoryService(repository);
await service.enqueueForProduct(
{
id: "product-1",
spu: "SPU-1",
skuItems: [
{ id: "sku-1", skuCode: "SKU-1", barcode: "BARCODE-1" } as never,
{ id: "sku-2", skuCode: "SKU-2", barcode: "BARCODE-2" } as never,
],
},
["sku-2"],
);
expect(repository.createMissing).toHaveBeenCalledWith([
expect.objectContaining({
productId: "product-1",
skuItemId: "sku-2",
barcode: "BARCODE-2",
}),
]);
});
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);
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"),
});
expect(result).toEqual({ processed: 1, pushed: 1, failed: 0 });
expect(post).toHaveBeenCalledWith(
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
buildFixedVirtualInventoryPayload("BARCODE-1"),
);
expect(repository.markSucceeded).toHaveBeenCalledWith({
id: "task-1",
attemptedAt: new Date("2026-08-10T00:00:00.000Z"),
requestId: "jst-request-1",
});
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ status: OperationLogStatus.SUCCESS }),
);
});
it("schedules every failed task exactly two hours later", async () => {
const repository = createRepository([createTask()]);
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,
});
expect(result).toEqual({ processed: 1, pushed: 0, failed: 1 });
expect(repository.scheduleRetry).toHaveBeenCalledWith({
id: "task-1",
attemptedAt: now,
scheduledAt: new Date(
now.getTime() + FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
),
error: "Jushuitan rejected the request",
requestId: "local-request-1",
});
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ status: OperationLogStatus.FAILED }),
);
});
});

View File

@ -0,0 +1,230 @@
import type { AuthUser } from "@/server/core/types";
import type { NonIntegratedSkuItem } from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
postJushuitanWithDefaultCredentials,
type JushuitanApiResult,
} from "./jushuitan-product-pricing-push.service";
import {
OperationLogAction,
OperationLogStatus,
operationLogService,
} from "./operation-log.service";
import {
jushuitanFixedVirtualInventoryTaskRepository,
type JushuitanFixedVirtualInventoryTaskRepositoryContract,
type JushuitanFixedVirtualInventoryTaskRecord,
} from "@/server/repositories/jushuitan-fixed-virtual-inventory-task.repository";
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;
type FixedVirtualInventoryProduct = {
id: string;
spu: string;
skuItems: NonIntegratedSkuItem[];
};
type FixedVirtualInventoryRunInput = {
actor: AuthUser;
source: "USER" | "SYSTEM";
actorId?: string;
requestId: string;
now?: Date;
};
type FixedVirtualInventoryApi = (
path: string,
biz: Record<string, unknown>,
) => Promise<JushuitanApiResult>;
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;
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,
items: [
{
sku_id: barcode,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
},
],
};
}
class JushuitanFixedVirtualInventoryService {
constructor(
private readonly repository: JushuitanFixedVirtualInventoryTaskRepositoryContract = jushuitanFixedVirtualInventoryTaskRepository,
private readonly post: FixedVirtualInventoryApi = postJushuitanWithDefaultCredentials,
) {}
async enqueueForProduct(
product: FixedVirtualInventoryProduct,
targetSkuIds: string[],
now = new Date(),
) {
const targetSkuIdSet = new Set(targetSkuIds);
const tasks = product.skuItems
.filter((sku) => targetSkuIdSet.has(sku.id))
.map((sku) => ({
productId: product.id,
productSpu: product.spu,
skuItemId: sku.id,
skuCode: sku.skuCode,
barcode: sku.barcode,
scheduledAt: now,
}));
return this.repository.createMissing(tasks);
}
async processDue(input: FixedVirtualInventoryRunInput) {
const now = input.now ?? new Date();
const tasks = await this.repository.findDue(now);
const result = { processed: 0, pushed: 0, failed: 0 };
for (const task of tasks) {
result.processed += 1;
if (await this.processOne(task, input)) {
result.pushed += 1;
} else {
result.failed += 1;
}
}
return result;
}
private async processOne(
task: JushuitanFixedVirtualInventoryTaskRecord,
input: FixedVirtualInventoryRunInput,
) {
const attemptedAt = input.now ?? new Date();
const barcode = task.barcode.trim();
if (!barcode) {
return this.scheduleRetry(
task,
input,
attemptedAt,
"SKU barcode is empty",
);
}
const payload = buildFixedVirtualInventoryPayload(barcode);
try {
const response = await this.post(
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
payload,
);
const requestId = readRequestId(response.raw) || input.requestId;
await this.repository.markSucceeded({
id: 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 push succeeded",
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;
} catch (error) {
return this.scheduleRetry(
task,
input,
attemptedAt,
normalizeError(error),
);
}
}
private async scheduleRetry(
task: JushuitanFixedVirtualInventoryTaskRecord,
input: FixedVirtualInventoryRunInput,
attemptedAt: Date,
reason: string,
) {
const scheduledAt = new Date(
attemptedAt.getTime() + FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS,
);
await this.repository.scheduleRetry({
id: 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;
}
}
export const jushuitanFixedVirtualInventoryService =
new JushuitanFixedVirtualInventoryService();
export {
JushuitanFixedVirtualInventoryService,
buildFixedVirtualInventoryPayload,
};

View File

@ -50,7 +50,7 @@ export type JushuitanVirtualInventoryPushInput = {
targetSkuIds: string[]
}
type JushuitanApiResult = {
export type JushuitanApiResult = {
path: string
code: string | number | null
message: string
@ -625,6 +625,13 @@ async function postJushuitan(
}
}
export async function postJushuitanWithDefaultCredentials(
path: string,
biz: Record<string, unknown>,
) {
return postJushuitan(readJushuitanCredentials(), path, biz)
}
function requireText(value: string, label: string) {
const normalizedValue = value.trim()
if (!normalizedValue) {