merge: release daily Jushuitan fixed virtual inventory

This commit is contained in:
WuYongJie 2026-08-25 14:27:28 +08:00
commit 77b3b3c800
6 changed files with 191 additions and 24 deletions

View File

@ -0,0 +1,76 @@
export {}
const mockHandleRequest = jest.fn()
const mockJsonError = jest.fn()
const mockJsonSuccess = jest.fn()
const mockProcessDue = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonError: (...args: unknown[]) => mockJsonError(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
}))
jest.mock('@/server/services/jushuitan-fixed-virtual-inventory.service', () => ({
jushuitanFixedVirtualInventoryService: {
processDue: (...args: unknown[]) => mockProcessDue(...args),
},
}))
const { POST } = require('./route')
function createRequest(input: { secret?: string } = {}) {
return {
headers: {
get: (name: string) =>
name === 'x-jst-virtual-inventory-secret' ? input.secret ?? null : null,
},
}
}
describe('POST fixed Jushuitan virtual inventory push', () => {
beforeEach(() => {
jest.clearAllMocks()
process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET = 'cron-secret'
mockHandleRequest.mockImplementation(
async (_request: unknown, handler: (ctx: unknown) => unknown) =>
handler({ requestId: 'request-1', user: null }),
)
mockJsonError.mockImplementation((code, message, status, requestId) => ({
code,
message,
status,
requestId,
}))
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
data,
status,
requestId,
}))
mockProcessDue.mockResolvedValue({ processed: 1, pushed: 1, failed: 0 })
})
afterAll(() => {
delete process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET
})
it('rejects a request without the cron secret', async () => {
const response = await POST(createRequest())
expect(response.status).toBe(401)
expect(mockProcessDue).not.toHaveBeenCalled()
})
it('runs the daily batch for the authenticated 20:00 request', async () => {
const response = await POST(createRequest({ secret: 'cron-secret' }))
expect(mockProcessDue).toHaveBeenCalledWith(
expect.objectContaining({ source: 'SYSTEM' }),
)
expect(response.data).toEqual({
processed: 1,
pushed: 1,
failed: 0,
})
})
})

View File

@ -0,0 +1,47 @@
import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
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()
if (!cronSecret) return null
const headerSecret =
request.headers.get('x-jst-virtual-inventory-secret')?.trim() ?? ''
if (headerSecret !== cronSecret) return null
return {
id: 'jst-fixed-virtual-inventory-cron',
email: 'jst-fixed-virtual-inventory-cron@local',
name: '聚水潭固定虚拟库存定时任务',
role: 'SUPER_ADMIN',
status: 'ACTIVE',
businessRoles: [],
}
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const user = ctx.user ?? getCronUser(request)
if (!user) {
return jsonError(
ErrorCode.AUTH_INVALID_TOKEN,
'请先登录或配置固定虚拟库存定时任务密钥',
401,
ctx.requestId,
)
}
const result = await jushuitanFixedVirtualInventoryService.processDue({
actor: user,
source: ctx.user ? 'USER' : 'SYSTEM',
actorId: ctx.user?.id,
requestId: ctx.requestId,
now: new Date(),
})
return jsonSuccess(result, 200, ctx.requestId)
}, { requireAuth: false })
}

View File

