feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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 type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
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 version = z.number().int().min(1).max(4_294_967_295);
|
||||
const createVersion = z.number().int().min(0).max(4_294_967_295);
|
||||
const quantity = z.number().int().min(1).max(1_000_000_000);
|
||||
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
|
||||
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
|
||||
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
|
||||
const reason = z.string().trim().min(1).max(512);
|
||||
const stockParams = z.object({ skuId: id }).strict();
|
||||
const ledgerParams = z.object({ inventoryId: id }).strict();
|
||||
const stockListQuery = z.object({
|
||||
storeId: id,
|
||||
page,
|
||||
pageSize,
|
||||
skuId: id.optional(),
|
||||
productId: id.optional(),
|
||||
search: z.string().trim().max(64).optional()
|
||||
}).strict();
|
||||
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
|
||||
const mutationBase = {
|
||||
storeId: id,
|
||||
requestId,
|
||||
reason,
|
||||
expectedVersion: version.optional()
|
||||
};
|
||||
const policyBody = z.object({
|
||||
...mutationBase,
|
||||
expectedVersion: createVersion,
|
||||
policyType: z.enum(['TRACKED', 'UNLIMITED']),
|
||||
lowStockThreshold: nonNegativeQuantity
|
||||
}).strict();
|
||||
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
|
||||
const adjustmentBody = z.object({
|
||||
...mutationBase,
|
||||
availableDelta: delta,
|
||||
lossDelta: delta.default(0)
|
||||
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
|
||||
const stocktakeBody = z.object({
|
||||
...mutationBase,
|
||||
expectedVersion: version,
|
||||
availableQuantity: nonNegativeQuantity,
|
||||
lossQuantity: nonNegativeQuantity
|
||||
}).strict();
|
||||
const lossBody = z.object({ ...mutationBase, quantity }).strict();
|
||||
|
||||
export interface InventoryRouteOptions {
|
||||
service: Pick<InventoryService,
|
||||
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
|
||||
| 'stocktake' | 'recordLoss'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerInventoryRoutes(
|
||||
app: FastifyInstance,
|
||||
options: InventoryRouteOptions
|
||||
) {
|
||||
app.get('/admin-api/inventory/stocks', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, false);
|
||||
const query = stockListQuery.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listStocks(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, false);
|
||||
const params = ledgerParams.safeParse(request.params);
|
||||
const query = ledgerQuery.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listLedger(actor, {
|
||||
inventoryId: params.data.inventoryId,
|
||||
...query.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = policyBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.configurePolicy(actor, {
|
||||
skuId: params.data.skuId,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = inboundBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = adjustmentBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = stocktakeBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = lossBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireInventoryActor(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: InventoryRouteOptions,
|
||||
write: boolean
|
||||
): Promise<ManagementActor | 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
|
||||
);
|
||||
const manager = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const allowed = manager || (write
|
||||
? access.capabilities.includes('inventory.adjust')
|
||||
: access.capabilities.includes('inventory.read')
|
||||
|| access.capabilities.includes('inventory.adjust'));
|
||||
if (!allowed) {
|
||||
reply.status(403).send({
|
||||
code: 'INVENTORY_OPERATION_FORBIDDEN',
|
||||
message: 'Inventory permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function inventoryResponse(
|
||||
reply: FastifyReply,
|
||||
traceId: string,
|
||||
work: () => Promise<unknown>
|
||||
) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof InventoryError)) throw error;
|
||||
const status = inventoryErrorStatus(error.code);
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The inventory operation is not allowed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function inventoryErrorStatus(code: string) {
|
||||
if (code.endsWith('_FORBIDDEN')) return 403;
|
||||
if (code.endsWith('_NOT_FOUND')) return 404;
|
||||
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|
||||
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
|
||||
return 400;
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_INVENTORY_REQUEST',
|
||||
message: 'The inventory request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user