feat(M09-D2): 完成商品订单与库存占用生命周期
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
|
||||
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
|
||||
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
|
||||
import {
|
||||
registerProductOrderRoutes,
|
||||
type ProductOrderRouteOptions
|
||||
} from './routes/product-orders.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -94,6 +98,7 @@ export interface BuildAppOptions {
|
||||
adminAuth?: AdminAuthRouteOptions;
|
||||
products?: ProductRouteOptions;
|
||||
inventory?: InventoryRouteOptions;
|
||||
productOrders?: ProductOrderRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -216,6 +221,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.inventory) {
|
||||
await registerInventoryRoutes(app, options.inventory);
|
||||
}
|
||||
if (options.productOrders) {
|
||||
await registerProductOrderRoutes(app, options.productOrders);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -140,20 +140,23 @@ export class AuthRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -38,7 +38,8 @@ export class RbacRepository {
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
'product.catalog.read', 'inventory.read',
|
||||
'goods.order.read', 'goods.order.manage'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
@@ -48,6 +49,7 @@ export class RbacRepository {
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
@@ -55,6 +57,7 @@ export class RbacRepository {
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
|
||||
@@ -71,7 +71,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -109,9 +110,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||
@@ -366,7 +369,8 @@ async function executeMigrationPlanUnlocked(
|
||||
1, 2, 3, 1,
|
||||
3, 7, 3, 1,
|
||||
2, 8, 4, 1,
|
||||
9, 63, 18, 28, 15, 2, 4, 1, 1
|
||||
9, 63, 18, 28, 15, 2, 4, 1, 1,
|
||||
7, 54, 22, 18, 34, 6, 2, 1, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -226,6 +226,7 @@ interface DeltaState {
|
||||
lockedDelta: number;
|
||||
lossDelta: number;
|
||||
reservationDelta: number;
|
||||
saleDelta: number;
|
||||
next: StockState;
|
||||
}
|
||||
|
||||
@@ -416,7 +417,8 @@ export class InventoryService {
|
||||
lowStockThreshold: input.policyType === 'UNLIMITED' ? 0 : threshold
|
||||
};
|
||||
return this.persistMutation(connection, stock, identity, {
|
||||
availableDelta: 0, lockedDelta: 0, lossDelta: 0, reservationDelta: 0, next
|
||||
availableDelta: 0, lockedDelta: 0, lossDelta: 0,
|
||||
reservationDelta: 0, saleDelta: 0, next
|
||||
}, auditFromActor(actor));
|
||||
});
|
||||
}
|
||||
@@ -499,11 +501,22 @@ export class InventoryService {
|
||||
|
||||
async deductMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
|
||||
return this.batchMutation(input, 'DEDUCT', (stock, quantity) => {
|
||||
if (stock.policyType === 'UNLIMITED') return delta(stock, 0, 0, 0, -quantity);
|
||||
if (stock.policyType === 'UNLIMITED') {
|
||||
return delta(stock, 0, 0, 0, -quantity, quantity);
|
||||
}
|
||||
if (number(stock.lockedQuantity) < quantity) {
|
||||
throw new InventoryError('INVENTORY_INSUFFICIENT_LOCKED');
|
||||
}
|
||||
return delta(stock, 0, -quantity, 0, -quantity);
|
||||
return delta(stock, 0, -quantity, 0, -quantity, quantity);
|
||||
}, connection);
|
||||
}
|
||||
|
||||
async returnMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
|
||||
return this.batchMutation(input, 'RETURN', (stock, quantity) => {
|
||||
if (stock.policyType === 'UNLIMITED') {
|
||||
return delta(stock, 0, 0, 0, 0, -quantity);
|
||||
}
|
||||
return delta(stock, quantity, 0, 0, 0, -quantity);
|
||||
}, connection);
|
||||
}
|
||||
|
||||
@@ -554,7 +567,7 @@ export class InventoryService {
|
||||
|
||||
private async batchMutation(
|
||||
input: InventoryBatchMutationInput,
|
||||
operation: 'LOCK' | 'RELEASE' | 'DEDUCT',
|
||||
operation: 'LOCK' | 'RELEASE' | 'DEDUCT' | 'RETURN',
|
||||
calculate: (stock: InventoryRow, quantity: number) => DeltaState,
|
||||
externalConnection?: PoolConnection
|
||||
) {
|
||||
@@ -605,11 +618,16 @@ export class InventoryService {
|
||||
}
|
||||
return { items: duplicates as InventoryMutationResult[], idempotent: true };
|
||||
}
|
||||
if (operation !== 'LOCK') {
|
||||
if (operation === 'RELEASE' || operation === 'DEDUCT') {
|
||||
await this.assertReservationBalances(
|
||||
connection, normalized.tenantId, normalized.storeId,
|
||||
normalized.businessType, normalized.businessId, stocks
|
||||
);
|
||||
} else if (operation === 'RETURN') {
|
||||
await this.assertReturnBalances(
|
||||
connection, normalized.tenantId, normalized.storeId,
|
||||
normalized.businessType, normalized.businessId, stocks
|
||||
);
|
||||
}
|
||||
const changes = stocks.map(({ stock, quantity }) => ({
|
||||
stock, change: calculate(stock, quantity)
|
||||
@@ -762,6 +780,48 @@ export class InventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertReturnBalances(
|
||||
connection: PoolConnection,
|
||||
tenantId: string,
|
||||
storeId: string,
|
||||
businessType: string,
|
||||
businessId: string,
|
||||
stocks: Array<{ stock: InventoryRow; quantity: number }>
|
||||
) {
|
||||
const placeholders = stocks.map(() => '?').join(', ');
|
||||
const [rows] = await connection.execute<ReservationRow[]>(
|
||||
`SELECT inventory_id AS inventoryId,
|
||||
COALESCE(SUM(COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.inventorySaleDelta'))
|
||||
AS SIGNED),
|
||||
CASE
|
||||
WHEN operation = 'DEDUCT' THEN -COALESCE(
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata,
|
||||
'$.inventoryReservationDelta')) AS SIGNED), locked_delta)
|
||||
WHEN operation = 'RETURN' THEN available_delta
|
||||
ELSE 0
|
||||
END
|
||||
)), 0) AS reservedQuantity
|
||||
FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND store_id = ?
|
||||
AND business_type = ? AND business_id = ?
|
||||
AND operation IN ('DEDUCT', 'RETURN')
|
||||
AND inventory_id IN (${placeholders})
|
||||
GROUP BY inventory_id
|
||||
FOR SHARE`,
|
||||
[tenantId, storeId, businessType, businessId,
|
||||
...stocks.map(({ stock }) => stock.id)]
|
||||
);
|
||||
const balances = new Map(
|
||||
rows.map((row) => [String(row.inventoryId), number(row.reservedQuantity)])
|
||||
);
|
||||
for (const { stock, quantity } of stocks) {
|
||||
if ((balances.get(stock.id) ?? 0) < quantity) {
|
||||
throw new InventoryError('INVENTORY_INSUFFICIENT_DEDUCTED');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertNoActiveReservations(
|
||||
connection: PoolConnection,
|
||||
stock: InventoryRow
|
||||
@@ -883,6 +943,7 @@ export class InventoryService {
|
||||
...identity.metadata,
|
||||
idempotencyFingerprint: identity.fingerprint,
|
||||
inventoryReservationDelta: change.reservationDelta,
|
||||
inventorySaleDelta: change.saleDelta,
|
||||
resultPolicyType: change.next.policyType,
|
||||
resultLowStockThreshold: change.next.lowStockThreshold
|
||||
};
|
||||
@@ -943,6 +1004,7 @@ export class InventoryService {
|
||||
lockedDelta: change.lockedDelta,
|
||||
lossDelta: change.lossDelta,
|
||||
reservationDelta: change.reservationDelta,
|
||||
saleDelta: change.saleDelta,
|
||||
versionAfter: number(stock.version) + 1
|
||||
})]
|
||||
);
|
||||
@@ -1088,7 +1150,8 @@ function delta(
|
||||
availableDelta: number,
|
||||
lockedDelta: number,
|
||||
lossDelta: number,
|
||||
reservationDelta = 0
|
||||
reservationDelta = 0,
|
||||
saleDelta = 0
|
||||
): DeltaState {
|
||||
const next = {
|
||||
policyType: stock.policyType,
|
||||
@@ -1098,7 +1161,7 @@ function delta(
|
||||
lowStockThreshold: number(stock.lowStockThreshold)
|
||||
};
|
||||
validateState(next);
|
||||
return { availableDelta, lockedDelta, lossDelta, reservationDelta, next };
|
||||
return { availableDelta, lockedDelta, lossDelta, reservationDelta, saleDelta, next };
|
||||
}
|
||||
|
||||
function validateState(state: StockState) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import {
|
||||
ProductOrderError,
|
||||
productOrderManagementActions,
|
||||
productOrderStatuses,
|
||||
type ProductOrderActor,
|
||||
type ProductOrderService
|
||||
} from '../products/product-order-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
|
||||
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
|
||||
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
|
||||
const orderParams = z.object({ orderId: id }).strict();
|
||||
const paymentParams = z.object({ paymentId: id }).strict();
|
||||
const refundParams = z.object({ refundId: id }).strict();
|
||||
const createSchema = z.object({
|
||||
storeId: id,
|
||||
requestId,
|
||||
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE']),
|
||||
roomOrderId: id.nullable().optional(),
|
||||
note: z.string().trim().max(512).default(''),
|
||||
items: z.array(z.object({
|
||||
skuId: id,
|
||||
quantity: z.number().int().min(1).max(1_000_000),
|
||||
note: z.string().trim().max(256).default('')
|
||||
}).strict()).min(1).max(100)
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.fulfillmentMode === 'DELIVERY' && !value.roomOrderId) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['roomOrderId'],
|
||||
message: 'roomOrderId is required for delivery orders'
|
||||
});
|
||||
}
|
||||
if (value.fulfillmentMode === 'SELF_SERVICE' && value.roomOrderId) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['roomOrderId'],
|
||||
message: 'roomOrderId is not allowed for self-service orders'
|
||||
});
|
||||
}
|
||||
});
|
||||
const customerListSchema = z.object({
|
||||
storeId: id.optional(), status: z.enum(productOrderStatuses).optional(), page, pageSize
|
||||
}).strict();
|
||||
const managementListSchema = z.object({
|
||||
storeId: id, status: z.enum(productOrderStatuses).optional(), page, pageSize
|
||||
}).strict();
|
||||
const cancelSchema = z.object({
|
||||
requestId, reason: z.string().trim().min(1).max(512)
|
||||
}).strict();
|
||||
const actionSchema = z.object({
|
||||
requestId,
|
||||
action: z.enum(productOrderManagementActions),
|
||||
reason: z.string().trim().max(512).default('')
|
||||
}).strict();
|
||||
const testPaymentSchema = z.object({ requestId, provider: z.literal('TEST') }).strict();
|
||||
const testCallbackSchema = z.object({
|
||||
callbackId: requestId,
|
||||
amountCents: z.number().int().min(1).max(100_000_000_000_000)
|
||||
}).strict();
|
||||
|
||||
export interface ProductOrderRouteOptions {
|
||||
service: Pick<ProductOrderService,
|
||||
'create' | 'listForCustomer' | 'getForCustomer' | 'cancelForCustomer'
|
||||
| 'listForManagement' | 'getForManagement' | 'managementAction'
|
||||
| 'createPaymentForCustomer' | 'completeTestPaymentForCustomer'
|
||||
| 'completeTestRefundForManagement'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
testAdapterEnabled?: boolean;
|
||||
}
|
||||
|
||||
export async function registerProductOrderRoutes(
|
||||
app: FastifyInstance,
|
||||
options: ProductOrderRouteOptions
|
||||
) {
|
||||
app.post('/app-api/product-orders', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const body = createSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.service.create(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/product-orders', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const query = customerListSchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listForCustomer(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/product-orders/:orderId', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getForCustomer(actor, params.data.orderId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-orders/:orderId/cancel', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
const body = cancelSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.cancelForCustomer(
|
||||
actor, params.data.orderId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
if (options.testAdapterEnabled) {
|
||||
app.post('/app-api/product-orders/:orderId/payments', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
const body = testPaymentSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.service.createPaymentForCustomer(
|
||||
actor, params.data.orderId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/product-payments/:paymentId/test-complete', async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, false);
|
||||
const params = paymentParams.safeParse(request.params);
|
||||
const body = testCallbackSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.completeTestPaymentForCustomer(
|
||||
actor, params.data.paymentId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
for (const prefix of ['/admin-api', '/app-api/management']) {
|
||||
app.get(`${prefix}/product-orders`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const query = managementListSchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listForManagement(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get(`${prefix}/product-orders/:orderId`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getForManagement(actor, params.data.orderId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post(`${prefix}/product-orders/:orderId/actions`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
const body = actionSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.managementAction(actor, params.data.orderId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
if (options.testAdapterEnabled) {
|
||||
app.post(`${prefix}/product-refunds/:refundId/test-complete`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const params = refundParams.safeParse(request.params);
|
||||
const body = testCallbackSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.completeTestRefundForManagement(
|
||||
actor, params.data.refundId, body.data
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: ProductOrderRouteOptions,
|
||||
management: boolean,
|
||||
write = false
|
||||
): Promise<ProductOrderActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
if (management) {
|
||||
const manager = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const capability = write ? 'goods.order.manage' : 'goods.order.read';
|
||||
if (!manager && !access.capabilities.includes(capability)
|
||||
&& !(write && access.capabilities.includes('goods.order.manage'))) {
|
||||
reply.status(403).send({
|
||||
code: 'PRODUCT_ORDER_OPERATION_FORBIDDEN',
|
||||
message: 'Product order permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
source: management ? 'MANAGEMENT' : 'CUSTOMER',
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProductOrderError)) throw error;
|
||||
const status = error.code === 'PRODUCT_ORDER_NOT_FOUND'
|
||||
|| error.code === 'PRODUCT_PAYMENT_NOT_FOUND'
|
||||
|| error.code === 'PRODUCT_REFUND_NOT_FOUND' ? 404
|
||||
: error.code.includes('FORBIDDEN') ? 403
|
||||
: error.code.includes('CONFLICT') || error.code.includes('NOT_ALLOWED')
|
||||
|| error.code.includes('IN_PROGRESS') ? 409
|
||||
: 400;
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The requested product order operation is not available.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'PRODUCT_ORDER_INPUT_INVALID',
|
||||
message: 'The product order input is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { SystemOperationsRepository } from './operations/system-operations-repos
|
||||
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
||||
import { InventoryService } from './inventory/inventory-service.js';
|
||||
import { ProductOrderService } from './products/product-order-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -58,6 +59,7 @@ const marketingBenefits = new MarketingBenefitService(pool);
|
||||
const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
||||
const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||
const inventoryService = new InventoryService(pool);
|
||||
const productOrderService = new ProductOrderService(pool, inventoryService);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
@@ -257,6 +259,13 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
productOrders: {
|
||||
service: productOrderService,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -167,6 +167,26 @@ function execute(sql, params) {
|
||||
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
|
||||
return [row ? [{ ...row }] : [], []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('inventorySaleDelta')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
for (const item of state.ledgers) {
|
||||
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|
||||
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|
||||
|| !inventoryIds.has(item.inventoryId)
|
||||
|| !['DEDUCT', 'RETURN'].includes(item.operation)) continue;
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
const fallback = item.operation === 'DEDUCT'
|
||||
? -Number(metadata.inventoryReservationDelta ?? item.lockedDelta)
|
||||
: Number(item.availableDelta);
|
||||
balances.set(item.inventoryId,
|
||||
(balances.get(item.inventoryId) ?? 0)
|
||||
+ Number(metadata.inventorySaleDelta ?? fallback));
|
||||
}
|
||||
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
|
||||
inventoryId, reservedQuantity
|
||||
})), []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
@@ -376,6 +396,51 @@ assert.deepEqual(
|
||||
[8, 0]
|
||||
);
|
||||
|
||||
const returned = await service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
});
|
||||
assert.equal(returned.idempotent, false);
|
||||
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
|
||||
const returnedReplay = await service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
});
|
||||
assert.equal(returnedReplay.idempotent, true);
|
||||
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
|
||||
items: [{ skuId: '5', quantity: 2 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'foreign-order-return', businessId: 'order-foreign',
|
||||
reason: '跨订单退款不得回补', items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-too-many', reason: '超额退款不得回补',
|
||||
items: [{ skuId: '5', quantity: 2 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.returnMany({
|
||||
...batchBase, requestId: 'order-return-case', businessId: 'ORDER-500',
|
||||
reason: '业务号大小写敏感', items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
|
||||
);
|
||||
|
||||
const beforeFailedBatch = structuredClone(state.stocks);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({
|
||||
@@ -443,4 +508,4 @@ assert.ok(history.items.length >= 7);
|
||||
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
|
||||
assert.equal(state.audits.length, state.ledgers.length);
|
||||
|
||||
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
|
||||
console.log('PASS: M09-D1 inventory policy, batch reservation/sale return lifecycle, idempotency, immutable ledger, scope and audit work.');
|
||||
|
||||
@@ -129,6 +129,15 @@ const productInventoryDownSql = read(
|
||||
const productInventoryVerifySql = read(
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
);
|
||||
const productOrderPaymentUpSql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql'
|
||||
);
|
||||
const productOrderPaymentDownSql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql'
|
||||
);
|
||||
const productOrderPaymentVerifySql = read(
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -611,4 +620,68 @@ assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
|
||||
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
|
||||
assert.match(productInventoryVerifySql, /'2026081107'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
|
||||
for (const table of [
|
||||
'qipai_product_orders',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_refund_events'
|
||||
]) {
|
||||
assert.match(productOrderPaymentUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||
assert.match(productOrderPaymentDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||
assert.match(productOrderPaymentVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.doesNotMatch(productOrderPaymentUpSql, /\bDECIMAL\b/i);
|
||||
for (const state of [
|
||||
'PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING',
|
||||
'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED',
|
||||
'REFUNDING', 'REFUNDED', 'REFUND_FAILED'
|
||||
]) assert.match(productOrderPaymentUpSql, new RegExp(`'${state}'`));
|
||||
assert.match(productOrderPaymentUpSql, /fulfillment_mode = 'DELIVERY' AND room_order_id IS NOT NULL/);
|
||||
assert.match(productOrderPaymentUpSql, /fulfillment_mode = 'SELF_SERVICE' AND room_order_id IS NULL/);
|
||||
assert.match(productOrderPaymentUpSql, /subtotal_cents = unit_price_cents \* quantity/);
|
||||
for (const snapshotColumn of [
|
||||
'product_code', 'product_name', 'sku_code', 'sku_name',
|
||||
'unit_name', 'attributes_snapshot', 'unit_price_cents', 'quantity', 'subtotal_cents'
|
||||
]) assert.match(productOrderPaymentUpSql, new RegExp(`\\b${snapshotColumn}\\b`));
|
||||
assert.match(productOrderPaymentUpSql, /order_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /payment_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /refund_no VARCHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /callback_id VARCHAR\(128\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_business_id VARCHAR\(128\) COLLATE utf8mb4_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /request_fingerprint CHAR\(64\) CHARACTER SET ascii COLLATE ascii_bin/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_order_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_provider_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_callback_provider/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_client_request/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_provider_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_payment_active_order/);
|
||||
assert.match(productOrderPaymentUpSql, /status IN \('PENDING', 'PROCESSING'\) THEN order_id/);
|
||||
assert.match(productOrderPaymentUpSql, /uq_qipai_product_refund_active_payment/);
|
||||
assert.match(productOrderPaymentUpSql, /status IN \('PENDING', 'PROCESSING'\) THEN payment_id/);
|
||||
assert.match(productOrderPaymentUpSql, /fk_qipai_product_order_inventory_request/);
|
||||
assert.match(productOrderPaymentUpSql, /fk_qipai_product_refund_inventory_request/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_disposition = 'RESTOCK'/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_disposition = 'NO_RESTOCK'/);
|
||||
assert.match(productOrderPaymentUpSql, /inventory_status IN \('LOCKED', 'DEDUCTED', 'RELEASED', 'RETURNED'\)/);
|
||||
assert.match(productOrderPaymentUpSql, /'COMPENSATION_REQUIRED', 'REFUNDED'/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_items_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_items_no_delete/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_events_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_order_events_no_delete/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_refund_events_no_update/);
|
||||
assert.match(productOrderPaymentUpSql, /qipai_product_refund_events_no_delete/);
|
||||
for (const permission of ['goods.order.read', 'goods.order.manage']) {
|
||||
const pattern = new RegExp(permission.replace('.', '\\.'));
|
||||
assert.match(productOrderPaymentUpSql, pattern);
|
||||
assert.match(productOrderPaymentDownSql, pattern);
|
||||
assert.match(productOrderPaymentVerifySql, pattern);
|
||||
}
|
||||
assert.match(productOrderPaymentUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
|
||||
assert.match(productOrderPaymentVerifySql, /fully_granted_goods_order_roles/);
|
||||
assert.match(productOrderPaymentVerifySql, /'2026081108'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D2 migration contracts are present.');
|
||||
|
||||
@@ -46,7 +46,8 @@ assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -59,11 +60,20 @@ assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql/
|
||||
);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081108_m09d2_product_order_payment_inventory\.verify\.sql$/
|
||||
);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||
< downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
);
|
||||
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
import {
|
||||
InventoryError, InventoryService
|
||||
} from '../dist/inventory/inventory-service.js';
|
||||
import {
|
||||
ProductOrderError, ProductOrderService
|
||||
} from '../dist/products/product-order-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -107,6 +110,13 @@ const expectedTables = [
|
||||
'qipai_product_inventory',
|
||||
'qipai_product_inventory_ledger',
|
||||
'qipai_product_inventory_requests',
|
||||
'qipai_product_order_events',
|
||||
'qipai_product_order_items',
|
||||
'qipai_product_orders',
|
||||
'qipai_product_payment_callbacks',
|
||||
'qipai_product_payments',
|
||||
'qipai_product_refund_events',
|
||||
'qipai_product_refunds',
|
||||
'qipai_product_skus',
|
||||
'qipai_product_store_hours',
|
||||
'qipai_product_store_listings',
|
||||
@@ -160,11 +170,28 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
repoRoot,
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation'
|
||||
);
|
||||
const [upSql, downSql] = await Promise.all([
|
||||
const productOrderMigrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8')
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
let productOrderDownAttempt = 0;
|
||||
const removeProductOrderDependents = async () => {
|
||||
productOrderDownAttempt += 1;
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${productOrderMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
checksum: `m09d2-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||
statements: splitSqlStatements(productOrderDownSql)
|
||||
});
|
||||
};
|
||||
|
||||
await removeProductOrderDependents();
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${migrationBase}.down.sql`,
|
||||
@@ -175,6 +202,7 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
|
||||
await removeProductOrderDependents();
|
||||
const interruptedDownStatements = splitSqlStatements(downSql).slice(0, -1);
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
@@ -236,14 +264,14 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||
'2026081006', '2026081107']
|
||||
'2026081006', '2026081107', '2026081108']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1931,7 +1959,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081107');
|
||||
assert.equal(overview.latestMigration.version, '2026081108');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -3997,6 +4025,292 @@ async function assertProductInventoryFoundation(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProductOrderPaymentInventory(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur
|
||||
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r
|
||||
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
|
||||
AND u.deleted_at IS NULL ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [customerRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur
|
||||
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r
|
||||
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'CUSTOMER'
|
||||
AND u.deleted_at IS NULL ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [storeRows] = await pool.query(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND name = 'M03A Store' AND deleted_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [categoryRows] = await pool.query(
|
||||
`SELECT id FROM qipai_product_categories
|
||||
WHERE tenant_id = ? AND store_id = ? AND category_code = 'M09D1-DRINKS'
|
||||
AND status = 'ACTIVE' AND deleted_at IS NULL ORDER BY id LIMIT 1`,
|
||||
[context.tenantId, storeRows[0]?.id]
|
||||
);
|
||||
assert.ok(adminRows[0] && customerRows[0] && storeRows[0] && categoryRows[0],
|
||||
'M09-D2 requires the M09-D1 tenant, customer, store and category fixtures.');
|
||||
const adminId = String(adminRows[0].id);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(storeRows[0].id);
|
||||
const categoryId = String(categoryRows[0].id);
|
||||
const rbac = new RbacRepository(pool);
|
||||
const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId);
|
||||
for (const capability of ['goods.order.read', 'goods.order.manage']) {
|
||||
assert.ok(adminAccess.capabilities.includes(capability),
|
||||
`missing M09-D2 capability ${capability}`);
|
||||
}
|
||||
const adminActor = {
|
||||
tenantId: context.tenantId, userId: adminId, access: adminAccess,
|
||||
source: 'MANAGEMENT', traceId: 'm09d2-live-admin', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D2 live MySQL admin test'
|
||||
};
|
||||
const customerActor = {
|
||||
tenantId: context.tenantId, userId: customerId,
|
||||
access: await rbac.getAccessProfile(context.tenantId, customerId),
|
||||
source: 'CUSTOMER', traceId: 'm09d2-live-customer', ip: '127.0.0.1',
|
||||
userAgent: 'M09-D2 live MySQL customer test'
|
||||
};
|
||||
const catalog = new ProductCatalogRepository(pool);
|
||||
const inventory = new InventoryService(pool);
|
||||
const service = new ProductOrderService(pool, inventory, {
|
||||
now: () => new Date('2026-08-10T15:00:00.000Z'),
|
||||
paymentHoldMinutes: 15
|
||||
});
|
||||
|
||||
const product = await catalog.createProduct(adminActor, {
|
||||
productCode: 'M09D2-ORDER-DRINK', name: 'M09D2 Order Drink', unitName: 'bottle',
|
||||
description: 'M09-D2 transactional product', coverUrl: '', images: [],
|
||||
deliveryEnabled: true, storageEnabled: true, status: 'ACTIVE', sortOrder: 2
|
||||
});
|
||||
const sku = await catalog.createSku(adminActor, product.productId, {
|
||||
skuCode: 'M09D2-ORDER-DRINK-500', name: '500ml', attributes: { volume: '500ml' },
|
||||
barcode: 'M09D2000001', imageUrl: '', salePriceCents: 700,
|
||||
marketPriceCents: 900, costPriceCents: 300,
|
||||
defaultInventoryPolicy: 'TRACKED', status: 'ACTIVE'
|
||||
});
|
||||
await catalog.putListing(adminActor, storeId, product.productId, 0, {
|
||||
categoryId, status: 'ACTIVE', fulfillmentMode: 'BOTH',
|
||||
salesStartAt: null, salesEndAt: null, sortOrder: 2
|
||||
});
|
||||
const configured = await inventory.configurePolicy(adminActor, {
|
||||
storeId, skuId: sku.skuId, requestId: 'm09d2-policy', reason: '订单测试库存策略',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
|
||||
});
|
||||
await inventory.inbound(adminActor, {
|
||||
storeId, skuId: sku.skuId, requestId: 'm09d2-inbound', reason: '订单测试入库',
|
||||
expectedVersion: configured.version, quantity: 20
|
||||
});
|
||||
|
||||
const firstInput = {
|
||||
storeId, requestId: 'm09d2-order-first', fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null, note: 'first order',
|
||||
items: [{ skuId: sku.skuId, quantity: 2, note: 'snapshot note' }]
|
||||
};
|
||||
const firstOrder = await service.create(customerActor, firstInput);
|
||||
assert.equal(firstOrder.status, 'PENDING_PAYMENT');
|
||||
assert.equal(firstOrder.totalAmountCents, 1400);
|
||||
assert.equal(firstOrder.items[0].productName, 'M09D2 Order Drink');
|
||||
assert.equal(firstOrder.items[0].unitPriceCents, 700);
|
||||
assert.equal((await service.create(customerActor, firstInput)).idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.create(customerActor, {
|
||||
...firstInput, items: [{ skuId: sku.skuId, quantity: 3, note: 'changed' }]
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-invalid-room-order', fulfillmentMode: 'DELIVERY',
|
||||
roomOrderId: '999999999999999999'
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_ROOM_ORDER_INVALID'
|
||||
);
|
||||
let [stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 2 });
|
||||
|
||||
const firstPayment = await service.createPaymentForCustomer(
|
||||
customerActor, firstOrder.id, { requestId: 'm09d2-payment-first', provider: 'TEST' }
|
||||
);
|
||||
assert.equal((await service.createPaymentForCustomer(
|
||||
customerActor, firstOrder.id, { requestId: 'm09d2-payment-first', provider: 'TEST' }
|
||||
)).idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.createPaymentForCustomer(customerActor, firstOrder.id, {
|
||||
requestId: 'm09d2-payment-first', provider: 'WECHAT'
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_PAYMENT_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
const paymentRace = await Promise.all([
|
||||
service.completeTestPaymentForCustomer(customerActor, firstPayment.id, {
|
||||
callbackId: 'm09d2-payment-first-callback-a', amountCents: 1400
|
||||
}),
|
||||
service.completeTestPaymentForCustomer(customerActor, firstPayment.id, {
|
||||
callbackId: 'm09d2-payment-first-callback-b', amountCents: 1400
|
||||
})
|
||||
]);
|
||||
assert.equal(paymentRace.filter((result) => result.idempotent === false).length, 1);
|
||||
assert.equal(paymentRace.filter((result) => result.idempotent === true).length, 1);
|
||||
[stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 0 });
|
||||
const [deductRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND business_type = 'PRODUCT_ORDER'
|
||||
AND business_id = ? AND operation = 'DEDUCT'`,
|
||||
[context.tenantId, `PRODUCT_ORDER:${firstOrder.orderNo}`]
|
||||
);
|
||||
assert.equal(Number(deductRows[0].total), 1,
|
||||
'concurrent payment callbacks must deduct inventory once');
|
||||
|
||||
await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-accept', action: 'ACCEPT', reason: ''
|
||||
});
|
||||
await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-ready', action: 'MARK_READY', reason: ''
|
||||
});
|
||||
const completed = await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-complete', action: 'COMPLETE', reason: ''
|
||||
});
|
||||
assert.equal(completed.status, 'COMPLETED');
|
||||
const completedRefunding = await service.managementAction(adminActor, firstOrder.id, {
|
||||
requestId: 'm09d2-first-refund', action: 'REFUND', reason: '完成后退款不回库'
|
||||
});
|
||||
assert.equal(completedRefunding.refunds[0].inventoryDisposition, 'NO_RESTOCK');
|
||||
await service.completeTestRefundForManagement(
|
||||
adminActor, String(completedRefunding.refunds[0].id), {
|
||||
callbackId: 'm09d2-first-refund-callback', amountCents: 1400
|
||||
}
|
||||
);
|
||||
|
||||
const restockOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-restock', note: 'restock order',
|
||||
items: [{ skuId: sku.skuId, quantity: 3, note: '' }]
|
||||
});
|
||||
const restockPayment = await service.createPaymentForCustomer(
|
||||
customerActor, restockOrder.id,
|
||||
{ requestId: 'm09d2-payment-restock', provider: 'TEST' }
|
||||
);
|
||||
await service.completeTestPaymentForCustomer(customerActor, restockPayment.id, {
|
||||
callbackId: 'm09d2-payment-restock-callback', amountCents: 2100
|
||||
});
|
||||
const restockRefunding = await service.managementAction(adminActor, restockOrder.id, {
|
||||
requestId: 'm09d2-restock-refund', action: 'REFUND', reason: '支付后取消回库'
|
||||
});
|
||||
const restockRefundId = String(restockRefunding.refunds[0].id);
|
||||
assert.equal(restockRefunding.refunds[0].inventoryDisposition, 'RESTOCK');
|
||||
const restocked = await service.completeTestRefundForManagement(
|
||||
adminActor, restockRefundId,
|
||||
{ callbackId: 'm09d2-restock-refund-callback', amountCents: 2100 }
|
||||
);
|
||||
assert.equal(restocked.status, 'SUCCEEDED');
|
||||
assert.equal((await service.completeTestRefundForManagement(
|
||||
adminActor, restockRefundId,
|
||||
{ callbackId: 'm09d2-restock-refund-callback', amountCents: 2100 }
|
||||
)).idempotent, true);
|
||||
|
||||
const cancelOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-cancel', note: 'cancel order',
|
||||
items: [{ skuId: sku.skuId, quantity: 1, note: '' }]
|
||||
});
|
||||
const cancelled = await service.cancelForCustomer(customerActor, cancelOrder.id, {
|
||||
requestId: 'm09d2-order-cancel-action', reason: '顾客取消待支付订单'
|
||||
});
|
||||
assert.equal(cancelled.status, 'CANCELLED');
|
||||
assert.equal(cancelled.inventoryStatus, 'RELEASED');
|
||||
assert.equal((await service.cancelForCustomer(customerActor, cancelOrder.id, {
|
||||
requestId: 'm09d2-order-cancel-action', reason: '顾客取消待支付订单'
|
||||
})).status, 'CANCELLED');
|
||||
|
||||
const timeoutOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-timeout', note: 'timeout order',
|
||||
items: [{ skuId: sku.skuId, quantity: 1, note: '' }]
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE qipai_product_orders SET expires_at = UTC_TIMESTAMP(3) - INTERVAL 1 SECOND
|
||||
WHERE tenant_id = ? AND id = ?`, [context.tenantId, timeoutOrder.id]
|
||||
);
|
||||
assert.ok((await service.expirePendingOrders()).expired >= 1);
|
||||
assert.equal((await service.getForCustomer(customerActor, timeoutOrder.id)).status, 'CANCELLED');
|
||||
|
||||
[stockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity, locked_quantity AS lockedQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, sku.skuId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
availableQuantity: Number(stockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(stockRows[0].lockedQuantity)
|
||||
}, { availableQuantity: 18, lockedQuantity: 0 },
|
||||
'completed consumption stays deducted while paid cancellation and pending cancellation restore stock');
|
||||
|
||||
await assert.rejects(
|
||||
() => service.listForManagement({
|
||||
...adminActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['goods.order.read'], storeIds: [] }
|
||||
}, { storeId, page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_order_items SET item_note = 'forbidden'
|
||||
WHERE tenant_id = ? AND order_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstOrder.id]
|
||||
),
|
||||
(error) => /PRODUCT_ORDER_ITEM_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`DELETE FROM qipai_product_order_events
|
||||
WHERE tenant_id = ? AND order_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstOrder.id]
|
||||
),
|
||||
(error) => /PRODUCT_ORDER_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_refund_events SET reason = 'forbidden'
|
||||
WHERE tenant_id = ? AND refund_id = ? LIMIT 1`,
|
||||
[context.tenantId, restockRefundId]
|
||||
),
|
||||
(error) => /PRODUCT_REFUND_EVENT_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
console.log(
|
||||
'PASS: M09-D2 order snapshots, concurrent payment deduct, cancellation release, '
|
||||
+ 'refund restock, timeout cleanup and immutable events are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -4049,7 +4363,8 @@ try {
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -4073,13 +4388,14 @@ try {
|
||||
await assertIotMessages(pool, loginContext);
|
||||
await assertCleaningTaskTransactions(pool, loginContext);
|
||||
await assertProductInventoryFoundation(pool, loginContext);
|
||||
await assertProductOrderPaymentInventory(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M09-D1 migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-D2 migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -4110,7 +4426,8 @@ try {
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { ProductOrderError } from '../dist/products/product-order-service.js';
|
||||
import { registerProductOrderRoutes } from '../dist/routes/product-orders.js';
|
||||
|
||||
const secret = 'test-only-product-order-route-jwt-secret';
|
||||
const sessionId = '3a6ab573-1105-4bf9-b75b-e88e49eb3b82';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d2-product-order-route'
|
||||
};
|
||||
let currentAccess = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
const calls = [];
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const order = {
|
||||
id: '101', storeId: '11', orderNo: 'PG101', status: 'PENDING_PAYMENT',
|
||||
fulfillmentMode: 'SELF_SERVICE', totalAmountCents: 500,
|
||||
inventoryStatus: 'LOCKED'
|
||||
};
|
||||
const service = {
|
||||
create: record('create', (_actor, input) => ({ ...order, storeId: input.storeId })),
|
||||
listForCustomer: record('listForCustomer', (_actor, input) => ({
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForCustomer: record('getForCustomer', (_actor, orderId) => {
|
||||
if (orderId === '999') throw new ProductOrderError('PRODUCT_ORDER_NOT_FOUND');
|
||||
return order;
|
||||
}),
|
||||
cancelForCustomer: record('cancelForCustomer', () => ({ ...order, status: 'CANCELLED' })),
|
||||
listForManagement: record('listForManagement', (_actor, input) => ({
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => order),
|
||||
managementAction: record('managementAction', (_actor, _id, input) => ({
|
||||
...order, status: input.action === 'ACCEPT' ? 'ACCEPTED' : order.status
|
||||
})),
|
||||
createPaymentForCustomer: record('createPaymentForCustomer', () => ({
|
||||
id: '401', orderId: '101', provider: 'TEST', status: 'PENDING', amountCents: 500
|
||||
})),
|
||||
completeTestPaymentForCustomer: record('completeTestPaymentForCustomer', () => ({
|
||||
paymentId: '401', orderId: '101', status: 'SUCCEEDED'
|
||||
})),
|
||||
completeTestRefundForManagement: record('completeTestRefundForManagement', () => ({
|
||||
refundId: '501', orderId: '101', status: 'SUCCEEDED'
|
||||
}))
|
||||
};
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorateRequest('traceId', '');
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||
reply.header('x-trace-id', request.traceId);
|
||||
});
|
||||
await registerProductOrderRoutes(app, {
|
||||
service,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: sessionId, tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||
jwtSecret: secret,
|
||||
testAdapterEnabled: true
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/product-orders' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||
|
||||
const invalidDelivery = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'create-invalid', fulfillmentMode: 'DELIVERY',
|
||||
note: '', items: [{ skuId: '5', quantity: 1, note: '' }]
|
||||
}
|
||||
});
|
||||
assert.equal(invalidDelivery.statusCode, 400);
|
||||
assert.equal(invalidDelivery.json().code, 'PRODUCT_ORDER_INPUT_INVALID');
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'create-order-1', fulfillmentMode: 'SELF_SERVICE',
|
||||
note: '少冰', items: [{ skuId: '5', quantity: 2, note: '' }]
|
||||
}
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.id, '101');
|
||||
const createCall = calls.find((call) => call.method === 'create');
|
||||
assert.equal(createCall.args[0].tenantId, '7');
|
||||
assert.equal(createCall.args[0].userId, '21');
|
||||
assert.equal(createCall.args[0].source, 'CUSTOMER');
|
||||
assert.equal(createCall.args[0].traceId, 'm09d2-product-order-route');
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-orders?storeId=11&page=2&pageSize=10', headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.json().data.page, 2);
|
||||
|
||||
const missing = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-orders/999', headers
|
||||
});
|
||||
assert.equal(missing.statusCode, 404);
|
||||
assert.equal(missing.json().code, 'PRODUCT_ORDER_NOT_FOUND');
|
||||
|
||||
const payment = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-orders/101/payments', headers,
|
||||
payload: { requestId: 'payment-create-1', provider: 'TEST' }
|
||||
});
|
||||
assert.equal(payment.statusCode, 201);
|
||||
assert.equal(payment.json().data.id, '401');
|
||||
|
||||
const paid = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-payments/401/test-complete', headers,
|
||||
payload: { callbackId: 'payment-callback-1', amountCents: 500 }
|
||||
});
|
||||
assert.equal(paid.statusCode, 200);
|
||||
assert.equal(paid.json().data.status, 'SUCCEEDED');
|
||||
|
||||
const managementForbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementForbidden.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.order.read'], storeIds: ['11']
|
||||
};
|
||||
const managementList = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/product-orders?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
|
||||
const readOnlyAction = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'accept-order-1', action: 'ACCEPT', reason: '' }
|
||||
});
|
||||
assert.equal(readOnlyAction.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.order.read', 'goods.order.manage'], storeIds: ['11']
|
||||
};
|
||||
const accepted = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'accept-order-1', action: 'ACCEPT', reason: '' }
|
||||
});
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.equal(accepted.json().data.status, 'ACCEPTED');
|
||||
|
||||
const refundCompleted = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-refunds/501/test-complete', headers,
|
||||
payload: { callbackId: 'refund-callback-1', amountCents: 500 }
|
||||
});
|
||||
assert.equal(refundCompleted.statusCode, 200);
|
||||
assert.equal(refundCompleted.json().data.status, 'SUCCEEDED');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D2 product order routes enforce customer ownership, management permissions and test payment boundaries.');
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ProductOrderError,
|
||||
ProductOrderService,
|
||||
productOrderFingerprint,
|
||||
resolveProductOrderTransition
|
||||
} from '../dist/products/product-order-service.js';
|
||||
|
||||
const fixedNow = new Date('2026-08-11T04:00:00.000Z');
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['CUSTOMER'], capabilities: [], storeIds: [] },
|
||||
source: 'CUSTOMER',
|
||||
traceId: 'm09d2-product-order-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'product-order-test'
|
||||
};
|
||||
const createInput = {
|
||||
storeId: '11',
|
||||
requestId: 'create-order-1',
|
||||
fulfillmentMode: 'SELF_SERVICE',
|
||||
roomOrderId: null,
|
||||
note: '少冰',
|
||||
items: [{ skuId: '5', quantity: 2, note: '分开放' }]
|
||||
};
|
||||
const fingerprint = productOrderFingerprint(createInput);
|
||||
|
||||
assert.equal(
|
||||
productOrderFingerprint({
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '6', quantity: 1, note: '' },
|
||||
{ skuId: '5', quantity: 2, note: '分开放' }
|
||||
]
|
||||
}),
|
||||
productOrderFingerprint({
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '5', quantity: 2, note: '分开放' },
|
||||
{ skuId: '6', quantity: 1, note: '' }
|
||||
]
|
||||
}),
|
||||
'semantic item ordering must not change the create fingerprint'
|
||||
);
|
||||
assert.notEqual(
|
||||
fingerprint,
|
||||
productOrderFingerprint({ ...createInput, note: '常温' }),
|
||||
'meaningful input changes must change the create fingerprint'
|
||||
);
|
||||
|
||||
assert.equal(resolveProductOrderTransition('PAID', 'DELIVERY', 'ACCEPT'), 'ACCEPTED');
|
||||
assert.equal(
|
||||
resolveProductOrderTransition('ACCEPTED', 'DELIVERY', 'START_DELIVERY'),
|
||||
'DELIVERING'
|
||||
);
|
||||
assert.equal(
|
||||
resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'MARK_READY'),
|
||||
'READY_FOR_SELF_SERVICE'
|
||||
);
|
||||
assert.equal(resolveProductOrderTransition('DELIVERING', 'DELIVERY', 'COMPLETE'), 'COMPLETED');
|
||||
assert.throws(
|
||||
() => resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'START_DELIVERY'),
|
||||
(error) => error instanceof ProductOrderError
|
||||
&& error.code === 'PRODUCT_ORDER_TRANSITION_NOT_ALLOWED'
|
||||
);
|
||||
|
||||
class ScriptedConnection {
|
||||
constructor(steps) {
|
||||
this.steps = [...steps];
|
||||
this.calls = [];
|
||||
this.committed = false;
|
||||
this.rolledBack = false;
|
||||
this.released = false;
|
||||
}
|
||||
|
||||
async beginTransaction() {}
|
||||
async commit() { this.committed = true; }
|
||||
async rollback() { this.rolledBack = true; }
|
||||
release() { this.released = true; }
|
||||
|
||||
async execute(sql, params = []) {
|
||||
this.calls.push({ sql, params });
|
||||
const step = this.steps.shift();
|
||||
assert.ok(step, `Unexpected SQL: ${sql}`);
|
||||
assert.match(sql, step.match);
|
||||
if (step.check) step.check(params, sql);
|
||||
return step.result;
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptedPool {
|
||||
constructor(directSteps, connections) {
|
||||
this.directSteps = [...directSteps];
|
||||
this.connections = [...connections];
|
||||
}
|
||||
|
||||
async execute(sql, params = []) {
|
||||
const step = this.directSteps.shift();
|
||||
assert.ok(step, `Unexpected direct SQL: ${sql}`);
|
||||
assert.match(sql, step.match);
|
||||
if (step.check) step.check(params, sql);
|
||||
return step.result;
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
const connection = this.connections.shift();
|
||||
assert.ok(connection, 'Unexpected transaction');
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
const baseOrder = {
|
||||
id: '101', tenantId: '7', storeId: '11', memberId: '21', roomOrderId: null,
|
||||
orderNo: 'PG202608110001', clientRequestId: createInput.requestId,
|
||||
requestFingerprint: fingerprint, inventoryBusinessId: 'PRODUCT_ORDER:PG202608110001',
|
||||
inventoryRequestId: 'po.lock.abc', fulfillmentMode: 'SELF_SERVICE',
|
||||
status: 'PENDING_PAYMENT', itemCount: 1, totalQuantity: 2,
|
||||
totalAmountCents: 500, paidAmountCents: 0, refundedAmountCents: 0,
|
||||
orderNote: '少冰', inventoryStatus: 'LOCKED',
|
||||
expiresAt: new Date('2026-08-11T04:15:00.000Z'),
|
||||
paidAt: null, acceptedAt: null, deliveringAt: null, completedAt: null,
|
||||
cancelledAt: null, createdSource: 'MEMBER', createdBy: null,
|
||||
version: 1, createdAt: fixedNow, updatedAt: fixedNow
|
||||
};
|
||||
const itemView = [{
|
||||
id: '201', lineNo: 1, productId: '3', skuId: '5', productCode: 'TEA',
|
||||
productName: '茶饮', skuCode: 'TEA-L', skuName: '大杯', unitName: '杯',
|
||||
attributes: null, unitPriceCents: 250, quantity: 2, subtotalCents: 500,
|
||||
note: '分开放'
|
||||
}];
|
||||
const detailSteps = (order = baseOrder) => [
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?/, result: [[order], []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*ORDER BY line_no/, result: [itemView, []] },
|
||||
{ match: /FROM qipai_product_payments[\s\S]*ORDER BY id DESC/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_refunds[\s\S]*ORDER BY id DESC/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_order_events[\s\S]*ORDER BY version_after/, result: [[{
|
||||
id: '301', fromStatus: null, toStatus: order.status, action: 'CREATE',
|
||||
actorType: 'MEMBER', actorId: null, versionAfter: order.version,
|
||||
reason: '', createdAt: fixedNow
|
||||
}], []] }
|
||||
];
|
||||
|
||||
const createConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?[\s\S]*FOR UPDATE/, result: [[], []] },
|
||||
{ match: /FROM qipai_stores s[\s\S]*FOR SHARE/, result: [[{
|
||||
id: null, timezone: 'Asia/Shanghai', businessStatus: 'OPEN', salesStatus: null,
|
||||
manualPausedAt: null, manualPausedUntil: null
|
||||
}], []] },
|
||||
{ match: /FROM qipai_product_skus s[\s\S]*FOR SHARE/, result: [[{
|
||||
skuId: '5', productId: '3', skuCode: 'TEA-L', skuName: '大杯',
|
||||
attributesSnapshot: null, unitPriceCents: 250, productCode: 'TEA',
|
||||
productName: '茶饮', unitName: '杯', deliveryEnabled: 1,
|
||||
fulfillmentMode: 'BOTH', salesStartAt: null, salesEndAt: null
|
||||
}], []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_orders/,
|
||||
result: [{ insertId: 101, affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[1], '11');
|
||||
assert.equal(params[2], '21');
|
||||
assert.equal(params[5], createInput.requestId);
|
||||
assert.equal(params[6], fingerprint);
|
||||
assert.equal(params[12], 500);
|
||||
}
|
||||
},
|
||||
{ match: /INSERT INTO qipai_product_order_items/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps()
|
||||
]);
|
||||
const createPool = new ScriptedPool([
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?/, result: [[], []] }
|
||||
], [createConnection]);
|
||||
let lockCall;
|
||||
const created = await new ProductOrderService(createPool, {
|
||||
async lockMany(input, connection) {
|
||||
lockCall = { input, connection };
|
||||
return { idempotent: false };
|
||||
},
|
||||
async releaseMany() { throw new Error('not expected'); },
|
||||
async deductMany() { throw new Error('not expected'); },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).create(actor, createInput);
|
||||
assert.equal(created.id, '101');
|
||||
assert.equal(created.totalAmountCents, 500);
|
||||
assert.equal(created.items[0].skuId, '5');
|
||||
assert.equal(lockCall.input.businessType, 'PRODUCT_ORDER');
|
||||
assert.deepEqual(lockCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(lockCall.connection, createConnection);
|
||||
assert.equal(createConnection.committed, true);
|
||||
assert.equal(createConnection.rolledBack, false);
|
||||
assert.equal(createConnection.released, true);
|
||||
assert.equal(createConnection.steps.length, 0);
|
||||
|
||||
const noSqlPool = new ScriptedPool([], []);
|
||||
await assert.rejects(
|
||||
() => new ProductOrderService(noSqlPool, {}).create(actor, {
|
||||
...createInput,
|
||||
items: [
|
||||
{ skuId: '5', quantity: 1, note: '' },
|
||||
{ skuId: '5', quantity: 1, note: 'duplicate' }
|
||||
]
|
||||
}),
|
||||
(error) => error instanceof ProductOrderError && error.code === 'PRODUCT_ORDER_SKU_DUPLICATE'
|
||||
);
|
||||
|
||||
const pendingPayment = {
|
||||
id: '401', tenantId: '7', storeId: '11', orderId: '101',
|
||||
paymentNo: 'PP202608110001', clientRequestId: 'payment-create-1',
|
||||
requestFingerprint: 'a'.repeat(64), provider: 'TEST', channel: 'TEST',
|
||||
status: 'PENDING', amountCents: 500, refundedAmountCents: 0,
|
||||
expiresAt: baseOrder.expiresAt, paidAt: null, version: 1
|
||||
};
|
||||
const paymentConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_payment_callbacks[\s\S]*FOR UPDATE/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_payments WHERE[\s\S]*FOR UPDATE/, result: [[pendingPayment], []] },
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
|
||||
{ match: /INSERT INTO qipai_product_payment_callbacks/, result: [{ insertId: 501, affectedRows: 1 }, []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*SUCCEEDED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*status = 'PAID'/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payment_callbacks/, result: [{ affectedRows: 1 }, []] }
|
||||
]);
|
||||
const paymentPool = new ScriptedPool([{
|
||||
match: /o\.member_id AS memberId[\s\S]*FROM qipai_product_payments p/,
|
||||
result: [[{ ...pendingPayment, memberId: '21' }], []]
|
||||
}], [paymentConnection]);
|
||||
let deductCall;
|
||||
const paymentResult = await new ProductOrderService(paymentPool, {
|
||||
async lockMany() { throw new Error('not expected'); },
|
||||
async releaseMany() { throw new Error('not expected'); },
|
||||
async deductMany(input, connection) { deductCall = { input, connection }; },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).completeTestPaymentForCustomer(actor, '401', {
|
||||
callbackId: 'test-payment-callback-1', amountCents: 500
|
||||
});
|
||||
assert.deepEqual(paymentResult, {
|
||||
paymentId: '401', orderId: '101', status: 'SUCCEEDED', idempotent: false
|
||||
});
|
||||
assert.deepEqual(deductCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(deductCall.input.businessId, baseOrder.inventoryBusinessId);
|
||||
assert.equal(deductCall.connection, paymentConnection);
|
||||
assert.equal(paymentConnection.committed, true);
|
||||
assert.equal(paymentConnection.steps.length, 0);
|
||||
|
||||
const cancelledOrder = {
|
||||
...baseOrder, status: 'CANCELLED', inventoryStatus: 'RELEASED',
|
||||
cancelledAt: fixedNow, version: 2
|
||||
};
|
||||
const cancelConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_product_order_events[\s\S]*request_id = \?/, result: [[], []] },
|
||||
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
|
||||
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*CANCELLED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*CLOSED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps(cancelledOrder)
|
||||
]);
|
||||
let releaseCall;
|
||||
const cancelled = await new ProductOrderService(new ScriptedPool([], [cancelConnection]), {
|
||||
async lockMany() { throw new Error('not expected'); },
|
||||
async releaseMany(input, connection) { releaseCall = { input, connection }; },
|
||||
async deductMany() { throw new Error('not expected'); },
|
||||
async returnMany() { throw new Error('not expected'); }
|
||||
}, { now: () => fixedNow }).cancelForCustomer(actor, '101', {
|
||||
requestId: 'cancel-order-1', reason: '顾客取消'
|
||||
});
|
||||
assert.equal(cancelled.status, 'CANCELLED');
|
||||
assert.equal(cancelled.inventoryStatus, 'RELEASED');
|
||||
assert.deepEqual(releaseCall.input.items, [{ skuId: '5', quantity: 2 }]);
|
||||
assert.equal(releaseCall.connection, cancelConnection);
|
||||
assert.equal(cancelConnection.committed, true);
|
||||
assert.equal(cancelConnection.steps.length, 0);
|
||||
|
||||
console.log('PASS: M09-D2 product order snapshot, transition, payment deduct and cancellation release service contracts work.');
|
||||
Reference in New Issue
Block a user