@ -13,7 +13,6 @@ import {
applyVirtualInventoryPushResultToRows,
applyVirtualInventoryRunFailureToRows,
} from '@/server/services/jushuitan-virtual-inventory-queue.service'
import { jushuitanFixedVirtualInventoryService } from '@/server/services/jushuitan-fixed-virtual-inventory.service'
const DUE_PRODUCT_PAGE_SIZE = 20
const MAX_DUE_PRODUCTS_PER_RUN = 100
@ -425,16 +424,6 @@ export async function POST(request: NextRequest) {
if (dueProductIds.length < requestedPageSize) break
}
const fixedVirtualInventory = ctx.user
? { processed: 0, pushed: 0, failed: 0 }
: await jushuitanFixedVirtualInventoryService.processDue({
actor: user,
source: logSource,
actorId,
requestId: ctx.requestId,
now,
})
return jsonSuccess(
{
processedProductCount: processedProducts.length,
@ -446,7 +435,6 @@ export async function POST(request: NextRequest) {
0,
),
processedProducts,
fixedVirtualInventory,
},
200,
ctx.requestId,

View File

@ -66,7 +66,7 @@ describe("Jushuitan fixed virtual inventory service", () => {
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_and_qty: [
{ sku_id: "BARCODE-1", qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY },
],
});
@ -132,6 +132,36 @@ describe("Jushuitan fixed virtual inventory service", () => {
);
});
it("retries a code-zero response without an 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: {} },
}));
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.markSucceeded).not.toHaveBeenCalled();
expect(repository.scheduleRetry).toHaveBeenCalledWith(
expect.objectContaining({
id: "task-1",
attemptedAt: 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 post = jest.fn(async () => {
@ -164,4 +194,5 @@ describe("Jushuitan fixed virtual inventory service", () => {
expect.objectContaining({ status: OperationLogStatus.FAILED }),
);
});
});

View File

@ -20,6 +20,8 @@ export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH =
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 = {
id: string;
@ -68,7 +70,7 @@ function normalizeError(error: unknown) {
function buildFixedVirtualInventoryPayload(barcode: string) {
return {
wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID,
items: [
sku_and_qty: [
{
sku_id: barcode,
qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY,
@ -105,16 +107,26 @@ class JushuitanFixedVirtualInventoryService {
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;
while (result.processed < MAX_FIXED_VIRTUAL_INVENTORY_TASKS_PER_RUN) {
const take = Math.min(
FIXED_VIRTUAL_INVENTORY_BATCH_SIZE,
MAX_FIXED_VIRTUAL_INVENTORY_TASKS_PER_RUN - result.processed,
);
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;
}
}
if (tasks.length < take) break;
}
return result;
@ -141,7 +153,12 @@ class JushuitanFixedVirtualInventoryService {
JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH,
payload,
);
const requestId = readRequestId(response.raw) || input.requestId;
const requestId = readRequestId(response.raw);
if (!requestId) {
throw new Error(
"Jushuitan fixed virtual inventory response is missing request_id",
);
}
await this.repository.markSucceeded({
id: task.id,
attemptedAt,
@ -157,7 +174,7 @@ class JushuitanFixedVirtualInventoryService {
productSpu: task.productSpu,
skuId: task.skuItemId,
skuCode: task.skuCode,
message: "Jushuitan fixed virtual inventory push succeeded",
message: "Jushuitan fixed virtual inventory request accepted",
requestId,
details: {
flow: "fixed-wms-virtual-inventory",

View File

@ -387,19 +387,27 @@ start_pm2_release() {
install_virtual_inventory_push_cron() {
local cron_file
local cron_line
local fixed_cron_line
local lock_file="/tmp/furniture-3d-system-virtual-inventory-push.lock"
local fixed_lock_file="/tmp/furniture-3d-system-fixed-virtual-inventory-push.lock"
local endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-virtual-inventory-push"
local fixed_endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push"
local log_file="$SHARED/logs/jushuitan-virtual-inventory-push.log"
local fixed_log_file="$SHARED/logs/jushuitan-fixed-virtual-inventory-push.log"
mkdir -p "$SHARED/logs"
cron_file="$(mktemp)"
(crontab -l 2>/dev/null || true) |
sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' |
sed '\|furniture-3d-system-virtual-inventory-push|d' > "$cron_file"
sed '\|furniture-3d-system-virtual-inventory-push|d' |
sed '\|/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push|d' |
sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' > "$cron_file"
cron_line="* * * * * /usr/bin/flock -n \"$lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 55 --request POST \"$endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$log_file\" 2>&1 # furniture-3d-system-virtual-inventory-push"
fixed_cron_line="0 20 * * * /usr/bin/flock -n \"$fixed_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 1800 --request POST \"$fixed_endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$fixed_log_file\" 2>&1 # furniture-3d-system-fixed-virtual-inventory-push"
printf '%s\n' "$cron_line" >> "$cron_file"
printf '%s\n' "$fixed_cron_line" >> "$cron_file"
crontab "$cron_file"
rm -f -- "$cron_file"
}