feat(ADM-018): completed feature
This commit is contained in:
118
project/src/modules/inventory/api/inventory.routes.ts
Normal file
118
project/src/modules/inventory/api/inventory.routes.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { InventoryService } from '../application/inventory-service.js';
|
||||
import {
|
||||
InsufficientReservedStockError,
|
||||
InsufficientStockError,
|
||||
InvalidStockQuantityError,
|
||||
} from '../domain/errors.js';
|
||||
import type { StockItem } from '../domain/stock.js';
|
||||
import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js';
|
||||
|
||||
export interface InventoryRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const variantParamSchema = z.object({ variantId: z.uuid() });
|
||||
const availabilityQuerySchema = z.object({
|
||||
quantity: z.coerce.number().int().positive().default(1),
|
||||
});
|
||||
const stockBodySchema = z.object({ quantity: z.number().int().min(0) });
|
||||
const stockCommandBodySchema = z.object({ quantity: z.number().int().positive() });
|
||||
|
||||
export async function registerInventoryRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: InventoryRoutesDeps,
|
||||
): Promise<void> {
|
||||
const inventory = new InventoryService(new PgInventoryRepository(deps.pool));
|
||||
|
||||
app.get('/inventory/:variantId/availability', async (request, reply) => {
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(availabilityQuerySchema, request.query);
|
||||
const availability = await inventory.checkAvailability(variantId, quantity);
|
||||
return reply.send(availability);
|
||||
});
|
||||
|
||||
app.put('/inventory/:variantId/stock', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(stockBodySchema, request.body);
|
||||
try {
|
||||
const item = await inventory.setAvailable({ variantId, quantity });
|
||||
return reply.send(serializeStockItem(item));
|
||||
} catch (error) {
|
||||
throw mapInventoryError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/inventory/:variantId/reservations', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(stockCommandBodySchema, request.body);
|
||||
try {
|
||||
const item = await inventory.reserve({ variantId, quantity });
|
||||
return reply.code(201).send(serializeStockItem(item));
|
||||
} catch (error) {
|
||||
throw mapInventoryError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/inventory/:variantId/reservations/release', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(stockCommandBodySchema, request.body);
|
||||
try {
|
||||
const item = await inventory.release({ variantId, quantity });
|
||||
return reply.send(serializeStockItem(item));
|
||||
} catch (error) {
|
||||
throw mapInventoryError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/inventory/:variantId/reservations/confirm', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(stockCommandBodySchema, request.body);
|
||||
try {
|
||||
const item = await inventory.confirm({ variantId, quantity });
|
||||
return reply.send(serializeStockItem(item));
|
||||
} catch (error) {
|
||||
throw mapInventoryError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mapInventoryError(error: unknown): Error {
|
||||
if (error instanceof InsufficientStockError) {
|
||||
return new AppError(409, 'INSUFFICIENT_STOCK', error.message);
|
||||
}
|
||||
if (error instanceof InsufficientReservedStockError) {
|
||||
return new AppError(409, 'INSUFFICIENT_RESERVED_STOCK', error.message);
|
||||
}
|
||||
if (error instanceof InvalidStockQuantityError) {
|
||||
return new AppError(422, 'INVALID_STOCK_QUANTITY', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown inventory error');
|
||||
}
|
||||
|
||||
function serializeStockItem(item: StockItem) {
|
||||
return {
|
||||
id: item.id,
|
||||
variantId: item.variantId,
|
||||
available: item.available,
|
||||
reserved: item.reserved,
|
||||
sold: item.sold,
|
||||
incoming: item.incoming,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
InsufficientReservedStockError,
|
||||
InsufficientStockError,
|
||||
InvalidStockQuantityError,
|
||||
} from '../domain/errors.js';
|
||||
import type {
|
||||
InventoryRepository,
|
||||
InventoryService as InventoryServicePort,
|
||||
} from '../domain/ports.js';
|
||||
import type {
|
||||
Availability,
|
||||
SetAvailableStockCommand,
|
||||
StockCommand,
|
||||
StockItem,
|
||||
} from '../domain/stock.js';
|
||||
|
||||
export class InventoryService implements InventoryServicePort {
|
||||
constructor(private readonly repository: InventoryRepository) {}
|
||||
|
||||
async checkAvailability(variantId: string, quantity: number): Promise<Availability> {
|
||||
ensurePositiveQuantity(quantity);
|
||||
const item = await this.repository.findByVariantId(variantId);
|
||||
const availableQuantity = item?.available ?? 0;
|
||||
return { available: availableQuantity >= quantity, availableQuantity };
|
||||
}
|
||||
|
||||
async reserve(input: StockCommand): Promise<StockItem> {
|
||||
ensurePositiveQuantity(input.quantity);
|
||||
const item = await this.repository.reserve(input);
|
||||
if (!item) {
|
||||
throw new InsufficientStockError();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async release(input: StockCommand): Promise<StockItem> {
|
||||
ensurePositiveQuantity(input.quantity);
|
||||
const item = await this.repository.release(input);
|
||||
if (!item) {
|
||||
throw new InsufficientReservedStockError();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async confirm(input: StockCommand): Promise<StockItem> {
|
||||
ensurePositiveQuantity(input.quantity);
|
||||
const item = await this.repository.confirm(input);
|
||||
if (!item) {
|
||||
throw new InsufficientReservedStockError();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async setAvailable(input: SetAvailableStockCommand): Promise<StockItem> {
|
||||
ensureNonNegativeInteger(input.quantity);
|
||||
return this.repository.setAvailable(input);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePositiveQuantity(quantity: number): void {
|
||||
if (!Number.isInteger(quantity) || quantity <= 0) {
|
||||
throw new InvalidStockQuantityError();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNonNegativeInteger(quantity: number): void {
|
||||
if (!Number.isInteger(quantity) || quantity < 0) {
|
||||
throw new InvalidStockQuantityError();
|
||||
}
|
||||
}
|
||||
20
project/src/modules/inventory/domain/errors.ts
Normal file
20
project/src/modules/inventory/domain/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class InvalidStockQuantityError extends Error {
|
||||
constructor() {
|
||||
super('Stock quantity must be a positive integer');
|
||||
this.name = 'InvalidStockQuantityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientStockError extends Error {
|
||||
constructor() {
|
||||
super('Insufficient stock available');
|
||||
this.name = 'InsufficientStockError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientReservedStockError extends Error {
|
||||
constructor() {
|
||||
super('Insufficient reserved stock');
|
||||
this.name = 'InsufficientReservedStockError';
|
||||
}
|
||||
}
|
||||
17
project/src/modules/inventory/domain/ports.ts
Normal file
17
project/src/modules/inventory/domain/ports.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
|
||||
|
||||
export interface InventoryService {
|
||||
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
|
||||
reserve(input: StockCommand): Promise<StockItem>;
|
||||
release(input: StockCommand): Promise<StockItem>;
|
||||
confirm(input: StockCommand): Promise<StockItem>;
|
||||
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
|
||||
}
|
||||
|
||||
export interface InventoryRepository {
|
||||
findByVariantId(variantId: string): Promise<StockItem | undefined>;
|
||||
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
|
||||
reserve(input: StockCommand): Promise<StockItem | undefined>;
|
||||
release(input: StockCommand): Promise<StockItem | undefined>;
|
||||
confirm(input: StockCommand): Promise<StockItem | undefined>;
|
||||
}
|
||||
30
project/src/modules/inventory/domain/stock.ts
Normal file
30
project/src/modules/inventory/domain/stock.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type StockState = 'available' | 'reserved' | 'sold' | 'incoming';
|
||||
|
||||
export interface StockItem {
|
||||
id: string;
|
||||
variantId: string;
|
||||
available: number;
|
||||
reserved: number;
|
||||
sold: number;
|
||||
incoming: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface StockCommand {
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export type SetAvailableStockCommand = StockCommand;
|
||||
|
||||
export interface Availability {
|
||||
available: boolean;
|
||||
availableQuantity: number;
|
||||
}
|
||||
|
||||
export function assertPositiveQuantity(quantity: number): void {
|
||||
if (!Number.isInteger(quantity) || quantity <= 0) {
|
||||
throw new Error('Stock quantity must be a positive integer');
|
||||
}
|
||||
}
|
||||
16
project/src/modules/inventory/index.ts
Normal file
16
project/src/modules/inventory/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** Public API of the inventory module. */
|
||||
import type pg from 'pg';
|
||||
import { InventoryService } from './application/inventory-service.js';
|
||||
import { PgInventoryRepository } from './infrastructure/pg-inventory-repository.js';
|
||||
|
||||
export { registerInventoryRoutes, type InventoryRoutesDeps } from './api/inventory.routes.js';
|
||||
export { InventoryService } from './application/inventory-service.js';
|
||||
export type {
|
||||
InventoryRepository,
|
||||
InventoryService as InventoryServicePort,
|
||||
} from './domain/ports.js';
|
||||
export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js';
|
||||
|
||||
export function createInventoryService(pool: pg.Pool): InventoryService {
|
||||
return new InventoryService(new PgInventoryRepository(pool));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type pg from 'pg';
|
||||
import type { InventoryRepository } from '../domain/ports.js';
|
||||
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
|
||||
|
||||
interface StockRow {
|
||||
id: string;
|
||||
variant_id: string;
|
||||
available: number;
|
||||
reserved: number;
|
||||
sold: number;
|
||||
incoming: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
type InventoryOperation = 'reserve' | 'release' | 'confirm' | 'set_available';
|
||||
|
||||
export class PgInventoryRepository implements InventoryRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findByVariantId(variantId: string): Promise<StockItem | undefined> {
|
||||
const result = await this.pool.query<StockRow>(
|
||||
'SELECT * FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toStockItem(row) : undefined;
|
||||
}
|
||||
|
||||
async setAvailable(input: SetAvailableStockCommand): Promise<StockItem> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await client.query<StockRow>(
|
||||
`INSERT INTO inventory_stock (variant_id, available)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (variant_id) DO UPDATE
|
||||
SET available = EXCLUDED.available, updated_at = now()
|
||||
RETURNING *`,
|
||||
[input.variantId, input.quantity],
|
||||
);
|
||||
const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row');
|
||||
await insertMovement(client, input.variantId, 'set_available', input.quantity);
|
||||
await client.query('COMMIT');
|
||||
return item;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async reserve(input: StockCommand): Promise<StockItem | undefined> {
|
||||
return this.applyAtomicOperation(input, 'reserve', (client, command) =>
|
||||
client.query<StockRow>(
|
||||
`UPDATE inventory_stock
|
||||
SET available = available - $2, reserved = reserved + $2, updated_at = now()
|
||||
WHERE variant_id = $1 AND available >= $2
|
||||
RETURNING *`,
|
||||
[command.variantId, command.quantity],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async release(input: StockCommand): Promise<StockItem | undefined> {
|
||||
return this.applyAtomicOperation(input, 'release', (client, command) =>
|
||||
client.query<StockRow>(
|
||||
`UPDATE inventory_stock
|
||||
SET reserved = reserved - $2, available = available + $2, updated_at = now()
|
||||
WHERE variant_id = $1 AND reserved >= $2
|
||||
RETURNING *`,
|
||||
[command.variantId, command.quantity],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async confirm(input: StockCommand): Promise<StockItem | undefined> {
|
||||
return this.applyAtomicOperation(input, 'confirm', (client, command) =>
|
||||
client.query<StockRow>(
|
||||
`UPDATE inventory_stock
|
||||
SET reserved = reserved - $2, sold = sold + $2, updated_at = now()
|
||||
WHERE variant_id = $1 AND reserved >= $2
|
||||
RETURNING *`,
|
||||
[command.variantId, command.quantity],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async applyAtomicOperation(
|
||||
input: StockCommand,
|
||||
operation: InventoryOperation,
|
||||
update: (client: pg.PoolClient, input: StockCommand) => Promise<pg.QueryResult<StockRow>>,
|
||||
): Promise<StockItem | undefined> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await update(client, input);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
await client.query('ROLLBACK');
|
||||
return undefined;
|
||||
}
|
||||
await insertMovement(client, input.variantId, operation, input.quantity);
|
||||
await client.query('COMMIT');
|
||||
return toStockItem(row);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function insertMovement(
|
||||
client: pg.PoolClient,
|
||||
variantId: string,
|
||||
operation: InventoryOperation,
|
||||
quantity: number,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_movements (variant_id, operation, quantity)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[variantId, operation, quantity],
|
||||
);
|
||||
}
|
||||
|
||||
function rowOrThrow(row: StockRow | undefined, message: string): StockItem {
|
||||
if (!row) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return toStockItem(row);
|
||||
}
|
||||
|
||||
function toStockItem(row: StockRow): StockItem {
|
||||
return {
|
||||
id: row.id,
|
||||
variantId: row.variant_id,
|
||||
available: row.available,
|
||||
reserved: row.reserved,
|
||||
sold: row.sold,
|
||||
incoming: row.incoming,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
29
project/src/modules/inventory/tests/boundary.test.ts
Normal file
29
project/src/modules/inventory/tests/boundary.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const entries = readdirSync(dir);
|
||||
return entries.flatMap((entry) => {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
return sourceFiles(path);
|
||||
}
|
||||
return path.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('inventory module boundary', () => {
|
||||
it('keeps catalog isolated from inventory internals and tables (AC3)', () => {
|
||||
const catalogDir = new URL('../../catalog', import.meta.url);
|
||||
|
||||
for (const file of sourceFiles(catalogDir.pathname)) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toContain('inventory_');
|
||||
expect(source).not.toMatch(
|
||||
/modules\/inventory\/(?:api|application|domain|infrastructure|tests)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InventoryService } from '../application/inventory-service.js';
|
||||
import {
|
||||
InsufficientReservedStockError,
|
||||
InsufficientStockError,
|
||||
InvalidStockQuantityError,
|
||||
} from '../domain/errors.js';
|
||||
import type { InventoryRepository } from '../domain/ports.js';
|
||||
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
|
||||
|
||||
const STOCK: StockItem = {
|
||||
id: 'stock-1',
|
||||
variantId: 'variant-1',
|
||||
available: 1,
|
||||
reserved: 0,
|
||||
sold: 0,
|
||||
incoming: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
function repository(overrides: Partial<InventoryRepository> = {}): InventoryRepository {
|
||||
return {
|
||||
findByVariantId: async () => STOCK,
|
||||
setAvailable: async (_input: SetAvailableStockCommand) => STOCK,
|
||||
reserve: async (_input: StockCommand) => STOCK,
|
||||
release: async (_input: StockCommand) => STOCK,
|
||||
confirm: async (_input: StockCommand) => STOCK,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('InventoryService', () => {
|
||||
it('rejects non-positive reserve quantities before persistence', async () => {
|
||||
const service = new InventoryService(repository());
|
||||
|
||||
await expect(service.reserve({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf(
|
||||
InvalidStockQuantityError,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps failed reservation to insufficient stock', async () => {
|
||||
const service = new InventoryService(repository({ reserve: async () => undefined }));
|
||||
|
||||
await expect(service.reserve({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
|
||||
InsufficientStockError,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps failed release to insufficient reserved stock', async () => {
|
||||
const service = new InventoryService(repository({ release: async () => undefined }));
|
||||
|
||||
await expect(service.release({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
|
||||
InsufficientReservedStockError,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks availability without mutating stock', async () => {
|
||||
const service = new InventoryService(repository());
|
||||
|
||||
await expect(service.checkAvailability('variant-1', 1)).resolves.toEqual({
|
||||
available: true,
|
||||
availableQuantity: 1,
|
||||
});
|
||||
await expect(service.checkAvailability('variant-1', 2)).resolves.toEqual({
|
||||
available: false,
|
||||
availableQuantity: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user