Files
qipai/backend/src/inventory/inventory-service.ts
T

1300 lines
45 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export type InventoryPolicyType = 'TRACKED' | 'UNLIMITED';
export type InventoryOperation =
| 'CONFIGURE'
| 'INBOUND'
| 'ADJUST'
| 'LOCK'
| 'RELEASE'
| 'DEDUCT'
| 'STOCKTAKE'
| 'LOSS'
| 'RETURN';
const MAX_QUANTITY = 1_000_000_000;
const MAX_BATCH_ITEMS = 100;
const idPattern = /^[1-9]\d{0,19}$/;
const requestIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
const businessTypePattern = /^[A-Z][A-Z0-9_.:-]{0,63}$/;
interface InventoryRow extends RowDataPacket {
id: string;
tenantId: string;
storeId: string;
skuId: string;
policyType: InventoryPolicyType;
availableQuantity: number;
lockedQuantity: number;
lossQuantity: number;
lowStockThreshold: number;
version: number;
}
interface InventoryListRow extends InventoryRow {
skuCode: string;
skuName: string;
productId: string;
productCode: string;
productName: string;
salePriceCents: number;
}
interface CountRow extends RowDataPacket { total: number }
interface InventoryRequestRow extends RowDataPacket {
tenantId: string;
storeId: string;
requestId: string;
operation: InventoryOperation;
businessType: string;
businessId: string;
fingerprint: string;
itemCount: number;
}
interface ReservationRow extends RowDataPacket {
inventoryId: string;
reservedQuantity: number;
}
interface ReservationTotalRow extends RowDataPacket { reservedQuantity: number }
interface LedgerRow extends RowDataPacket {
id: string;
inventoryId: string;
storeId: string;
skuId: string;
requestId: string;
businessType: string;
businessId: string;
operation: InventoryOperation;
availableDelta: number;
lockedDelta: number;
lossDelta: number;
availableAfter: number;
lockedAfter: number;
lossAfter: number;
versionAfter: number;
operatorId: string | null;
traceId: string;
reason: string;
metadata: unknown;
createdAt: Date;
}
export interface InventoryStock {
id: string;
tenantId: string;
storeId: string;
skuId: string;
skuCode: string;
skuName: string;
productId: string;
productCode: string;
productName: string;
salePriceCents: number;
policyType: InventoryPolicyType;
availableQuantity: number;
lockedQuantity: number;
lossQuantity: number;
lowStockThreshold: number;
lowStock: boolean;
version: number;
}
export interface InventoryLedgerEntry {
id: string;
inventoryId: string;
storeId: string;
skuId: string;
requestId: string;
businessType: string;
businessId: string;
operation: InventoryOperation;
availableDelta: number;
lockedDelta: number;
lossDelta: number;
availableAfter: number;
lockedAfter: number;
lossAfter: number;
versionAfter: number;
operatorId: string | null;
traceId: string;
reason: string;
metadata: Record<string, unknown>;
createdAt: Date;
}
export interface InventoryPage<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface InventoryMutationResult {
inventoryId: string;
ledgerId: string;
storeId: string;
skuId: string;
operation: InventoryOperation;
policyType: InventoryPolicyType;
availableQuantity: number;
lockedQuantity: number;
lossQuantity: number;
lowStockThreshold: number;
version: number;
idempotent: boolean;
}
export interface InventoryListInput {
storeId: string;
page: number;
pageSize: number;
skuId?: string;
productId?: string;
search?: string;
}
export interface InventoryLedgerListInput {
storeId: string;
inventoryId: string;
page: number;
pageSize: number;
}
interface AdminMutationBase {
storeId: string;
skuId: string;
requestId: string;
reason: string;
expectedVersion?: number;
}
export interface ConfigureInventoryPolicyInput extends AdminMutationBase {
expectedVersion: number;
policyType: InventoryPolicyType;
lowStockThreshold: number;
}
export interface InventoryInboundInput extends AdminMutationBase { quantity: number }
export interface InventoryAdjustmentInput extends AdminMutationBase {
availableDelta: number;
lossDelta: number;
}
export interface InventoryStocktakeInput extends AdminMutationBase {
expectedVersion: number;
availableQuantity: number;
lossQuantity: number;
}
export interface InventoryLossInput extends AdminMutationBase { quantity: number }
export interface InventoryBatchItem { skuId: string; quantity: number }
export interface InventoryBatchMutationInput {
tenantId: string;
storeId: string;
items: InventoryBatchItem[];
requestId: string;
businessType: string;
businessId: string;
traceId: string;
operatorId?: string | null;
ip?: string;
userAgent?: string;
reason?: string;
metadata?: Record<string, unknown>;
}
interface StockState {
policyType: InventoryPolicyType;
availableQuantity: number;
lockedQuantity: number;
lossQuantity: number;
lowStockThreshold: number;
}
interface DeltaState {
availableDelta: number;
lockedDelta: number;
lossDelta: number;
reservationDelta: number;
next: StockState;
}
interface MutationIdentity {
requestId: string;
businessType: string;
businessId: string;
operation: InventoryOperation;
operatorId: string | null;
traceId: string;
reason: string;
metadata: Record<string, unknown>;
fingerprint: string;
}
interface AuditIdentity {
tenantId: string;
operatorId: string | null;
traceId: string;
ip: string;
userAgent: string;
}
export class InventoryError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class InventoryService {
constructor(private readonly pool: MySqlPool) {}
async listStocks(
actor: ManagementActor,
input: InventoryListInput
): Promise<InventoryPage<InventoryStock>> {
this.assertStoreScope(actor, input.storeId, false);
const page = boundedPage(input.page);
const pageSize = boundedPageSize(input.pageSize);
const filters: string[] = ['i.tenant_id = ?', 'i.store_id = ?'];
const params: Array<string | number> = [actor.tenantId, input.storeId];
if (input.skuId) {
assertId(input.skuId, 'INVENTORY_SKU_ID_INVALID');
filters.push('i.sku_id = ?');
params.push(input.skuId);
}
if (input.productId) {
assertId(input.productId, 'INVENTORY_PRODUCT_ID_INVALID');
filters.push('s.product_id = ?');
params.push(input.productId);
}
const search = input.search?.trim();
if (search) {
if (search.length > 64) throw new InventoryError('INVENTORY_SEARCH_INVALID');
filters.push('(s.sku_code LIKE ? OR s.name LIKE ? OR p.product_code LIKE ? OR p.name LIKE ?)');
const keyword = `%${search}%`;
params.push(keyword, keyword, keyword, keyword);
}
const where = filters.join(' AND ');
const [countRows] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total
FROM qipai_product_inventory i
INNER JOIN qipai_product_skus s
ON s.tenant_id = i.tenant_id AND s.id = i.sku_id AND s.deleted_at IS NULL
INNER JOIN qipai_products p
ON p.tenant_id = s.tenant_id AND p.id = s.product_id AND p.deleted_at IS NULL
WHERE ${where}`,
params
);
const [rows] = await this.pool.execute<InventoryListRow[]>(
`SELECT i.id, i.tenant_id AS tenantId, i.store_id AS storeId,
i.sku_id AS skuId, i.policy_type AS policyType,
i.available_quantity AS availableQuantity,
i.locked_quantity AS lockedQuantity, i.loss_quantity AS lossQuantity,
i.low_stock_threshold AS lowStockThreshold, i.version,
s.sku_code AS skuCode, s.name AS skuName, s.product_id AS productId,
s.sale_price_cents AS salePriceCents,
p.product_code AS productCode, p.name AS productName
FROM qipai_product_inventory i
INNER JOIN qipai_product_skus s
ON s.tenant_id = i.tenant_id AND s.id = i.sku_id AND s.deleted_at IS NULL
INNER JOIN qipai_products p
ON p.tenant_id = s.tenant_id AND p.id = s.product_id AND p.deleted_at IS NULL
WHERE ${where}
ORDER BY p.sort_order, p.id, s.id
LIMIT ? OFFSET ?`,
[...params, pageSize, (page - 1) * pageSize]
);
return {
items: rows.map(mapStock),
total: Number(countRows[0]?.total ?? 0),
page,
pageSize
};
}
async listLedger(
actor: ManagementActor,
input: InventoryLedgerListInput
): Promise<InventoryPage<InventoryLedgerEntry>> {
this.assertStoreScope(actor, input.storeId, false);
assertId(input.inventoryId, 'INVENTORY_ID_INVALID');
const page = boundedPage(input.page);
const pageSize = boundedPageSize(input.pageSize);
const params = [actor.tenantId, input.storeId, input.inventoryId];
const [countRows] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND store_id = ? AND inventory_id = ?`,
params
);
const [rows] = await this.pool.execute<LedgerRow[]>(
`SELECT id, inventory_id AS inventoryId, store_id AS storeId, sku_id AS skuId,
request_id AS requestId, business_type AS businessType,
business_id AS businessId, operation,
available_delta AS availableDelta, locked_delta AS lockedDelta,
loss_delta AS lossDelta, available_after AS availableAfter,
locked_after AS lockedAfter, loss_after AS lossAfter,
version_after AS versionAfter, operator_id AS operatorId,
trace_id AS traceId, reason, metadata, created_at AS createdAt
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND store_id = ? AND inventory_id = ?
ORDER BY id DESC LIMIT ? OFFSET ?`,
[...params, pageSize, (page - 1) * pageSize]
);
return {
items: rows.map(mapLedger),
total: Number(countRows[0]?.total ?? 0),
page,
pageSize
};
}
async configurePolicy(actor: ManagementActor, input: ConfigureInventoryPolicyInput) {
this.assertStoreScope(actor, input.storeId, true);
validateAdminBase(input, true);
const threshold = boundedNonNegative(input.lowStockThreshold, 'INVENTORY_THRESHOLD_INVALID');
if (!['TRACKED', 'UNLIMITED'].includes(input.policyType)) {
throw new InventoryError('INVENTORY_POLICY_INVALID');
}
const identity = mutationIdentity({
requestId: input.requestId,
businessType: 'ADMIN_INVENTORY_POLICY',
businessId: input.skuId,
operation: 'CONFIGURE',
operatorId: actor.userId,
traceId: actor.traceId,
reason: input.reason,
metadata: {},
payload: {
storeId: input.storeId, skuId: input.skuId, policyType: input.policyType,
lowStockThreshold: threshold, expectedVersion: input.expectedVersion
}
});
return this.transaction(async (connection) => {
const requestExists = await this.registerRequest(
connection, actor.tenantId, input.storeId, identity, 1
);
if (requestExists) {
const stock = await this.lockStock(
connection, actor.tenantId, input.storeId, input.skuId
);
const duplicate = await this.replayOrConflict(connection, stock, identity);
if (!duplicate) throw new InventoryError('INVENTORY_IDEMPOTENCY_CONFLICT');
return duplicate;
}
await this.assertSkuManageable(connection, actor.tenantId, input.skuId);
const created = await this.ensureInventory(
connection, actor.tenantId, input.storeId, input.skuId, actor.userId
);
const stock = await this.lockStock(connection, actor.tenantId, input.storeId, input.skuId);
if ((created && input.expectedVersion !== 0)
|| (!created && input.expectedVersion === 0)) {
throw new InventoryError('INVENTORY_VERSION_CONFLICT');
}
if (!created) assertExpectedVersion(stock, input.expectedVersion);
if (input.policyType !== stock.policyType) {
await this.assertNoActiveReservations(connection, stock);
}
if (input.policyType === 'UNLIMITED'
&& (number(stock.availableQuantity) !== 0
|| number(stock.lockedQuantity) !== 0
|| number(stock.lossQuantity) !== 0)) {
throw new InventoryError('INVENTORY_POLICY_STOCK_NOT_ZERO');
}
const next: StockState = {
policyType: input.policyType,
availableQuantity: number(stock.availableQuantity),
lockedQuantity: number(stock.lockedQuantity),
lossQuantity: number(stock.lossQuantity),
lowStockThreshold: input.policyType === 'UNLIMITED' ? 0 : threshold
};
return this.persistMutation(connection, stock, identity, {
availableDelta: 0, lockedDelta: 0, lossDelta: 0, reservationDelta: 0, next
}, auditFromActor(actor));
});
}
async inbound(actor: ManagementActor, input: InventoryInboundInput) {
const quantity = boundedPositive(input.quantity, 'INVENTORY_QUANTITY_INVALID');
return this.adminMutation(actor, input, 'INBOUND', 'ADMIN_INVENTORY_INBOUND', {
quantity
}, (stock) => {
assertTracked(stock);
return delta(stock, quantity, 0, 0);
});
}
async adjust(actor: ManagementActor, input: InventoryAdjustmentInput) {
const availableDelta = boundedDelta(input.availableDelta, 'INVENTORY_ADJUSTMENT_INVALID');
const lossDelta = boundedDelta(input.lossDelta, 'INVENTORY_ADJUSTMENT_INVALID');
if (availableDelta === 0 && lossDelta === 0) {
throw new InventoryError('INVENTORY_ADJUSTMENT_ZERO');
}
return this.adminMutation(actor, input, 'ADJUST', 'ADMIN_INVENTORY_ADJUST', {
availableDelta, lossDelta
}, (stock) => {
assertTracked(stock);
return delta(stock, availableDelta, 0, lossDelta);
});
}
async stocktake(actor: ManagementActor, input: InventoryStocktakeInput) {
assertRequiredExpectedVersion(input.expectedVersion, false);
const available = boundedNonNegative(
input.availableQuantity, 'INVENTORY_STOCKTAKE_INVALID'
);
const loss = boundedNonNegative(input.lossQuantity, 'INVENTORY_STOCKTAKE_INVALID');
return this.adminMutation(actor, input, 'STOCKTAKE', 'ADMIN_INVENTORY_STOCKTAKE', {
availableQuantity: available, lossQuantity: loss
}, (stock) => {
assertTracked(stock);
return delta(
stock,
available - number(stock.availableQuantity),
0,
loss - number(stock.lossQuantity)
);
});
}
async recordLoss(actor: ManagementActor, input: InventoryLossInput) {
const quantity = boundedPositive(input.quantity, 'INVENTORY_QUANTITY_INVALID');
return this.adminMutation(actor, input, 'LOSS', 'ADMIN_INVENTORY_LOSS', {
quantity
}, (stock) => {
assertTracked(stock);
if (number(stock.availableQuantity) < quantity) {
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
}
return delta(stock, -quantity, 0, quantity);
});
}
async lockMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
return this.batchMutation(input, 'LOCK', (stock, quantity) => {
if (stock.policyType === 'UNLIMITED') return delta(stock, 0, 0, 0, quantity);
if (number(stock.availableQuantity) < quantity) {
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
}
return delta(stock, -quantity, quantity, 0, quantity);
}, connection);
}
async releaseMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
return this.batchMutation(input, 'RELEASE', (stock, quantity) => {
if (stock.policyType === 'UNLIMITED') return delta(stock, 0, 0, 0, -quantity);
if (number(stock.lockedQuantity) < quantity) {
throw new InventoryError('INVENTORY_INSUFFICIENT_LOCKED');
}
return delta(stock, quantity, -quantity, 0, -quantity);
}, connection);
}
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 (number(stock.lockedQuantity) < quantity) {
throw new InventoryError('INVENTORY_INSUFFICIENT_LOCKED');
}
return delta(stock, 0, -quantity, 0, -quantity);
}, connection);
}
private async adminMutation(
actor: ManagementActor,
input: AdminMutationBase,
operation: InventoryOperation,
businessType: string,
payload: Record<string, unknown>,
calculate: (stock: InventoryRow) => DeltaState
) {
this.assertStoreScope(actor, input.storeId, true);
validateAdminBase(input);
const identity = mutationIdentity({
requestId: input.requestId,
businessType,
businessId: input.skuId,
operation,
operatorId: actor.userId,
traceId: actor.traceId,
reason: input.reason,
metadata: {},
payload: {
storeId: input.storeId, skuId: input.skuId,
expectedVersion: input.expectedVersion ?? null, ...payload
}
});
return this.transaction(async (connection) => {
const requestExists = await this.registerRequest(
connection, actor.tenantId, input.storeId, identity, 1
);
if (requestExists) {
const stock = await this.lockStock(
connection, actor.tenantId, input.storeId, input.skuId
);
const duplicate = await this.replayOrConflict(connection, stock, identity);
if (!duplicate) throw new InventoryError('INVENTORY_IDEMPOTENCY_CONFLICT');
return duplicate;
}
await this.assertSkuManageable(connection, actor.tenantId, input.skuId);
const stock = await this.lockStock(connection, actor.tenantId, input.storeId, input.skuId);
assertExpectedVersion(stock, input.expectedVersion);
return this.persistMutation(
connection, stock, identity, calculate(stock), auditFromActor(actor)
);
});
}
private async batchMutation(
input: InventoryBatchMutationInput,
operation: 'LOCK' | 'RELEASE' | 'DEDUCT',
calculate: (stock: InventoryRow, quantity: number) => DeltaState,
externalConnection?: PoolConnection
) {
const normalized = normalizeBatch(input);
const fingerprint = idempotencyFingerprint({
tenantId: normalized.tenantId,
storeId: normalized.storeId,
items: normalized.items,
businessType: normalized.businessType,
businessId: normalized.businessId,
operation,
reason: normalized.reason,
metadata: normalized.metadata
});
const identity = mutationIdentity({
requestId: normalized.requestId,
businessType: normalized.businessType,
businessId: normalized.businessId,
operation,
operatorId: normalized.operatorId,
traceId: normalized.traceId,
reason: normalized.reason,
metadata: normalized.metadata,
fingerprint
});
const work = async (connection: PoolConnection) => {
const requestExists = await this.registerRequest(
connection, normalized.tenantId, normalized.storeId,
identity, normalized.items.length
);
const stocks: Array<{ stock: InventoryRow; quantity: number }> = [];
for (const item of normalized.items) {
if (operation === 'LOCK' && !requestExists) {
await this.assertSkuLockable(connection, normalized.tenantId, item.skuId);
}
const stock = await this.lockStock(
connection, normalized.tenantId, normalized.storeId, item.skuId
);
stocks.push({ stock, quantity: item.quantity });
}
if (requestExists) {
const duplicates: Array<InventoryMutationResult | null> = [];
for (const item of stocks) {
duplicates.push(await this.replayOrConflict(connection, item.stock, identity));
}
if (!duplicates.every(Boolean)) {
throw new InventoryError('INVENTORY_IDEMPOTENCY_CONFLICT');
}
return { items: duplicates as InventoryMutationResult[], idempotent: true };
}
if (operation !== 'LOCK') {
await this.assertReservationBalances(
connection, normalized.tenantId, normalized.storeId,
normalized.businessType, normalized.businessId, stocks
);
}
const changes = stocks.map(({ stock, quantity }) => ({
stock, change: calculate(stock, quantity)
}));
const audit: AuditIdentity = {
tenantId: normalized.tenantId,
operatorId: normalized.operatorId,
traceId: normalized.traceId,
ip: normalized.ip,
userAgent: normalized.userAgent
};
const results: InventoryMutationResult[] = [];
for (const item of changes) {
results.push(await this.persistMutation(
connection, item.stock, identity, item.change, audit
));
}
return { items: results, idempotent: false };
};
return externalConnection ? work(externalConnection) : this.transaction(work);
}
private async registerRequest(
connection: PoolConnection,
tenantId: string,
storeId: string,
identity: MutationIdentity,
itemCount: number
): Promise<boolean> {
try {
await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_product_inventory_requests
(tenant_id, store_id, request_id, operation, business_type, business_id,
fingerprint, item_count, operator_id, trace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[tenantId, storeId, identity.requestId, identity.operation,
identity.businessType, identity.businessId, identity.fingerprint,
itemCount, identity.operatorId, identity.traceId]
);
return false;
} catch (error) {
if (!isDuplicateEntry(error)) throw error;
}
const [rows] = await connection.execute<InventoryRequestRow[]>(
`SELECT tenant_id AS tenantId, store_id AS storeId, request_id AS requestId,
operation, business_type AS businessType, business_id AS businessId,
fingerprint, item_count AS itemCount
FROM qipai_product_inventory_requests
WHERE tenant_id = ? AND request_id = ? FOR UPDATE`,
[tenantId, identity.requestId]
);
const request = rows[0];
if (!request
|| String(request.storeId) !== storeId
|| request.requestId !== identity.requestId
|| request.operation !== identity.operation
|| request.businessType !== identity.businessType
|| request.businessId !== identity.businessId
|| request.fingerprint !== identity.fingerprint
|| number(request.itemCount) !== itemCount) {
throw new InventoryError('INVENTORY_IDEMPOTENCY_CONFLICT');
}
return true;
}
private async assertSkuLockable(
connection: PoolConnection,
tenantId: string,
skuId: string
) {
await this.assertSkuLifecycle(connection, tenantId, skuId, true);
}
private async assertSkuManageable(
connection: PoolConnection,
tenantId: string,
skuId: string
) {
await this.assertSkuLifecycle(connection, tenantId, skuId, false);
}
private async assertSkuLifecycle(
connection: PoolConnection,
tenantId: string,
skuId: string,
requireActive: boolean
) {
const unavailableCode = requireActive
? 'INVENTORY_SKU_NOT_SELLABLE'
: 'INVENTORY_SKU_NOT_FOUND';
const [skuRows] = await connection.execute<RowDataPacket[]>(
`SELECT product_id AS productId
FROM qipai_product_skus
WHERE tenant_id = ? AND id = ? LIMIT 1`,
[tenantId, skuId]
);
const productId = skuRows[0]?.productId;
if (!productId) throw new InventoryError(unavailableCode);
const [productRows] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_products
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${requireActive ? "AND status = 'ACTIVE'" : ''}
FOR SHARE`,
[tenantId, productId]
);
if (!productRows[0]) throw new InventoryError(unavailableCode);
const [activeSkuRows] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_product_skus
WHERE tenant_id = ? AND product_id = ? AND id = ?
AND deleted_at IS NULL
${requireActive ? "AND status = 'ACTIVE'" : ''}
FOR SHARE`,
[tenantId, productId, skuId]
);
if (!activeSkuRows[0]) throw new InventoryError(unavailableCode);
}
private async assertReservationBalances(
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, '$.inventoryReservationDelta'))
AS SIGNED),
locked_delta
)), 0) AS reservedQuantity
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND store_id = ?
AND business_type = ? AND business_id = ?
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_RESERVATION');
}
}
}
private async assertNoActiveReservations(
connection: PoolConnection,
stock: InventoryRow
) {
const [rows] = await connection.execute<ReservationTotalRow[]>(
`SELECT COALESCE(SUM(COALESCE(
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.inventoryReservationDelta'))
AS SIGNED),
locked_delta
)), 0) AS reservedQuantity
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ?
FOR SHARE`,
[stock.tenantId, stock.id]
);
if (number(rows[0]?.reservedQuantity ?? 0) !== 0) {
throw new InventoryError('INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS');
}
}
private async ensureInventory(
connection: PoolConnection,
tenantId: string,
storeId: string,
skuId: string,
operatorId: string
): Promise<boolean> {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_product_inventory
(tenant_id, store_id, sku_id, policy_type, available_quantity,
locked_quantity, loss_quantity, low_stock_threshold, version, updated_by)
SELECT ?, st.id, s.id, s.default_inventory_policy, 0, 0, 0, 0, 1, ?
FROM qipai_product_skus s
INNER JOIN qipai_products p
ON p.tenant_id = s.tenant_id AND p.id = s.product_id AND p.deleted_at IS NULL
INNER JOIN qipai_stores st
ON st.tenant_id = ? AND st.id = ? AND st.deleted_at IS NULL
WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL`,
[tenantId, operatorId, tenantId, storeId, tenantId, skuId]
);
if (result.affectedRows === 0) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?
LIMIT 1 FOR UPDATE`,
[tenantId, storeId, skuId]
);
if (!rows[0]) throw new InventoryError('INVENTORY_SKU_OR_STORE_NOT_FOUND');
}
return result.affectedRows === 1;
}
private async lockStock(
connection: PoolConnection,
tenantId: string,
storeId: string,
skuId: string
): Promise<InventoryRow> {
const [rows] = await connection.execute<InventoryRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, sku_id AS skuId,
policy_type AS policyType, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity,
low_stock_threshold AS lowStockThreshold, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id = ? FOR UPDATE`,
[tenantId, storeId, skuId]
);
if (!rows[0]) throw new InventoryError('INVENTORY_STOCK_NOT_FOUND');
return normalizeStockRow(rows[0]);
}
private async replayOrConflict(
connection: PoolConnection,
stock: InventoryRow,
identity: MutationIdentity
): Promise<InventoryMutationResult | null> {
const [rows] = await connection.execute<LedgerRow[]>(
`SELECT id, inventory_id AS inventoryId, store_id AS storeId, sku_id AS skuId,
request_id AS requestId, business_type AS businessType,
business_id AS businessId, operation,
available_delta AS availableDelta, locked_delta AS lockedDelta,
loss_delta AS lossDelta, available_after AS availableAfter,
locked_after AS lockedAfter, loss_after AS lossAfter,
version_after AS versionAfter, operator_id AS operatorId,
trace_id AS traceId, reason, metadata, created_at AS createdAt
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ? AND request_id = ? LIMIT 1`,
[stock.tenantId, stock.id, identity.requestId]
);
const row = rows[0];
if (!row) return null;
const metadata = parseMetadata(row.metadata);
if (metadata.idempotencyFingerprint !== identity.fingerprint) {
throw new InventoryError('INVENTORY_IDEMPOTENCY_CONFLICT');
}
return mutationResult(stock, row, true);
}
private async persistMutation(
connection: PoolConnection,
stock: InventoryRow,
identity: MutationIdentity,
change: DeltaState,
audit: AuditIdentity
): Promise<InventoryMutationResult> {
validateState(change.next);
const nextVersion = number(stock.version) + 1;
const [updated] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_product_inventory
SET policy_type = ?, available_quantity = ?, locked_quantity = ?,
loss_quantity = ?, low_stock_threshold = ?, version = ?, updated_by = ?
WHERE tenant_id = ? AND id = ? AND version = ?`,
[change.next.policyType, change.next.availableQuantity, change.next.lockedQuantity,
change.next.lossQuantity, change.next.lowStockThreshold, nextVersion,
identity.operatorId, stock.tenantId, stock.id, stock.version]
);
if (updated.affectedRows !== 1) throw new InventoryError('INVENTORY_VERSION_CONFLICT');
const ledgerMetadata = {
...identity.metadata,
idempotencyFingerprint: identity.fingerprint,
inventoryReservationDelta: change.reservationDelta,
resultPolicyType: change.next.policyType,
resultLowStockThreshold: change.next.lowStockThreshold
};
const [ledger] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_product_inventory_ledger
(tenant_id, inventory_id, store_id, sku_id, request_id, business_type,
business_id, operation, available_delta, locked_delta, loss_delta,
available_after, locked_after, loss_after, version_after, operator_id,
trace_id, reason, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[stock.tenantId, stock.id, stock.storeId, stock.skuId, identity.requestId,
identity.businessType, identity.businessId, identity.operation,
change.availableDelta, change.lockedDelta, change.lossDelta,
change.next.availableQuantity, change.next.lockedQuantity, change.next.lossQuantity,
nextVersion, identity.operatorId, identity.traceId, identity.reason.slice(0, 512),
JSON.stringify(ledgerMetadata)]
);
await this.audit(connection, audit, stock, identity, change, String(ledger.insertId));
return {
inventoryId: String(stock.id),
ledgerId: String(ledger.insertId),
storeId: String(stock.storeId),
skuId: String(stock.skuId),
operation: identity.operation,
policyType: change.next.policyType,
availableQuantity: change.next.availableQuantity,
lockedQuantity: change.next.lockedQuantity,
lossQuantity: change.next.lossQuantity,
lowStockThreshold: change.next.lowStockThreshold,
version: nextVersion,
idempotent: false
};
}
private async audit(
connection: PoolConnection,
audit: AuditIdentity,
stock: InventoryRow,
identity: MutationIdentity,
change: DeltaState,
ledgerId: string
) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, ?, ?, ?, 'PRODUCT_INVENTORY', ?, ?, ?, ?, ?)`,
[audit.tenantId, audit.operatorId ? 'USER' : 'SYSTEM', audit.operatorId,
`PRODUCT_INVENTORY_${identity.operation}`, stock.id, audit.traceId,
audit.ip.slice(0, 64), audit.userAgent.slice(0, 255), JSON.stringify({
storeId: String(stock.storeId),
skuId: String(stock.skuId),
requestId: identity.requestId,
businessType: identity.businessType,
businessId: identity.businessId,
ledgerId,
availableDelta: change.availableDelta,
lockedDelta: change.lockedDelta,
lossDelta: change.lossDelta,
reservationDelta: change.reservationDelta,
versionAfter: number(stock.version) + 1
})]
);
}
private assertStoreScope(actor: ManagementActor, storeId: string, write: boolean) {
assertId(storeId, 'INVENTORY_STORE_ID_INVALID');
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
const capability = write ? 'inventory.adjust' : 'inventory.read';
const hasCapability = actor.access.capabilities.includes(capability)
|| (!write && actor.access.capabilities.includes('inventory.adjust'));
if (!hasCapability || !actor.access.storeIds.includes(storeId)) {
throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
}
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function normalizeBatch(input: InventoryBatchMutationInput) {
assertId(input.tenantId, 'INVENTORY_TENANT_ID_INVALID');
assertId(input.storeId, 'INVENTORY_STORE_ID_INVALID');
validateRequestId(input.requestId);
if (!businessTypePattern.test(input.businessType)) {
throw new InventoryError('INVENTORY_BUSINESS_TYPE_INVALID');
}
if (!input.businessId.trim() || input.businessId.length > 128) {
throw new InventoryError('INVENTORY_BUSINESS_ID_INVALID');
}
if (!input.traceId.trim() || input.traceId.length > 128) {
throw new InventoryError('INVENTORY_TRACE_ID_INVALID');
}
if (!input.items.length || input.items.length > MAX_BATCH_ITEMS) {
throw new InventoryError('INVENTORY_BATCH_INVALID');
}
const quantities = new Map<string, number>();
for (const item of input.items) {
assertId(item.skuId, 'INVENTORY_SKU_ID_INVALID');
const quantity = boundedPositive(item.quantity, 'INVENTORY_QUANTITY_INVALID');
const combined = (quantities.get(item.skuId) ?? 0) + quantity;
quantities.set(
item.skuId,
boundedPositive(combined, 'INVENTORY_QUANTITY_INVALID')
);
}
const items = [...quantities.entries()]
.map(([skuId, quantity]) => ({ skuId, quantity }))
.sort((left, right) => compareIds(left.skuId, right.skuId));
if (input.operatorId != null) assertId(input.operatorId, 'INVENTORY_OPERATOR_ID_INVALID');
return {
...input,
items,
operatorId: input.operatorId ?? null,
ip: input.ip ?? '',
userAgent: input.userAgent ?? '',
reason: (input.reason ?? '').trim().slice(0, 512),
metadata: input.metadata ?? {}
};
}
function validateAdminBase(input: AdminMutationBase, allowCreateVersion = false) {
assertId(input.storeId, 'INVENTORY_STORE_ID_INVALID');
assertId(input.skuId, 'INVENTORY_SKU_ID_INVALID');
validateRequestId(input.requestId);
if (!input.reason.trim() || input.reason.trim().length > 512) {
throw new InventoryError('INVENTORY_REASON_INVALID');
}
if (input.expectedVersion === undefined) {
if (allowCreateVersion) throw new InventoryError('INVENTORY_VERSION_INVALID');
} else if (allowCreateVersion) {
boundedNonNegative(input.expectedVersion, 'INVENTORY_VERSION_INVALID');
} else {
boundedPositive(input.expectedVersion, 'INVENTORY_VERSION_INVALID');
}
}
function assertRequiredExpectedVersion(value: number | undefined, allowZero: boolean) {
if (value === undefined) throw new InventoryError('INVENTORY_VERSION_INVALID');
if (allowZero) boundedNonNegative(value, 'INVENTORY_VERSION_INVALID');
else boundedPositive(value, 'INVENTORY_VERSION_INVALID');
}
function mutationIdentity(input: {
requestId: string;
businessType: string;
businessId: string;
operation: InventoryOperation;
operatorId: string | null;
traceId: string;
reason: string;
metadata: Record<string, unknown>;
payload?: Record<string, unknown>;
fingerprint?: string;
}): MutationIdentity {
return {
requestId: input.requestId,
businessType: input.businessType,
businessId: input.businessId,
operation: input.operation,
operatorId: input.operatorId,
traceId: input.traceId,
reason: input.reason.trim(),
metadata: input.metadata,
fingerprint: input.fingerprint ?? idempotencyFingerprint({
businessType: input.businessType,
businessId: input.businessId,
operation: input.operation,
reason: input.reason.trim(),
payload: input.payload ?? {},
metadata: input.metadata
})
};
}
function idempotencyFingerprint(value: unknown) {
return createHash('sha256').update(stableJson(value)).digest('hex');
}
function stableJson(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
const object = value as Record<string, unknown>;
return `{${Object.keys(object).filter((key) => object[key] !== undefined).sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
}
function delta(
stock: InventoryRow,
availableDelta: number,
lockedDelta: number,
lossDelta: number,
reservationDelta = 0
): DeltaState {
const next = {
policyType: stock.policyType,
availableQuantity: number(stock.availableQuantity) + availableDelta,
lockedQuantity: number(stock.lockedQuantity) + lockedDelta,
lossQuantity: number(stock.lossQuantity) + lossDelta,
lowStockThreshold: number(stock.lowStockThreshold)
};
validateState(next);
return { availableDelta, lockedDelta, lossDelta, reservationDelta, next };
}
function validateState(state: StockState) {
for (const value of [
state.availableQuantity, state.lockedQuantity, state.lossQuantity, state.lowStockThreshold
]) boundedNonNegative(value, 'INVENTORY_QUANTITY_OUT_OF_RANGE');
if (state.policyType === 'UNLIMITED'
&& (state.availableQuantity !== 0 || state.lockedQuantity !== 0
|| state.lossQuantity !== 0 || state.lowStockThreshold !== 0)) {
throw new InventoryError('INVENTORY_UNLIMITED_STATE_INVALID');
}
}
function assertTracked(stock: InventoryRow) {
if (stock.policyType !== 'TRACKED') {
throw new InventoryError('INVENTORY_UNLIMITED_MUTATION_FORBIDDEN');
}
}
function assertExpectedVersion(stock: InventoryRow, expected?: number) {
if (expected !== undefined && number(stock.version) !== expected) {
throw new InventoryError('INVENTORY_VERSION_CONFLICT');
}
}
function mutationResult(
stock: InventoryRow,
row: LedgerRow,
idempotent: boolean
): InventoryMutationResult {
const metadata = parseMetadata(row.metadata);
const policyType = metadata.resultPolicyType === 'TRACKED'
|| metadata.resultPolicyType === 'UNLIMITED'
? metadata.resultPolicyType
: stock.policyType;
const lowStockThreshold = typeof metadata.resultLowStockThreshold === 'number'
&& Number.isSafeInteger(metadata.resultLowStockThreshold)
&& metadata.resultLowStockThreshold >= 0
? metadata.resultLowStockThreshold
: number(stock.lowStockThreshold);
return {
inventoryId: String(row.inventoryId),
ledgerId: String(row.id),
storeId: String(row.storeId),
skuId: String(row.skuId),
operation: row.operation,
policyType,
availableQuantity: number(row.availableAfter),
lockedQuantity: number(row.lockedAfter),
lossQuantity: number(row.lossAfter),
lowStockThreshold,
version: number(row.versionAfter),
idempotent
};
}
function mapStock(row: InventoryListRow): InventoryStock {
const policyType = row.policyType;
const availableQuantity = number(row.availableQuantity);
const threshold = number(row.lowStockThreshold);
return {
id: String(row.id),
tenantId: String(row.tenantId),
storeId: String(row.storeId),
skuId: String(row.skuId),
skuCode: row.skuCode,
skuName: row.skuName,
productId: String(row.productId),
productCode: row.productCode,
productName: row.productName,
salePriceCents: number(row.salePriceCents),
policyType,
availableQuantity,
lockedQuantity: number(row.lockedQuantity),
lossQuantity: number(row.lossQuantity),
lowStockThreshold: threshold,
lowStock: policyType === 'TRACKED' && availableQuantity <= threshold,
version: number(row.version)
};
}
function mapLedger(row: LedgerRow): InventoryLedgerEntry {
return {
id: String(row.id),
inventoryId: String(row.inventoryId),
storeId: String(row.storeId),
skuId: String(row.skuId),
requestId: row.requestId,
businessType: row.businessType,
businessId: row.businessId,
operation: row.operation,
availableDelta: number(row.availableDelta),
lockedDelta: number(row.lockedDelta),
lossDelta: number(row.lossDelta),
availableAfter: number(row.availableAfter),
lockedAfter: number(row.lockedAfter),
lossAfter: number(row.lossAfter),
versionAfter: number(row.versionAfter),
operatorId: row.operatorId === null ? null : String(row.operatorId),
traceId: row.traceId,
reason: row.reason,
metadata: parseMetadata(row.metadata),
createdAt: row.createdAt
};
}
function normalizeStockRow(row: InventoryRow): InventoryRow {
return {
...row,
id: String(row.id),
tenantId: String(row.tenantId),
storeId: String(row.storeId),
skuId: String(row.skuId),
availableQuantity: number(row.availableQuantity),
lockedQuantity: number(row.lockedQuantity),
lossQuantity: number(row.lossQuantity),
lowStockThreshold: number(row.lowStockThreshold),
version: number(row.version)
};
}
function parseMetadata(value: unknown): Record<string, unknown> {
if (!value) return {};
if (typeof value === 'object' && !Buffer.isBuffer(value)) {
return value as Record<string, unknown>;
}
try {
const parsed = JSON.parse(Buffer.isBuffer(value) ? value.toString('utf8') : String(value));
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
function isDuplicateEntry(error: unknown) {
return Boolean(error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_DUP_ENTRY');
}
function auditFromActor(actor: ManagementActor): AuditIdentity {
return {
tenantId: actor.tenantId,
operatorId: actor.userId,
traceId: actor.traceId,
ip: actor.ip,
userAgent: actor.userAgent
};
}
function compareIds(left: string, right: string) {
const leftId = BigInt(left);
const rightId = BigInt(right);
return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
}
function assertId(value: string, code: string) {
if (!idPattern.test(value)) throw new InventoryError(code);
}
function validateRequestId(value: string) {
if (!requestIdPattern.test(value)) throw new InventoryError('INVENTORY_REQUEST_ID_INVALID');
}
function boundedPage(value: number) {
if (!Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
throw new InventoryError('INVENTORY_PAGE_INVALID');
}
return value;
}
function boundedPageSize(value: number) {
if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
throw new InventoryError('INVENTORY_PAGE_SIZE_INVALID');
}
return value;
}
function boundedPositive(value: number, code: string) {
const parsed = boundedNonNegative(value, code);
if (parsed === 0) throw new InventoryError(code);
return parsed;
}
function boundedNonNegative(value: number, code: string) {
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_QUANTITY) {
throw new InventoryError(code);
}
return value;
}
function boundedDelta(value: number, code: string) {
if (!Number.isSafeInteger(value) || value < -MAX_QUANTITY || value > MAX_QUANTITY) {
throw new InventoryError(code);
}
return value;
}
function number(value: unknown) { return Number(value); }