1300 lines
45 KiB
TypeScript
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); }
|