251 lines
9.1 KiB
TypeScript
251 lines
9.1 KiB
TypeScript
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
|
|
});
|
|
}
|