feat(M09-D2): 完成商品订单与库存占用生命周期

This commit is contained in:
Codex
2026-08-11 07:04:23 +08:00
parent 93af128002
commit 1a3ea7bdf0
30 changed files with 3773 additions and 91 deletions
+8
View File
@@ -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;
}
+12 -9
View File
@@ -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]
);
+4 -1
View File
@@ -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 = ?`,
+7 -3
View File
@@ -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(
+70 -7
View File
@@ -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
+295
View File
@@ -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
});
}
+9
View File
@@ -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 () => {