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; createdAt: Date; } export interface InventoryPage { 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; } 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; 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> { 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 = [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( `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( `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> { 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( `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( `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, 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 = []; 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 { try { await connection.execute( `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( `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( `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( `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( `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( `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( `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 { const [result] = await connection.execute( `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( `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 { const [rows] = await connection.execute( `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 { const [rows] = await connection.execute( `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 { validateState(change.next); const nextVersion = number(stock.version) + 1; const [updated] = await connection.execute( `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( `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(work: (connection: PoolConnection) => Promise) { 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(); 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; payload?: Record; 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; 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 { if (!value) return {}; if (typeof value === 'object' && !Buffer.isBuffer(value)) { return value as Record; } try { const parsed = JSON.parse(Buffer.isBuffer(value) ? value.toString('utf8') : String(value)); return parsed && typeof parsed === 'object' ? parsed as Record : {}; } 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); }