feat(M09-D2): 完成商品订单与库存占用生命周期
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user