148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
}
|