feat(M09-D1): 完成商品目录与库存流水底座

This commit is contained in:
Codex
2026-08-11 03:51:18 +08:00
parent 2de796143f
commit 93af128002
37 changed files with 14683 additions and 58 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+10
View File
@@ -62,6 +62,8 @@ import {
type SystemOperationsRouteOptions
} from './routes/system-operations.js';
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -90,6 +92,8 @@ export interface BuildAppOptions {
franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions;
products?: ProductRouteOptions;
inventory?: InventoryRouteOptions;
}
declare module 'fastify' {
@@ -206,6 +210,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.adminAuth) {
await registerAdminAuthRoutes(app, options.adminAuth);
}
if (options.products) {
await registerProductRoutes(app, options.products);
}
if (options.inventory) {
await registerInventoryRoutes(app, options.inventory);
}
return app;
}
+8 -3
View File
@@ -141,14 +141,19 @@ export class AuthRepository {
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read'))
AND p.code IN ('profile.read', 'store.operation.read',
'product.catalog.read', 'inventory.read'))
OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'))
WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId]
);
+6 -1
View File
@@ -37,7 +37,8 @@ export class RbacRepository {
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read'))
AND p.code IN ('profile.read', 'store.operation.read',
'product.catalog.read', 'inventory.read'))
OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read'))
@@ -45,11 +46,15 @@ export class RbacRepository {
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
WHERE r.tenant_id = ?`,
+325 -6
View File
@@ -11,6 +11,15 @@ export interface MigrationPlan {
file: string;
checksum: string;
statements: readonly string[];
migrations?: readonly MigrationFilePlan[];
}
export interface MigrationFilePlan {
version: string;
name: string;
file: string;
checksum: string;
statements: readonly string[];
}
export interface MigrationExecutionResult extends MigrationPlan {
@@ -18,6 +27,12 @@ export interface MigrationExecutionResult extends MigrationPlan {
affectedRows: number;
}
type MigrationQueryExecutor = Pick<MySqlPool, 'query'>;
type MigrationPool = MigrationQueryExecutor & Partial<Pick<MySqlPool, 'getConnection'>>;
const migrationLockExpression =
"CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
up: [
@@ -55,7 +70,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -92,9 +108,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
],
down: [
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
@@ -223,17 +241,30 @@ export async function loadMigrationPlan(direction: MigrationDirection): Promise<
relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
);
const sql = sqlParts.join('\n');
const migrations = relativeFiles.map((file, index) => {
const match = /(?:^|\/)(\d+)_([a-z0-9_]+)\.(?:up|verify|down)\.sql$/iu.exec(file);
if (!match) throw new Error(`Invalid migration file name: ${file}`);
const migrationSql = sqlParts[index];
return {
version: match[1],
name: match[2],
file,
checksum: createHash('sha256').update(migrationSql).digest('hex'),
statements: splitSqlStatements(migrationSql)
};
});
return {
direction,
file: relativeFiles.join(','),
checksum: createHash('sha256').update(sql).digest('hex'),
statements: splitSqlStatements(sql)
statements: splitSqlStatements(sql),
migrations
};
}
export async function executeMigrationPlan(
pool: Pick<MySqlPool, 'query'>,
pool: MigrationPool,
plan: MigrationPlan,
dryRun = false
): Promise<MigrationExecutionResult> {
@@ -241,9 +272,64 @@ export async function executeMigrationPlan(
return { ...plan, executed: false, affectedRows: 0 };
}
if (pool.getConnection) {
const connection = await pool.getConnection();
let lockAcquired = false;
let executionFailed = false;
try {
const [rows] = await connection.query(
`SELECT GET_LOCK(${migrationLockExpression}, 30) AS acquired`
);
const acquired = Array.isArray(rows)
? Number((rows[0] as Record<string, unknown> | undefined)?.acquired ?? 0)
: 0;
if (acquired !== 1) {
throw new Error('MIGRATION_LOCK_TIMEOUT: another migration process is still running.');
}
lockAcquired = true;
return await executeMigrationPlanUnlocked(connection, plan);
} catch (error) {
executionFailed = true;
throw error;
} finally {
let releaseError: unknown;
if (lockAcquired) {
try {
const [rows] = await connection.query(
`SELECT RELEASE_LOCK(${migrationLockExpression}) AS released`
);
const released = Array.isArray(rows)
? Number((rows[0] as Record<string, unknown> | undefined)?.released ?? 0)
: 0;
if (released !== 1) {
releaseError = new Error('MIGRATION_LOCK_RELEASE_FAILED: migration lock was lost.');
}
} catch (error) {
releaseError = error;
}
}
connection.release();
if (!executionFailed && releaseError) throw releaseError;
}
}
return executeMigrationPlanUnlocked(pool, plan);
}
async function executeMigrationPlanUnlocked(
pool: MigrationQueryExecutor,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
if (plan.direction === 'up' && plan.migrations?.length) {
return executeVersionedUpPlan(pool, plan);
}
if (plan.direction === 'up') await assertTriggerConfiguration(pool, plan.statements);
let affectedRows = 0;
for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement);
const result = await queryMigrationStatement(pool, statement);
if (plan.direction === 'verify') {
const minimumRows = [
10, 26, 1,
@@ -279,7 +365,8 @@ export async function executeMigrationPlan(
1, 2, 3, 1,
1, 2, 3, 1,
3, 7, 3, 1,
2, 8, 4, 1
2, 8, 4, 1,
9, 63, 18, 28, 15, 2, 4, 1, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -297,3 +384,235 @@ export async function executeMigrationPlan(
return { ...plan, executed: true, affectedRows };
}
interface AppliedMigrationRow extends Record<string, unknown> {
version?: unknown;
name?: unknown;
checksum?: unknown;
}
async function executeVersionedUpPlan(
pool: Pick<MySqlPool, 'query'>,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
const migrations = plan.migrations ?? [];
const appliedRows = await readAppliedMigrations(pool);
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
for (const migration of migrations) {
const row = applied.get(migration.version);
if (!row) continue;
if (String(row.name) !== migration.name) {
throw new Error(
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
);
}
const storedChecksum = String(row.checksum ?? '');
if (storedChecksum && storedChecksum !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
const pending = migrations.filter((migration) => !applied.has(migration.version));
await assertTriggerConfiguration(
pool,
pending.flatMap((migration) => [...migration.statements])
);
let affectedRows = 0;
for (const migration of pending) {
for (const statement of migration.statements) {
const result = await queryMigrationStatement(pool, statement);
if (result && typeof result === 'object' && 'affectedRows' in result) {
const value = Reflect.get(result, 'affectedRows');
if (typeof value === 'number') affectedRows += value;
}
}
const [markerRows] = await pool.query(
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const marker = Array.isArray(markerRows)
? markerRows[0] as Record<string, unknown> | undefined
: undefined;
if (!marker || String(marker.name) !== migration.name) {
throw new Error(
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
);
}
await storeMigrationChecksum(pool, migration);
}
for (const migration of migrations) {
await storeMigrationChecksum(pool, migration);
}
return { ...plan, executed: true, affectedRows };
}
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
try {
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
throw error;
}
}
async function storeMigrationChecksum(
pool: Pick<MySqlPool, 'query'>,
migration: MigrationFilePlan
) {
try {
const [result] = await pool.query(
`UPDATE qipai_schema_migrations
SET checksum = ?
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
[migration.checksum, migration.version, migration.checksum]
);
if (result && typeof result === 'object'
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
const [rows] = await pool.query(
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const stored = Array.isArray(rows)
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
: '';
if (stored && stored !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
throw error;
}
}
async function assertTriggerConfiguration(
pool: Pick<MySqlPool, 'query'>,
statements: readonly string[]
) {
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
const [rows] = await pool.query(
`SELECT @@GLOBAL.log_bin AS logBin,
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
);
const setting = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
if (Number(setting?.logBin ?? 0) === 1
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
throw new Error(
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
);
}
}
async function queryMigrationStatement(
pool: Pick<MySqlPool, 'query'>,
statement: string
) {
try {
const [result] = await pool.query(statement);
return result;
} catch (error) {
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
throw error;
}
}
async function isMissingM09D1DownObject(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (indexTarget) {
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${indexTarget.table}'
AND index_name = '${indexTarget.index}'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
async function isMatchingM09D1ChecksumColumn(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
is_nullable AS isNullable, column_default AS columnDefault,
collation_name AS collationName
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'`
);
const column = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
return column?.dataType === 'char'
&& Number(column.maxLength) === 64
&& column.isNullable === 'NO'
&& String(column.columnDefault ?? '') === ''
&& column.collationName === 'ascii_bin';
}
async function isMatchingM09D1ScopeIndex(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
return false;
}
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (!target) return false;
const [rows] = await pool.query(
`SELECT non_unique AS nonUnique,
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${target.table}'
AND index_name = '${target.index}'
GROUP BY non_unique`
);
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -113,6 +113,8 @@ function adminAccess(access: AccessProfile) {
const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant || access.capabilities.includes('product.catalog.read')
|| access.capabilities.includes('inventory.read') ? ['products'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
+250
View File
@@ -0,0 +1,250 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
const version = z.number().int().min(1).max(4_294_967_295);
const createVersion = z.number().int().min(0).max(4_294_967_295);
const quantity = z.number().int().min(1).max(1_000_000_000);
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
const reason = z.string().trim().min(1).max(512);
const stockParams = z.object({ skuId: id }).strict();
const ledgerParams = z.object({ inventoryId: id }).strict();
const stockListQuery = z.object({
storeId: id,
page,
pageSize,
skuId: id.optional(),
productId: id.optional(),
search: z.string().trim().max(64).optional()
}).strict();
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
const mutationBase = {
storeId: id,
requestId,
reason,
expectedVersion: version.optional()
};
const policyBody = z.object({
...mutationBase,
expectedVersion: createVersion,
policyType: z.enum(['TRACKED', 'UNLIMITED']),
lowStockThreshold: nonNegativeQuantity
}).strict();
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
const adjustmentBody = z.object({
...mutationBase,
availableDelta: delta,
lossDelta: delta.default(0)
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
const stocktakeBody = z.object({
...mutationBase,
expectedVersion: version,
availableQuantity: nonNegativeQuantity,
lossQuantity: nonNegativeQuantity
}).strict();
const lossBody = z.object({ ...mutationBase, quantity }).strict();
export interface InventoryRouteOptions {
service: Pick<InventoryService,
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
| 'stocktake' | 'recordLoss'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerInventoryRoutes(
app: FastifyInstance,
options: InventoryRouteOptions
) {
app.get('/admin-api/inventory/stocks', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const query = stockListQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listStocks(actor, query.data),
traceId: request.traceId
}));
});
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const params = ledgerParams.safeParse(request.params);
const query = ledgerQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listLedger(actor, {
inventoryId: params.data.inventoryId,
...query.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = policyBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.configurePolicy(actor, {
skuId: params.data.skuId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = inboundBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = adjustmentBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = stocktakeBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = lossBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
}
async function requireInventoryActor(
request: FastifyRequest,
reply: FastifyReply,
options: InventoryRouteOptions,
write: boolean
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
const manager = access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const allowed = manager || (write
? access.capabilities.includes('inventory.adjust')
: access.capabilities.includes('inventory.read')
|| access.capabilities.includes('inventory.adjust'));
if (!allowed) {
reply.status(403).send({
code: 'INVENTORY_OPERATION_FORBIDDEN',
message: 'Inventory permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function inventoryResponse(
reply: FastifyReply,
traceId: string,
work: () => Promise<unknown>
) {
try {
return await work();
} catch (error) {
if (!(error instanceof InventoryError)) throw error;
const status = inventoryErrorStatus(error.code);
return reply.status(status).send({
code: error.code,
message: 'The inventory operation is not allowed.',
traceId
});
}
}
function inventoryErrorStatus(code: string) {
if (code.endsWith('_FORBIDDEN')) return 403;
if (code.endsWith('_NOT_FOUND')) return 404;
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
return 400;
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_INVENTORY_REQUEST',
message: 'The inventory request is invalid.',
traceId
});
}
+487
View File
@@ -0,0 +1,487 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import {
ProductCatalogError,
type ProductCatalogRepository
} from '../products/product-catalog-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const version = z.coerce.number().int().min(0).max(0xffffffff);
const code = z.string().trim().min(1).max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
const storeParamsSchema = z.object({ storeId: id }).strict();
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
const productParamsSchema = z.object({ productId: id }).strict();
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
const includeInactiveQuerySchema = z.object({
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
}).strict();
const categoryFields = {
parentId: id.nullable().optional(),
categoryCode: code,
name: z.string().trim().min(1).max(128),
description: z.string().trim().max(1024).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createCategorySchema = z.object(categoryFields).strict();
const updateCategorySchema = z.object({
...categoryFields,
expectedVersion: version.min(1)
}).strict();
const productFields = {
productCode: code,
name: z.string().trim().min(1).max(128),
unitName: z.string().trim().min(1).max(32),
description: z.string().trim().max(10000).default(''),
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
images: z.array(z.string().url().max(512)).max(20).default([]),
deliveryEnabled: z.boolean().default(true),
storageEnabled: z.boolean().default(false),
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createProductSchema = z.object(productFields).strict();
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
const productListQuerySchema = z.object({
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
search: z.string().trim().min(1).max(128).optional()
}).strict();
const attributesSchema = z.record(
z.string().trim().min(1).max(64),
z.string().trim().max(128)
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
const skuFields = {
skuCode: code,
name: z.string().trim().min(1).max(128),
attributes: attributesSchema.default({}),
barcode: z.string().trim().max(64).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
salePriceCents: z.number().int().min(0).max(100000000),
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
costPriceCents: z.number().int().min(0).max(100000000).default(0),
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
};
const createSkuSchema = z.object(skuFields).strict();
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
const listingSchema = z.object({
expectedVersion: version,
categoryId: id,
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
salesStartAt: z.coerce.date().nullable().optional(),
salesEndAt: z.coerce.date().nullable().optional(),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|| value.salesEndAt > value.salesStartAt, {
message: 'salesEndAt must be later than salesStartAt'
});
const hourSchema = z.object({
weekday: z.number().int().min(1).max(7),
slotNo: z.number().int().min(1).max(8),
openMinute: z.number().int().min(0).max(1439),
closeMinute: z.number().int().min(0).max(1439),
crossesMidnight: z.boolean()
}).strict();
const settingsSchema = z.object({
expectedVersion: version,
salesStatus: z.enum(['OPEN', 'CLOSED']),
manualPaused: z.boolean(),
manualPausedUntil: z.coerce.date().nullable().optional(),
manualPauseReason: z.string().trim().max(512).default(''),
hours: z.array(hourSchema).max(56).default([])
}).strict().superRefine((value, context) => {
if (value.manualPaused && value.manualPauseReason.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['manualPauseReason'],
message: 'manualPauseReason is required while manually paused'
});
}
});
export interface ProductRouteOptions {
repository: Pick<ProductCatalogRepository,
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
| 'listListings' | 'putListing' | 'archiveListing'
| 'getStoreSettings' | 'putStoreSettings'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
for (const path of [
'/admin-api/stores/:storeId/product-categories',
'/app-api/management/stores/:storeId/product-categories'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listCategories(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = createCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const body = updateCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateCategory(
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveCategory(
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const query = productListQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listProducts(actor, query.data),
traceId: request.traceId
}));
});
app.post('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const body = createProductSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createProduct(actor, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = updateProductSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateProduct(
actor, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveProduct(
actor, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const params = productParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSkus(
actor, params.data.productId, query.data.includeInactive
),
traceId: request.traceId
}));
});
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = createSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createSku(actor, params.data.productId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const body = updateSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateSku(
actor, params.data.productId, params.data.skuId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveSku(
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-listings',
'/app-api/management/stores/:storeId/product-listings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listListings(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const body = listingSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putListing(
actor, params.data.storeId, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveListing(
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-sales-settings',
'/app-api/management/stores/:storeId/product-sales-settings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getStoreSettings(actor, params.data.storeId),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = settingsSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putStoreSettings(
actor, params.data.storeId, expectedVersion, input
),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: ProductRouteOptions,
mode: 'read' | 'write',
scope: 'store' | 'global-catalog' = 'store'
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
const allowed = access.capabilities.includes(capability)
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|| access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
if (!allowed) {
reply.status(403).send({
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
message: 'Product catalog permission is required.',
traceId: request.traceId
});
return null;
}
if (scope === 'global-catalog') {
const tenantManager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
&& access.storeIds.length > 0
&& (access.capabilities.includes('product.catalog.read')
|| access.capabilities.includes('product.catalog.write'));
if (!tenantManager && !authorizedStoreAdmin) {
reply.status(403).send({
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
message: 'Tenant management or an authorized store administrator is required.',
traceId: request.traceId
});
return null;
}
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ProductCatalogError)) throw error;
const code = error.code;
const status = code.endsWith('_FORBIDDEN') ? 403
: code.endsWith('_NOT_FOUND') ? 404
: code.includes('INVALID') ? 400
: 409;
return reply.status(status).send({
code,
message: 'The product catalog request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
});
}
+16
View File
@@ -44,6 +44,8 @@ import { BusinessStatisticsRepository } from './operations/business-statistics-r
import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
import { InventoryService } from './inventory/inventory-service.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -54,6 +56,8 @@ const orderManagementRepository = new OrderManagementRepository(pool);
const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool);
const cleaningTaskRepository = new CleaningTaskRepository(pool);
const productCatalogRepository = new ProductCatalogRepository(pool);
const inventoryService = new InventoryService(pool);
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
@@ -241,6 +245,18 @@ const app = await buildApp({
jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds
},
products: {
repository: productCatalogRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
inventory: {
service: inventoryService,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+1
View File
@@ -43,6 +43,7 @@ assert.equal(loginInput.tenantCode, 'demo');
assert.equal(login.json().data.user.phone, undefined);
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
assert.ok(login.json().data.access.menus.includes('system'));
assert.ok(login.json().data.access.menus.includes('products'));
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
const successfulSessionId = loginInput.sessionId;
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { signAccessToken } from '../dist/auth/jwt.js';
import { InventoryError } from '../dist/inventory/inventory-service.js';
import { registerInventoryRoutes } from '../dist/routes/inventory.js';
const secret = 'test-only-inventory-route-jwt-secret';
const sessionId = '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e';
const token = signAccessToken({
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
}, secret, 900);
const headers = {
authorization: `Bearer ${token}`,
'x-trace-id': 'm09d1-inventory-route'
};
const calls = [];
let currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const mutationResult = (input, operation) => ({
inventoryId: '101',
ledgerId: '1001',
storeId: input.storeId,
skuId: input.skuId,
operation,
policyType: input.policyType ?? 'TRACKED',
availableQuantity: input.availableQuantity ?? 8,
lockedQuantity: 0,
lossQuantity: input.lossQuantity ?? 1,
lowStockThreshold: input.lowStockThreshold ?? 2,
version: 2,
idempotent: false
});
const record = (method, result) => async (...args) => {
calls.push({ method, args });
return typeof result === 'function' ? result(...args) : result;
};
const mutate = (operation) => record(operation.toLowerCase(), (_actor, input) => {
if (input.storeId === '12') throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
if (input.requestId === 'insufficient') {
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
}
return mutationResult(input, operation);
});
const service = {
listStocks: record('listStocks', (_actor, input) => ({
items: [], total: 0, page: input.page, pageSize: input.pageSize
})),
listLedger: record('listLedger', (_actor, input) => ({
items: [], total: 0, page: input.page, pageSize: input.pageSize
})),
configurePolicy: mutate('CONFIGURE'),
inbound: mutate('INBOUND'),
adjust: mutate('ADJUST'),
stocktake: mutate('STOCKTAKE'),
recordLoss: mutate('LOSS')
};
const app = Fastify({ logger: false });
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
request.traceId = request.headers['x-trace-id'] || request.id;
reply.header('x-trace-id', request.traceId);
});
await registerInventoryRoutes(app, {
service,
authRepository: {
async validateSession() {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60_000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() { return currentAccess; }
},
jwtSecret: secret
});
const unauthorized = await app.inject({
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11'
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
const forbidden = await app.inject({
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
});
assert.equal(forbidden.statusCode, 403);
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
currentAccess = {
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
};
const listed = await app.inject({
method: 'GET',
url: '/admin-api/inventory/stocks?storeId=11&page=2&pageSize=25&search=tea',
headers
});
assert.equal(listed.statusCode, 200);
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
const listCall = calls.find((call) => call.method === 'listStocks');
assert.equal(listCall.args[0].tenantId, '7');
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
assert.deepEqual(listCall.args[1], {
storeId: '11', page: 2, pageSize: 25, search: 'tea'
});
const ledger = await app.inject({
method: 'GET',
url: '/admin-api/inventory/stocks/101/ledger?storeId=11&page=1&pageSize=10',
headers
});
assert.equal(ledger.statusCode, 200);
const ledgerCall = calls.find((call) => call.method === 'listLedger');
assert.equal(ledgerCall.args[1].inventoryId, '101');
const readOnlyWrite = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
payload: { storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2 }
});
assert.equal(readOnlyWrite.statusCode, 403);
currentAccess = {
roles: ['STORE_ADMIN'], capabilities: ['inventory.adjust'], storeIds: ['11']
};
const invalid = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/adjust', headers,
payload: {
storeId: '11', requestId: 'adjust-zero', reason: 'nothing',
availableDelta: 0, lossDelta: 0
}
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.json().code, 'INVALID_INVENTORY_REQUEST');
const requests = [
['PUT', '/admin-api/inventory/stocks/5/policy', {
storeId: '11', requestId: 'policy-1', reason: 'configure',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
}, 'configure'],
['POST', '/admin-api/inventory/stocks/5/inbound', {
storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2
}, 'inbound'],
['POST', '/admin-api/inventory/stocks/5/adjust', {
storeId: '11', requestId: 'adjust-1', reason: 'manual correction',
availableDelta: -1, lossDelta: 1
}, 'adjust'],
['POST', '/admin-api/inventory/stocks/5/stocktake', {
storeId: '11', requestId: 'stocktake-1', reason: 'count',
expectedVersion: 1, availableQuantity: 8, lossQuantity: 1
}, 'stocktake'],
['POST', '/admin-api/inventory/stocks/5/loss', {
storeId: '11', requestId: 'loss-1', reason: 'damaged', quantity: 1
}, 'loss']
];
for (const [method, url, payload, calledMethod] of requests) {
const response = await app.inject({ method, url, headers, payload });
assert.equal(response.statusCode, 200, `${method} ${url}`);
assert.equal(response.json().code, 0);
const call = calls.findLast((item) => item.method === calledMethod);
assert.equal(call.args[1].skuId, '5');
assert.equal(call.args[1].storeId, '11');
}
for (const [method, url, payload] of [
['PUT', '/admin-api/inventory/stocks/5/policy', {
storeId: '11', requestId: 'policy-no-version', reason: 'configure',
policyType: 'TRACKED', lowStockThreshold: 2
}],
['POST', '/admin-api/inventory/stocks/5/stocktake', {
storeId: '11', requestId: 'stocktake-no-version', reason: 'count',
availableQuantity: 8, lossQuantity: 1
}]
]) {
const response = await app.inject({ method, url, headers, payload });
assert.equal(response.statusCode, 400, `${method} ${url} requires expectedVersion`);
assert.equal(response.json().code, 'INVALID_INVENTORY_REQUEST');
}
const insufficient = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
payload: { storeId: '11', requestId: 'insufficient', reason: 'conflict', quantity: 2 }
});
assert.equal(insufficient.statusCode, 409);
assert.equal(insufficient.json().code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
const outOfScope = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/loss', headers,
payload: { storeId: '12', requestId: 'loss-outside', reason: 'wrong store', quantity: 1 }
});
assert.equal(outOfScope.statusCode, 403);
assert.equal(outOfScope.json().code, 'INVENTORY_STORE_SCOPE_FORBIDDEN');
const internalRoute = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/lock', headers,
payload: { storeId: '11', requestId: 'lock-1', reason: 'must stay internal', quantity: 1 }
});
assert.equal(internalRoute.statusCode, 404);
await app.close();
console.log('PASS: M09-D1 inventory routes enforce auth, inventory.adjust, validation, error mapping and HTTP boundaries.');
+446
View File
@@ -0,0 +1,446 @@
import assert from 'node:assert/strict';
import {
InventoryError,
InventoryService
} from '../dist/inventory/inventory-service.js';
const state = {
stocks: [
stock('101', '5', 10, 0, 1, 2, 'TRACKED'),
stock('102', '2', 4, 0, 0, 1, 'TRACKED'),
stock('103', '9', 0, 0, 0, 0, 'UNLIMITED')
],
skus: new Map([
['2', 'TRACKED'], ['4', 'TRACKED'], ['5', 'TRACKED'], ['9', 'UNLIMITED']
]),
inactiveSkus: new Set(),
archivedSkus: new Set(),
requests: [],
ledgers: [],
audits: [],
lockOrder: [],
nextStockId: 104,
nextLedgerId: 1001,
commits: 0,
rollbacks: 0
};
function stock(id, skuId, availableQuantity, lockedQuantity, lossQuantity,
lowStockThreshold, policyType) {
return {
id, tenantId: '7', storeId: '11', skuId, policyType,
availableQuantity, lockedQuantity, lossQuantity, lowStockThreshold, version: 1
};
}
function cloneMutableState() {
return structuredClone({
stocks: state.stocks,
requests: state.requests,
ledgers: state.ledgers,
audits: state.audits,
nextStockId: state.nextStockId,
nextLedgerId: state.nextLedgerId
});
}
let transactionSnapshot = null;
const pool = {
async getConnection() { return connection; },
async execute(sql, params = []) { return execute(sql, params); }
};
const connection = {
async beginTransaction() { transactionSnapshot = cloneMutableState(); },
async commit() { state.commits += 1; transactionSnapshot = null; },
async rollback() {
state.rollbacks += 1;
if (transactionSnapshot) {
state.stocks = transactionSnapshot.stocks;
state.requests = transactionSnapshot.requests;
state.ledgers = transactionSnapshot.ledgers;
state.audits = transactionSnapshot.audits;
state.nextStockId = transactionSnapshot.nextStockId;
state.nextLedgerId = transactionSnapshot.nextLedgerId;
}
transactionSnapshot = null;
},
release() {},
async execute(sql, params = []) { return execute(sql, params); }
};
function execute(sql, params) {
if (/\b(UPDATE|DELETE)\s+qipai_product_inventory_ledger/i.test(sql)) {
throw new Error('immutable ledger was mutated');
}
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory_ledger')) {
const matches = state.ledgers.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]));
return [[{ total: matches.length }], []];
}
if (sql.includes('FROM qipai_product_inventory_ledger')
&& sql.includes('ORDER BY id DESC')) {
const rows = state.ledgers.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]))
.sort((left, right) => Number(right.id) - Number(left.id));
return [rows, []];
}
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory i')) {
const matches = state.stocks.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]));
return [[{ total: matches.length }], []];
}
if (sql.includes('FROM qipai_product_inventory i') && sql.includes('ORDER BY p.sort_order')) {
const rows = state.stocks.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1])).map((item) => ({
...item,
skuCode: `SKU-${item.skuId}`,
skuName: `规格 ${item.skuId}`,
productId: `8${item.skuId}`,
productCode: `P-${item.skuId}`,
productName: `商品 ${item.skuId}`,
salePriceCents: 1200
}));
return [rows, []];
}
if (sql.includes('INSERT IGNORE INTO qipai_product_inventory')) {
const tenantId = String(params[0]);
const storeId = String(params[3]);
const skuId = String(params[5]);
const existing = findStock(tenantId, storeId, skuId);
if (existing || tenantId !== '7' || storeId !== '11' || !state.skus.has(skuId)) {
return [{ affectedRows: 0 }, []];
}
state.stocks.push({
id: String(state.nextStockId++), tenantId, storeId, skuId,
policyType: state.skus.get(skuId), availableQuantity: 0, lockedQuantity: 0,
lossQuantity: 0, lowStockThreshold: 0, version: 1
});
return [{ affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_product_inventory_requests')) {
const tenantId = String(params[0]);
const requestId = String(params[2]);
if (state.requests.some((item) => item.tenantId === tenantId
&& item.requestId === requestId)) {
throw Object.assign(new Error('duplicate inventory request'), { code: 'ER_DUP_ENTRY' });
}
state.requests.push({
tenantId, storeId: String(params[1]), requestId,
operation: params[3], businessType: String(params[4]), businessId: String(params[5]),
fingerprint: String(params[6]), itemCount: Number(params[7])
});
return [{ insertId: state.requests.length, affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_product_inventory_requests') && sql.includes('FOR UPDATE')) {
const row = state.requests.find((item) => item.tenantId === String(params[0])
&& item.requestId === String(params[1]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('SELECT product_id AS productId')
&& sql.includes('FROM qipai_product_skus')) {
const skuId = String(params[1]);
return [state.skus.has(skuId) ? [{ productId: `8${skuId}` }] : [], []];
}
if (sql.includes('SELECT id FROM qipai_products') && sql.includes('FOR SHARE')) {
return [[{ id: String(params[1]) }], []];
}
if (sql.includes('SELECT id FROM qipai_product_skus') && sql.includes('FOR SHARE')) {
const skuId = String(params[2]);
const requiresActive = sql.includes("status = 'ACTIVE'");
return [state.skus.has(skuId) && !state.archivedSkus.has(skuId)
&& (!requiresActive || !state.inactiveSkus.has(skuId))
? [{ id: skuId }] : [], []];
}
if (sql.includes('SELECT id FROM qipai_product_inventory')) {
const found = findStock(String(params[0]), String(params[1]), String(params[2]));
return [found ? [{ id: found.id }] : [], []];
}
if (sql.includes('FROM qipai_product_inventory') && sql.includes('FOR UPDATE')) {
const skuId = String(params[2]);
state.lockOrder.push(skuId);
const found = findStock(String(params[0]), String(params[1]), skuId);
return [found ? [{ ...found }] : [], []];
}
if (sql.includes('FROM qipai_product_inventory_ledger')
&& sql.includes('request_id = ?')) {
const row = state.ledgers.find((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
const inventoryIds = new Set(params.slice(4).map(String));
const balances = new Map();
for (const item of state.ledgers) {
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|| !inventoryIds.has(item.inventoryId)) continue;
const metadata = JSON.parse(item.metadata);
const reservationDelta = metadata.inventoryReservationDelta ?? Number(item.lockedDelta);
balances.set(item.inventoryId,
(balances.get(item.inventoryId) ?? 0) + Number(reservationDelta));
}
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
inventoryId, reservedQuantity
})), []];
}
if (sql.includes('AS reservedQuantity')
&& sql.includes('WHERE tenant_id = ? AND inventory_id = ?')) {
const reservedQuantity = state.ledgers
.filter((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]))
.reduce((total, item) => {
const metadata = JSON.parse(item.metadata);
return total + Number(metadata.inventoryReservationDelta ?? item.lockedDelta);
}, 0);
return [[{ reservedQuantity }], []];
}
if (sql.startsWith('UPDATE qipai_product_inventory')) {
const found = state.stocks.find((item) => item.tenantId === String(params[7])
&& item.id === String(params[8]) && item.version === Number(params[9]));
if (!found) return [{ affectedRows: 0 }, []];
Object.assign(found, {
policyType: params[0], availableQuantity: Number(params[1]),
lockedQuantity: Number(params[2]), lossQuantity: Number(params[3]),
lowStockThreshold: Number(params[4]), version: Number(params[5])
});
return [{ affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_product_inventory_ledger')) {
const duplicate = state.ledgers.some((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]) && item.requestId === String(params[4]));
if (duplicate) throw new Error('duplicate inventory ledger request');
const row = {
id: String(state.nextLedgerId++),
tenantId: String(params[0]), inventoryId: String(params[1]),
storeId: String(params[2]), skuId: String(params[3]), requestId: String(params[4]),
businessType: String(params[5]), businessId: String(params[6]), operation: params[7],
availableDelta: Number(params[8]), lockedDelta: Number(params[9]),
lossDelta: Number(params[10]), availableAfter: Number(params[11]),
lockedAfter: Number(params[12]), lossAfter: Number(params[13]),
versionAfter: Number(params[14]), operatorId: params[15] == null ? null : String(params[15]),
traceId: String(params[16]), reason: String(params[17]), metadata: String(params[18]),
createdAt: new Date()
};
state.ledgers.push(row);
return [{ insertId: row.id, affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_audit_logs')) {
state.audits.push({ action: params[3], resourceId: String(params[4]), metadata: params[8] });
return [{ insertId: state.audits.length, affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
function findStock(tenantId, storeId, skuId) {
return state.stocks.find((item) => item.tenantId === tenantId
&& item.storeId === storeId && item.skuId === skuId);
}
const manager = {
tenantId: '7', userId: '21',
access: { roles: ['STORE_ADMIN'], capabilities: ['inventory.read', 'inventory.adjust'], storeIds: ['11'] },
traceId: 'inventory-test', ip: '127.0.0.1', userAgent: 'inventory-test-agent'
};
const service = new InventoryService(pool);
const listed = await service.listStocks(manager, { storeId: '11', page: 1, pageSize: 20 });
assert.equal(listed.total, 3);
assert.equal(listed.items[0].tenantId, '7');
assert.equal(listed.items[0].lossQuantity, 1);
const inboundInput = {
storeId: '11', skuId: '5', requestId: 'admin-inbound-1', reason: '首批入库',
expectedVersion: 1, quantity: 5
};
const inbound = await service.inbound(manager, inboundInput);
assert.equal(inbound.availableQuantity, 15);
assert.equal(inbound.version, 2);
const auditCountAfterInbound = state.audits.length;
const replay = await service.inbound(manager, inboundInput);
assert.equal(replay.idempotent, true);
assert.equal(replay.availableQuantity, 15);
assert.equal(state.audits.length, auditCountAfterInbound);
await assert.rejects(
() => service.inbound(manager, { ...inboundInput, quantity: 6 }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
const adjusted = await service.adjust(manager, {
storeId: '11', skuId: '5', requestId: 'admin-adjust-1', reason: '人工纠偏',
expectedVersion: 2, availableDelta: -2, lossDelta: 1
});
assert.deepEqual(
[adjusted.availableQuantity, adjusted.lossQuantity, adjusted.version],
[13, 2, 3]
);
const counted = await service.stocktake(manager, {
storeId: '11', skuId: '5', requestId: 'admin-stocktake-1', reason: '闭店盘点',
expectedVersion: 3, availableQuantity: 12, lossQuantity: 3
});
assert.deepEqual(
[counted.availableQuantity, counted.lossQuantity, counted.version],
[12, 3, 4]
);
const lost = await service.recordLoss(manager, {
storeId: '11', skuId: '5', requestId: 'admin-loss-1', reason: '包装破损',
expectedVersion: 4, quantity: 2
});
assert.deepEqual([lost.availableQuantity, lost.lossQuantity, lost.version], [10, 5, 5]);
const configured = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
});
assert.equal(configured.policyType, 'TRACKED');
assert.equal(configured.lowStockThreshold, 3);
assert.ok(findStock('7', '11', '4'));
const reconfigured = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-2', reason: 'raise warning threshold',
policyType: 'TRACKED', lowStockThreshold: 7, expectedVersion: 2
});
assert.equal(reconfigured.lowStockThreshold, 7);
const configuredReplay = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
});
assert.equal(configuredReplay.idempotent, true);
assert.equal(configuredReplay.lowStockThreshold, 3);
await assert.rejects(
() => service.configurePolicy(manager, {
storeId: '11', skuId: '5', requestId: 'admin-policy-unlimited', reason: '错误切换',
policyType: 'UNLIMITED', lowStockThreshold: 0, expectedVersion: 5
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_POLICY_STOCK_NOT_ZERO'
);
const scopedOut = { ...manager, access: { ...manager.access, storeIds: ['12'] } };
await assert.rejects(
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
);
state.lockOrder.length = 0;
const batchBase = {
tenantId: '7', storeId: '11', requestId: 'order-lock-1',
businessType: 'PRODUCT_ORDER', businessId: 'order-500', traceId: 'order-lock-trace',
operatorId: '21', reason: '创建商品订单', metadata: { source: 'ORDER' },
items: [{ skuId: '5', quantity: 3 }, { skuId: '2', quantity: 1 }, { skuId: '2', quantity: 1 }]
};
const locked = await service.lockMany(batchBase);
assert.equal(locked.idempotent, false);
assert.deepEqual(state.lockOrder.slice(-2), ['2', '5']);
assert.deepEqual(
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
[2, 2]
);
assert.deepEqual(
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
[7, 3]
);
const ledgerCountAfterLock = state.ledgers.length;
const lockedReplay = await service.lockMany(batchBase);
assert.equal(lockedReplay.idempotent, true);
assert.equal(state.ledgers.length, ledgerCountAfterLock);
await assert.rejects(
() => service.lockMany({ ...batchBase, items: [{ skuId: '5', quantity: 4 }, { skuId: '2', quantity: 2 }] }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.lockMany({ ...batchBase, items: [{ skuId: '9', quantity: 1 }] }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.releaseMany({
...batchBase, requestId: 'foreign-order-release', businessId: 'order-foreign',
items: [{ skuId: '5', quantity: 1 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await service.releaseMany({
...batchBase, requestId: 'order-release-1', reason: '部分取消',
items: [{ skuId: '5', quantity: 1 }, { skuId: '2', quantity: 1 }]
});
await service.deductMany({
...batchBase, requestId: 'order-deduct-1', reason: '支付确认扣减',
items: [{ skuId: '5', quantity: 2 }, { skuId: '2', quantity: 1 }]
});
assert.deepEqual(
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
[3, 0]
);
assert.deepEqual(
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
[8, 0]
);
const beforeFailedBatch = structuredClone(state.stocks);
await assert.rejects(
() => service.lockMany({
...batchBase, requestId: 'order-lock-too-many', businessId: 'order-501',
items: [{ skuId: '5', quantity: 999 }]
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
);
assert.deepEqual(state.stocks, beforeFailedBatch);
assert.ok(state.rollbacks > 0);
const unlimited = await service.lockMany({
...batchBase, requestId: 'order-unlimited-lock', businessId: 'order-502',
items: [{ skuId: '9', quantity: 999 }]
});
assert.equal(unlimited.items[0].availableQuantity, 0);
assert.equal(unlimited.items[0].lockedQuantity, 0);
await assert.rejects(
() => service.configurePolicy(manager, {
storeId: '11', skuId: '9', requestId: 'unlimited-switch-active', reason: '切换追踪',
expectedVersion: unlimited.items[0].version, policyType: 'TRACKED', lowStockThreshold: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
);
const unlimitedDeduct = await service.deductMany({
...batchBase, requestId: 'order-unlimited-deduct', businessId: 'order-502',
items: [{ skuId: '9', quantity: 999 }]
});
const trackedAfterUnlimited = await service.configurePolicy(manager, {
storeId: '11', skuId: '9', requestId: 'unlimited-switch-settled', reason: '切换追踪',
expectedVersion: unlimitedDeduct.items[0].version,
policyType: 'TRACKED', lowStockThreshold: 0
});
assert.equal(trackedAfterUnlimited.policyType, 'TRACKED');
state.inactiveSkus.add('2');
const inactiveReplay = await service.lockMany(batchBase);
assert.equal(inactiveReplay.idempotent, true);
await assert.rejects(
() => service.lockMany({
...batchBase, requestId: 'inactive-sku-lock', businessId: 'order-503',
items: [{ skuId: '2', quantity: 1 }]
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_SELLABLE'
);
state.inactiveSkus.delete('2');
state.archivedSkus.add('5');
const archivedAdminReplay = await service.inbound(manager, inboundInput);
assert.equal(archivedAdminReplay.idempotent, true);
await assert.rejects(
() => service.inbound(manager, {
storeId: '11', skuId: '5', requestId: 'archived-sku-inbound',
reason: '归档规格不得重新入库', quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
);
state.archivedSkus.delete('5');
const history = await service.listLedger(manager, {
storeId: '11', inventoryId: '101', page: 1, pageSize: 100
});
assert.ok(history.items.length >= 7);
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
assert.equal(state.audits.length, state.ledgers.length);
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
+76 -1
View File
@@ -120,6 +120,15 @@ const cleaningSettlementIntegrityDownSql = read(
const cleaningSettlementIntegrityVerifySql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
);
const productInventoryUpSql = read(
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
);
const productInventoryDownSql = read(
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql'
);
const productInventoryVerifySql = read(
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
);
const coreTables = [
'qipai_schema_migrations',
@@ -536,4 +545,70 @@ assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M09-C migration contracts are present.');
for (const table of [
'qipai_product_categories',
'qipai_products',
'qipai_product_skus',
'qipai_product_store_listings',
'qipai_product_store_settings',
'qipai_product_store_hours',
'qipai_product_inventory',
'qipai_product_inventory_requests',
'qipai_product_inventory_ledger'
]) {
assert.match(productInventoryUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(productInventoryDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(productInventoryVerifySql, new RegExp(`'${table}'`));
}
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_stores_tenant_id/);
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_users_tenant_id/);
assert.match(productInventoryUpSql, /ADD COLUMN checksum CHAR\(64\)/);
assert.match(productInventoryUpSql, /store_id BIGINT UNSIGNED NOT NULL/);
assert.match(productInventoryUpSql, /uq_qipai_product_category_scope_id/);
assert.match(
productInventoryUpSql,
/FOREIGN KEY \(tenant_id, store_id, category_id\)[\s\S]*qipai_product_categories/
);
assert.match(productInventoryUpSql, /delivery_enabled TINYINT\(1\) NOT NULL DEFAULT 1/);
assert.match(productInventoryUpSql, /storage_enabled TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /default_inventory_policy VARCHAR\(16\)/);
assert.match(productInventoryUpSql, /default_inventory_policy IN \('TRACKED', 'UNLIMITED'\)/);
assert.match(productInventoryUpSql, /manual_paused_at DATETIME\(3\) NULL/);
assert.match(productInventoryUpSql, /manual_paused_until DATETIME\(3\) NULL/);
assert.match(productInventoryUpSql, /manual_pause_reason VARCHAR\(512\)/);
assert.match(productInventoryUpSql, /crosses_midnight TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /crosses_midnight = 1 AND close_minute <= open_minute/);
assert.match(productInventoryUpSql, /loss_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /loss_delta BIGINT NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /loss_after BIGINT UNSIGNED NOT NULL/);
for (const operation of [
'CONFIGURE', 'INBOUND', 'ADJUST', 'LOCK', 'RELEASE',
'DEDUCT', 'STOCKTAKE', 'LOSS', 'RETURN'
]) assert.match(productInventoryUpSql, new RegExp(`'${operation}'`));
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_request/);
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_request/);
assert.match(productInventoryUpSql, /fk_qipai_product_inventory_ledger_request/);
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_version/);
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_update/);
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_delete/);
for (const permission of [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
]) {
assert.match(productInventoryUpSql, new RegExp(permission.replace('.', '\\.')));
assert.match(productInventoryDownSql, new RegExp(permission.replace('.', '\\.')));
assert.match(productInventoryVerifySql, new RegExp(permission.replace('.', '\\.')));
}
assert.match(productInventoryUpSql, /r\.code = 'STAFF'/);
assert.match(productInventoryUpSql, /r\.code IN \('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_stores_tenant_id/);
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_users_tenant_id/);
assert.match(productInventoryDownSql, /DROP COLUMN checksum/);
assert.match(
productInventoryUpSql,
/FOREIGN KEY \(tenant_id, operator_id\) REFERENCES qipai_users\(tenant_id, id\)/
);
assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
assert.match(productInventoryVerifySql, /'2026081107'/);
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
+195 -2
View File
@@ -45,7 +45,8 @@ assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -55,10 +56,19 @@ assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
assert.match(
verifyPlan.file,
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
);
const downPlan = await loadMigrationPlan('down');
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
assert.ok(
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
< downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
);
assert.ok(
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
@@ -76,6 +86,51 @@ const dryRun = await executeMigrationPlan(fakePool, plan, true);
assert.equal(dryRun.executed, false);
assert.equal(calls.length, 0);
const lockCalls = [];
let connectionReleased = false;
const lockedResult = await executeMigrationPlan({
async query() {
throw new Error('pool.query must not run while a dedicated migration connection exists');
},
async getConnection() {
return {
async query(sql) {
lockCalls.push(sql);
if (sql.includes('GET_LOCK')) return [[{ acquired: 1 }], []];
if (sql.includes('RELEASE_LOCK')) return [[{ released: 1 }], []];
return [{ affectedRows: 2 }, []];
},
release() {
connectionReleased = true;
}
};
}
}, {
direction: 'down', file: 'locked.sql', checksum: 'locked', statements: ['SELECT 1']
});
assert.equal(lockedResult.affectedRows, 2);
assert.equal(connectionReleased, true);
assert.match(lockCalls[0], /GET_LOCK/);
assert.equal(lockCalls[1], 'SELECT 1');
assert.match(lockCalls[2], /RELEASE_LOCK/);
let timedOutConnectionReleased = false;
await assert.rejects(
() => executeMigrationPlan({
async query() {},
async getConnection() {
return {
async query() { return [[{ acquired: 0 }], []]; },
release() { timedOutConnectionReleased = true; }
};
}
}, {
direction: 'down', file: 'lock-timeout.sql', checksum: 'lock-timeout', statements: []
}),
/MIGRATION_LOCK_TIMEOUT/
);
assert.equal(timedOutConnectionReleased, true);
const liveResult = await executeMigrationPlan(fakePool, {
direction: 'up',
file: 'test.sql',
@@ -86,6 +141,144 @@ assert.equal(liveResult.executed, true);
assert.equal(liveResult.affectedRows, 2);
assert.deepEqual(calls, ['SELECT 1', 'SELECT 2']);
const triggerPlan = {
direction: 'up',
file: 'trigger.sql',
checksum: 'trigger',
statements: ['CREATE TRIGGER test_trigger BEFORE UPDATE ON test FOR EACH ROW SET @x = 1']
};
await assert.rejects(
() => executeMigrationPlan({
async query() { return [[{ logBin: 1, trustFunctionCreators: 0 }], []]; }
}, triggerPlan),
/MIGRATION_TRIGGER_PRIVILEGE_REQUIRED/
);
const triggerCalls = [];
await executeMigrationPlan({
async query(sql) {
triggerCalls.push(sql);
if (sql.includes('@@GLOBAL.log_bin')) {
return [[{ logBin: 1, trustFunctionCreators: 1 }], []];
}
return [{ affectedRows: 0 }, []];
}
}, triggerPlan);
assert.equal(triggerCalls.length, 2);
let indexAttempted = false;
const retryResult = await executeMigrationPlan({
async query(sql) {
if (sql.includes('ADD UNIQUE KEY uq_qipai_stores_tenant_id')) {
indexAttempted = true;
throw Object.assign(new Error('duplicate index'), { code: 'ER_DUP_KEYNAME' });
}
if (sql.includes('information_schema.statistics')) {
return [[{ nonUnique: 0, columns: 'tenant_id,id' }], []];
}
return [{ affectedRows: 1 }, []];
}
}, {
direction: 'up', file: 'retry.sql', checksum: 'retry',
statements: [
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)',
'SELECT 1'
]
});
assert.equal(indexAttempted, true);
assert.equal(retryResult.affectedRows, 1);
await assert.rejects(
() => executeMigrationPlan({
async query(sql) {
if (sql.includes('ADD UNIQUE KEY')) {
throw Object.assign(new Error('wrong duplicate index'), { code: 'ER_DUP_KEYNAME' });
}
return [[{ nonUnique: 0, columns: 'id,tenant_id' }], []];
}
}, {
direction: 'up', file: 'retry-mismatch.sql', checksum: 'retry-mismatch',
statements: [
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)'
]
}),
/wrong duplicate index/
);
const downRetryCalls = [];
const downRetryResult = await executeMigrationPlan({
async query(sql) {
downRetryCalls.push(sql);
if (/DROP INDEX uq_qipai_(?:stores|users)_tenant_id/.test(sql)
|| /DROP COLUMN checksum/.test(sql)) {
throw Object.assign(new Error('already removed'), { code: 'ER_CANT_DROP_FIELD_OR_KEY' });
}
if (sql.includes('information_schema.statistics')
|| sql.includes('information_schema.columns')) return [[], []];
return [{ affectedRows: 1 }, []];
}
}, {
direction: 'down', file: 'retry-down.sql', checksum: 'retry-down',
statements: [
'ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id',
'ALTER TABLE qipai_users DROP INDEX uq_qipai_users_tenant_id',
'ALTER TABLE qipai_schema_migrations DROP COLUMN checksum',
'SELECT 1'
]
});
assert.equal(downRetryResult.affectedRows, 1);
assert.equal(downRetryCalls.filter((sql) => sql.includes('information_schema.')).length, 3);
await assert.rejects(
() => executeMigrationPlan({
async query(sql) {
if (sql.startsWith('ALTER TABLE')) {
throw Object.assign(new Error('drop target still exists'), {
code: 'ER_CANT_DROP_FIELD_OR_KEY'
});
}
return [[{ present: 1 }], []];
}
}, {
direction: 'down', file: 'retry-down-mismatch.sql', checksum: 'retry-down-mismatch',
statements: ['ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id']
}),
/drop target still exists/
);
const versionedCalls = [];
const versionedPlan = {
direction: 'up', file: 'old.sql,new.sql', checksum: 'versioned',
statements: ['SELECT old', 'SELECT new'],
migrations: [
{ version: '1', name: 'old', file: 'old.sql', checksum: 'old-checksum', statements: ['SELECT old'] },
{ version: '2', name: 'new', file: 'new.sql', checksum: 'new-checksum', statements: ['SELECT new'] }
]
};
await executeMigrationPlan({
async query(sql, params = []) {
versionedCalls.push({ sql, params });
if (sql.startsWith('SELECT * FROM qipai_schema_migrations')) {
return [[{ version: '1', name: 'old', checksum: 'old-checksum' }], []];
}
if (sql.startsWith('SELECT version, name')) {
return [[{ version: '2', name: 'new' }], []];
}
if (sql.startsWith('UPDATE qipai_schema_migrations')) {
return [{ affectedRows: 1 }, []];
}
return [{ affectedRows: 1 }, []];
}
}, versionedPlan);
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT old'), false);
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT new'), true);
await assert.rejects(
() => executeMigrationPlan({
async query() {
return [[{ version: '1', name: 'old', checksum: 'changed' }], []];
}
}, versionedPlan),
/MIGRATION_CHECKSUM_MISMATCH/
);
await assert.rejects(
() => executeMigrationPlan({
async query() {
@@ -48,6 +48,12 @@ import { hashPassword } from '../dist/auth/password.js';
import {
CleaningTaskError, CleaningTaskRepository
} from '../dist/cleaning/cleaning-task-repository.js';
import {
ProductCatalogError, ProductCatalogRepository
} from '../dist/products/product-catalog-repository.js';
import {
InventoryError, InventoryService
} from '../dist/inventory/inventory-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -97,6 +103,15 @@ const expectedTables = [
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_product_categories',
'qipai_product_inventory',
'qipai_product_inventory_ledger',
'qipai_product_inventory_requests',
'qipai_product_skus',
'qipai_product_store_hours',
'qipai_product_store_listings',
'qipai_product_store_settings',
'qipai_products',
'qipai_profit_share_policies',
'qipai_profit_share_receivers',
'qipai_profit_shares',
@@ -140,18 +155,95 @@ async function readCoreTables(pool) {
return rows.map((row) => row.tableName);
}
async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
const migrationBase = resolve(
repoRoot,
'database/migrations/2026081107_m09d1_product_inventory_foundation'
);
const [upSql, downSql] = await Promise.all([
readFile(`${migrationBase}.up.sql`, 'utf8'),
readFile(`${migrationBase}.down.sql`, 'utf8')
]);
const upStatements = splitSqlStatements(upSql);
await executeMigrationPlan(pool, {
direction: 'down',
file: `${migrationBase}.down.sql`,
checksum: 'm09d1-crash-retry-down',
statements: splitSqlStatements(downSql)
});
await pool.query(upStatements[0]);
await executeMigrationPlan(pool, fullUpPlan);
await executeMigrationPlan(pool, fullUpPlan);
const interruptedDownStatements = splitSqlStatements(downSql).slice(0, -1);
await executeMigrationPlan(pool, {
direction: 'down',
file: `${migrationBase}.interrupted.down.sql`,
checksum: 'm09d1-interrupted-down',
statements: interruptedDownStatements
});
await executeMigrationPlan(pool, {
direction: 'down',
file: `${migrationBase}.down.sql`,
checksum: 'm09d1-crash-retry-down',
statements: splitSqlStatements(downSql)
});
await executeMigrationPlan(pool, fullUpPlan);
console.log(
'PASS: M09-C markers skipped old migrations and D1 recovered after interrupted up/down DDL.'
);
}
async function assertMigrationAdvisoryLock(pool) {
const lockExpression = "CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
const blocker = await pool.getConnection();
let held = false;
let pending;
try {
const [rows] = await blocker.query(
`SELECT GET_LOCK(${lockExpression}, 0) AS acquired`
);
assert.equal(Number(rows[0]?.acquired ?? 0), 1);
held = true;
pending = executeMigrationPlan(pool, {
direction: 'down',
file: 'concurrent-migration-lock-probe.sql',
checksum: 'concurrent-migration-lock-probe',
statements: ['SELECT 1']
});
const state = await Promise.race([
pending.then(() => 'completed'),
new Promise((resolve) => setTimeout(() => resolve('blocked'), 100))
]);
assert.equal(state, 'blocked', 'a second migration connection must wait for the advisory lock');
const [releaseRows] = await blocker.query(
`SELECT RELEASE_LOCK(${lockExpression}) AS released`
);
assert.equal(Number(releaseRows[0]?.released ?? 0), 1);
held = false;
await pending;
console.log('PASS: concurrent migration processes are serialized by a database advisory lock.');
} finally {
if (held) await blocker.query(`SELECT RELEASE_LOCK(${lockExpression}) AS released`);
blocker.release();
if (pending) await pending.catch(() => undefined);
}
}
async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
'2026081006']
'2026081006', '2026081107']
);
return rows;
}
@@ -1839,7 +1931,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081006');
assert.equal(overview.latestMigration.version, '2026081107');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -3120,6 +3212,791 @@ async function assertCleaningTaskTransactions(pool, context) {
);
}
async function assertProductInventoryFoundation(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
AND u.deleted_at IS NULL
ORDER BY u.id LIMIT 1`,
[context.tenantId]
);
const [storeRows] = await pool.query(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND name = 'M03A Store' AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId]
);
assert.ok(adminRows[0], 'M09-D1 requires the tenant administrator fixture.');
assert.ok(storeRows[0], 'M09-D1 requires the M03A store fixture.');
const adminId = String(adminRows[0].id);
const storeId = String(storeRows[0].id);
const rbac = new RbacRepository(pool);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
for (const capability of [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
]) assert.ok(access.capabilities.includes(capability), `missing M09-D1 capability ${capability}`);
const actor = {
tenantId: context.tenantId,
userId: adminId,
access,
traceId: 'm09d1-live-test',
ip: '127.0.0.1',
userAgent: 'M09-D1 live MySQL test'
};
const catalog = new ProductCatalogRepository(pool);
const inventory = new InventoryService(pool);
const categoryInput = {
parentId: null,
categoryCode: 'M09D1-DRINKS',
name: 'M09D1 Drinks',
description: 'Sanitized live category',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
const category = await catalog.createCategory(actor, storeId, categoryInput);
const [foreignUserRows] = await pool.query(
`SELECT id FROM qipai_users WHERE tenant_id <> ? ORDER BY id LIMIT 1`,
[context.tenantId]
);
assert.ok(foreignUserRows[0], 'M09-D1 requires a foreign-tenant user fixture.');
await assert.rejects(
() => pool.query(
`INSERT INTO qipai_product_categories
(tenant_id, store_id, category_code, name, created_by, updated_by)
VALUES (?, ?, 'M09D1-FOREIGN-ACTOR', 'invalid actor', ?, ?)`,
[context.tenantId, storeId, foreignUserRows[0].id, foreignUserRows[0].id]
),
(error) => error?.code === 'ER_NO_REFERENCED_ROW_2'
);
await assert.rejects(
() => catalog.createCategory(actor, storeId, categoryInput),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_CODE_CONFLICT'
);
const [otherStoreRows] = await pool.query(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND id <> ? AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId, storeId]
);
assert.ok(otherStoreRows[0]);
const otherStoreId = String(otherStoreRows[0].id);
await catalog.createCategory(actor, otherStoreId, categoryInput);
const storeScopedActor = {
...actor,
access: {
roles: ['STORE_ADMIN'],
capabilities: [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
],
storeIds: [storeId]
}
};
await assert.rejects(
() => catalog.listCategories(storeScopedActor, otherStoreId),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
);
const reusable = await catalog.createCategory(actor, storeId, {
...categoryInput,
categoryCode: 'M09D1-REUSABLE',
name: 'M09D1 Reusable'
});
await catalog.archiveCategory(actor, storeId, reusable.categoryId, reusable.version);
const reused = await catalog.createCategory(actor, storeId, {
...categoryInput,
categoryCode: 'M09D1-REUSABLE',
name: 'M09D1 Reused'
});
assert.notEqual(reused.categoryId, reusable.categoryId);
const productInput = {
productCode: 'M09D1-TEA',
name: 'M09D1 Tea',
unitName: 'bottle',
description: 'Sanitized live product',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: true,
status: 'ACTIVE',
sortOrder: 1
};
const product = await catalog.createProduct(actor, productInput);
await assert.rejects(
() => catalog.createProduct(actor, productInput),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_CODE_CONFLICT'
);
await assert.rejects(
() => catalog.updateProduct(actor, product.productId, 99, {
...productInput,
name: 'M09D1 stale update'
}),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_VERSION_CONFLICT'
);
const firstSkuInput = {
skuCode: 'M09D1-TEA-500',
name: '500ml',
attributes: { volume: '500ml' },
barcode: 'M09D1000001',
imageUrl: '',
salePriceCents: 600,
marketPriceCents: 800,
costPriceCents: 250,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
};
const secondSkuInput = {
...firstSkuInput,
skuCode: 'M09D1-TEA-330',
name: '330ml',
attributes: { volume: '330ml' },
barcode: 'M09D1000002',
salePriceCents: 500
};
const unlimitedSkuInput = {
...firstSkuInput,
skuCode: 'M09D1-TEA-GIFT',
name: 'gift entitlement',
attributes: { kind: 'gift' },
barcode: 'M09D1000003',
defaultInventoryPolicy: 'UNLIMITED',
salePriceCents: 100
};
const firstSku = await catalog.createSku(actor, product.productId, firstSkuInput);
const secondSku = await catalog.createSku(actor, product.productId, secondSkuInput);
const unlimitedSku = await catalog.createSku(actor, product.productId, unlimitedSkuInput);
await assert.rejects(
() => catalog.updateSku(actor, product.productId, firstSku.skuId, 99, firstSkuInput),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
);
await assert.rejects(
() => pool.query(
`INSERT INTO qipai_product_skus
(tenant_id, product_id, sku_code, name, sale_price_cents,
default_inventory_policy, created_by, updated_by)
VALUES (?, ?, 'M09D1-AMOUNT-OVERFLOW', 'invalid amount', 100000001,
'TRACKED', ?, ?)`,
[context.tenantId, product.productId, adminId, adminId]
),
(error) => error?.code === 'ER_CHECK_CONSTRAINT_VIOLATED'
);
const disposableProductInput = {
...productInput,
productCode: 'M09D1-DISPOSABLE',
name: 'M09D1 Disposable'
};
const disposableProduct = await catalog.createProduct(actor, disposableProductInput);
const disposableSkuInput = {
...firstSkuInput,
skuCode: 'M09D1-DISPOSABLE-SKU',
name: 'disposable sku',
barcode: 'M09D1000099'
};
const disposableSku = await catalog.createSku(
actor, disposableProduct.productId, disposableSkuInput
);
await inventory.configurePolicy(actor, {
storeId,
skuId: disposableSku.skuId,
requestId: 'm09d1-disposable-policy',
reason: 'create zero inventory before archive',
expectedVersion: 0,
policyType: 'TRACKED',
lowStockThreshold: 0
});
await catalog.archiveSku(
actor, disposableProduct.productId, disposableSku.skuId, disposableSku.version
);
await assert.rejects(
() => inventory.inbound(actor, {
storeId,
skuId: disposableSku.skuId,
requestId: 'm09d1-archived-sku-inbound',
reason: 'archived sku must reject inventory writes',
quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
);
await catalog.archiveProduct(
actor, disposableProduct.productId, disposableProduct.version
);
const listing = await catalog.putListing(actor, storeId, product.productId, 0, {
categoryId: category.categoryId,
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
salesStartAt: null,
salesEndAt: null,
sortOrder: 1
});
await assert.rejects(
() => catalog.putListing(actor, storeId, product.productId, 99, {
categoryId: category.categoryId,
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
salesStartAt: null,
salesEndAt: null,
sortOrder: 2
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_LISTING_VERSION_CONFLICT'
);
assert.equal(listing.version, 1);
const overnightHours = [{
weekday: 1,
slotNo: 1,
openMinute: 22 * 60,
closeMinute: 2 * 60,
crossesMidnight: true
}];
const settings = await catalog.putStoreSettings(actor, storeId, 0, {
salesStatus: 'OPEN',
manualPaused: false,
manualPausedUntil: null,
manualPauseReason: '',
hours: overnightHours
}, new Date('2026-08-10T14:00:00.000Z'));
assert.equal(
(await catalog.getStoreSettings(
actor, storeId, new Date('2026-08-10T15:00:00.000Z')
)).openNow,
true,
'cross-midnight product hours must open on the configured weekday'
);
assert.equal(
(await catalog.getStoreSettings(
actor, storeId, new Date('2026-08-10T17:00:00.000Z')
)).openNow,
true,
'cross-midnight product hours must include the previous-day window'
);
await assert.rejects(
() => catalog.putStoreSettings(actor, storeId, settings.version, {
salesStatus: 'OPEN',
manualPaused: false,
manualPausedUntil: null,
manualPauseReason: '',
hours: [
...overnightHours,
{ weekday: 2, slotNo: 1, openMinute: 60, closeMinute: 180, crossesMidnight: false }
]
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
);
const paused = await catalog.putStoreSettings(actor, storeId, settings.version, {
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: null,
manualPauseReason: 'M09-D1 manual pause',
hours: overnightHours
}, new Date('2026-08-10T14:30:00.000Z'));
assert.equal(
(await catalog.getStoreSettings(
actor, storeId, new Date('2026-08-10T15:00:00.000Z')
)).openNow,
false
);
await catalog.putStoreSettings(actor, storeId, paused.version, {
salesStatus: 'OPEN',
manualPaused: false,
manualPausedUntil: null,
manualPauseReason: '',
hours: overnightHours
}, new Date('2026-08-10T14:40:00.000Z'));
const configuredFirst = await inventory.configurePolicy(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-policy-first',
reason: 'configure tracked stock',
expectedVersion: 0,
policyType: 'TRACKED',
lowStockThreshold: 1
});
const firstInbound = await inventory.inbound(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-inbound-first',
reason: 'initial inbound stock',
expectedVersion: configuredFirst.version,
quantity: 5
});
const configuredSecond = await inventory.configurePolicy(actor, {
storeId,
skuId: secondSku.skuId,
requestId: 'm09d1-policy-second',
reason: 'configure second tracked stock',
expectedVersion: 0,
policyType: 'TRACKED',
lowStockThreshold: 1
});
await inventory.inbound(actor, {
storeId,
skuId: secondSku.skuId,
requestId: 'm09d1-inbound-second',
reason: 'second inbound stock',
expectedVersion: configuredSecond.version,
quantity: 6
});
const configuredUnlimited = await inventory.configurePolicy(actor, {
storeId,
skuId: unlimitedSku.skuId,
requestId: 'm09d1-policy-unlimited',
reason: 'configure unlimited stock',
expectedVersion: 0,
policyType: 'UNLIMITED',
lowStockThreshold: 0
});
const unlimitedLockInput = {
tenantId: context.tenantId,
storeId,
items: [{ skuId: unlimitedSku.skuId, quantity: 2 }],
requestId: 'm09d1-unlimited-lock',
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-unlimited-order-item',
traceId: 'm09d1-unlimited-lock',
operatorId: adminId,
reason: 'lock unlimited inventory entitlement'
};
const unlimitedLock = await inventory.lockMany(unlimitedLockInput);
await assert.rejects(
() => inventory.configurePolicy(actor, {
storeId,
skuId: unlimitedSku.skuId,
requestId: 'm09d1-unlimited-switch-active',
reason: 'active reservation must block policy switch',
expectedVersion: unlimitedLock.items[0].version,
policyType: 'TRACKED',
lowStockThreshold: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
);
const unlimitedDeduct = await inventory.deductMany({
...unlimitedLockInput,
requestId: 'm09d1-unlimited-deduct',
traceId: 'm09d1-unlimited-deduct',
reason: 'settle unlimited inventory entitlement'
});
const switchedUnlimited = await inventory.configurePolicy(actor, {
storeId,
skuId: unlimitedSku.skuId,
requestId: 'm09d1-unlimited-switch-settled',
reason: 'switch after reservation settlement',
expectedVersion: unlimitedDeduct.items[0].version,
policyType: 'TRACKED',
lowStockThreshold: 0
});
assert.equal(configuredUnlimited.policyType, 'UNLIMITED');
assert.equal(switchedUnlimited.policyType, 'TRACKED');
const lockInputs = ['a', 'b'].map((suffix) => ({
tenantId: context.tenantId,
storeId,
items: [{ skuId: firstSku.skuId, quantity: 4 }],
requestId: `m09d1-concurrent-lock-${suffix}`,
businessType: 'GOODS_ORDER_ITEM',
businessId: `m09d1-order-item-${suffix}`,
traceId: `m09d1-concurrent-lock-${suffix}`,
operatorId: adminId,
ip: '127.0.0.1',
userAgent: 'M09-D1 concurrent inventory lock',
reason: 'concurrent inventory lock'
}));
const concurrentLocks = await Promise.allSettled(lockInputs.map((input) =>
inventory.lockMany(input)
));
const winningIndexes = concurrentLocks
.map((result, index) => result.status === 'fulfilled' ? index : -1)
.filter((index) => index >= 0);
const rejected = concurrentLocks.filter((result) => result.status === 'rejected');
assert.equal(winningIndexes.length, 1, 'concurrent inventory lock must have one winner');
assert.equal(rejected.length, 1, 'concurrent inventory lock must reject one contender');
assert.ok(rejected[0].reason instanceof InventoryError);
assert.equal(rejected[0].reason.code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
const winningLock = lockInputs[winningIndexes[0]];
const replay = await inventory.lockMany(winningLock);
assert.equal(replay.idempotent, true);
await assert.rejects(
() => inventory.lockMany({
...winningLock,
items: [{ skuId: firstSku.skuId, quantity: 3 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => inventory.releaseMany({
...winningLock,
requestId: 'm09d1-cross-order-release',
businessId: 'm09d1-foreign-order-item',
items: [{ skuId: firstSku.skuId, quantity: 1 }],
reason: 'must not release another order reservation'
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await inventory.releaseMany({
tenantId: context.tenantId,
storeId,
items: [{ skuId: firstSku.skuId, quantity: 2 }],
requestId: 'm09d1-release-two',
businessType: 'GOODS_ORDER_ITEM',
businessId: winningLock.businessId,
traceId: 'm09d1-release-two',
operatorId: adminId,
reason: 'release two locked units'
});
await inventory.deductMany({
tenantId: context.tenantId,
storeId,
items: [{ skuId: firstSku.skuId, quantity: 2 }],
requestId: 'm09d1-deduct-two',
businessType: 'GOODS_ORDER_ITEM',
businessId: winningLock.businessId,
traceId: 'm09d1-deduct-two',
operatorId: adminId,
reason: 'deduct two locked units'
});
const caseSensitiveRequestBase = {
tenantId: context.tenantId,
storeId,
items: [{ skuId: secondSku.skuId, quantity: 1 }],
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-case-sensitive-request-order',
traceId: 'm09d1-case-sensitive-request',
operatorId: adminId,
reason: 'opaque request ids are case sensitive'
};
const upperCaseRequest = await inventory.lockMany({
...caseSensitiveRequestBase,
requestId: 'M09D1-Request-Case'
});
const lowerCaseRequest = await inventory.lockMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-request-case'
});
assert.equal(upperCaseRequest.idempotent, false);
assert.equal(lowerCaseRequest.idempotent, false);
assert.notEqual(upperCaseRequest.items[0].ledgerId, lowerCaseRequest.items[0].ledgerId);
await inventory.releaseMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-request-case-release',
items: [{ skuId: secondSku.skuId, quantity: 2 }],
reason: 'release both case-sensitive requests'
});
const businessCaseLock = await inventory.lockMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-business-case-lock',
businessId: 'Order-A',
reason: 'lock with case-sensitive business id'
});
await assert.rejects(
() => inventory.releaseMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-business-case-wrong-release',
businessId: 'order-a',
reason: 'business id case mismatch must reject'
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await inventory.releaseMany({
...caseSensitiveRequestBase,
requestId: 'm09d1-business-case-release',
businessId: 'Order-A',
reason: 'release exact business id reservation'
});
assert.equal(businessCaseLock.idempotent, false);
const globalRequestInputs = [firstSku.skuId, secondSku.skuId].map((skuId) => ({
tenantId: context.tenantId,
storeId,
items: [{ skuId, quantity: 1 }],
requestId: 'm09d1-global-request-race',
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-global-request-order',
traceId: `m09d1-global-request-${skuId}`,
operatorId: adminId,
reason: 'same request must not cover disjoint sku sets'
}));
const globalRequestRace = await Promise.allSettled(
globalRequestInputs.map((input) => inventory.lockMany(input))
);
const globalWinnerIndex = globalRequestRace.findIndex((result) => result.status === 'fulfilled');
const globalRejected = globalRequestRace.filter((result) => result.status === 'rejected');
assert.notEqual(globalWinnerIndex, -1, 'one global inventory request must win');
assert.equal(globalRejected.length, 1, 'disjoint reuse of one request id must reject once');
assert.ok(globalRejected[0].reason instanceof InventoryError);
assert.equal(globalRejected[0].reason.code, 'INVENTORY_IDEMPOTENCY_CONFLICT');
const globalWinner = globalRequestInputs[globalWinnerIndex];
await inventory.releaseMany({
...globalWinner,
requestId: 'm09d1-global-request-release',
traceId: 'm09d1-global-request-release',
reason: 'release the winning global request reservation'
});
const [secondVersionRows] = await pool.query(
`SELECT version FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
[context.tenantId, storeId, secondSku.skuId]
);
const secondExpectedVersion = Number(secondVersionRows[0].version);
const policyRace = await Promise.allSettled([2, 3].map((lowStockThreshold, index) =>
inventory.configurePolicy(actor, {
storeId,
skuId: secondSku.skuId,
requestId: `m09d1-policy-race-${index}`,
reason: 'concurrent policy threshold update',
expectedVersion: secondExpectedVersion,
policyType: 'TRACKED',
lowStockThreshold
})
));
assert.equal(
policyRace.filter((result) => result.status === 'fulfilled').length,
1,
'one inventory policy CAS must win'
);
const rejectedPolicy = policyRace.find((result) => result.status === 'rejected');
assert.ok(rejectedPolicy?.reason instanceof InventoryError);
assert.equal(rejectedPolicy.reason.code, 'INVENTORY_VERSION_CONFLICT');
const [beforeAtomicRows] = await pool.query(
`SELECT sku_id AS skuId, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id IN (?, ?)
ORDER BY sku_id`,
[context.tenantId, storeId, firstSku.skuId, secondSku.skuId]
);
await assert.rejects(
() => inventory.lockMany({
tenantId: context.tenantId,
storeId,
items: [
{ skuId: firstSku.skuId, quantity: 2 },
{ skuId: secondSku.skuId, quantity: 7 }
],
requestId: 'm09d1-atomic-reject',
businessType: 'GOODS_ORDER_ITEM',
businessId: 'm09d1-atomic-reject',
traceId: 'm09d1-atomic-reject',
operatorId: adminId,
reason: 'reject the complete batch'
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
);
const [afterAtomicRows] = await pool.query(
`SELECT sku_id AS skuId, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id IN (?, ?)
ORDER BY sku_id`,
[context.tenantId, storeId, firstSku.skuId, secondSku.skuId]
);
assert.deepEqual(afterAtomicRows, beforeAtomicRows, 'failed batch must not partially mutate stock');
await pool.query(
`UPDATE qipai_product_skus SET status = 'INACTIVE', version = version + 1
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstSku.skuId]
);
const inactiveSkuReplay = await inventory.lockMany(winningLock);
assert.equal(
inactiveSkuReplay.idempotent,
true,
'an exact inventory request replay must survive a later SKU deactivation'
);
await pool.query(
`UPDATE qipai_product_skus SET status = 'ACTIVE', version = version + 1
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstSku.skuId]
);
const [firstStockRows] = await pool.query(
`SELECT id, available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
FROM qipai_product_inventory
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
[context.tenantId, storeId, firstSku.skuId]
);
const firstStock = firstStockRows[0];
assert.deepEqual(
{
availableQuantity: Number(firstStock.availableQuantity),
lockedQuantity: Number(firstStock.lockedQuantity),
lossQuantity: Number(firstStock.lossQuantity)
},
{ availableQuantity: 3, lockedQuantity: 0, lossQuantity: 0 }
);
const loss = await inventory.recordLoss(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-loss-one',
reason: 'record one damaged unit',
expectedVersion: Number(firstStock.version),
quantity: 1
});
const stocktakeRace = await Promise.allSettled([2, 1].map((availableQuantity, index) =>
inventory.stocktake(actor, {
storeId,
skuId: firstSku.skuId,
requestId: `m09d1-stocktake-${index}`,
reason: 'concurrent absolute stocktake',
expectedVersion: loss.version,
availableQuantity,
lossQuantity: 1
})
));
const stocktakeWinners = stocktakeRace.filter((result) => result.status === 'fulfilled');
const stocktakeRejected = stocktakeRace.filter((result) => result.status === 'rejected');
assert.equal(stocktakeWinners.length, 1, 'one absolute stocktake CAS must win');
assert.equal(stocktakeRejected.length, 1, 'one stale absolute stocktake CAS must reject');
assert.ok(stocktakeRejected[0].reason instanceof InventoryError);
assert.equal(stocktakeRejected[0].reason.code, 'INVENTORY_VERSION_CONFLICT');
const stocktake = stocktakeWinners[0].value;
await assert.rejects(
() => inventory.adjust(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-negative-adjust',
reason: 'negative inventory must fail',
expectedVersion: stocktake.version,
availableDelta: -3,
lossDelta: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_QUANTITY_OUT_OF_RANGE'
);
await assert.rejects(
() => inventory.inbound(actor, {
storeId,
skuId: firstSku.skuId,
requestId: 'm09d1-stale-inbound',
reason: 'stale version must fail',
expectedVersion: firstInbound.version,
quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_VERSION_CONFLICT'
);
await assert.rejects(
() => inventory.listStocks({
...storeScopedActor,
access: { ...storeScopedActor.access, storeIds: [otherStoreId] }
}, { storeId, page: 1, pageSize: 20 }),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
);
await assert.rejects(
() => pool.query(
`UPDATE qipai_product_inventory SET available_quantity = 1000000001
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstStock.id]
),
(error) => error?.code === 'ER_CHECK_CONSTRAINT_VIOLATED'
);
const [ledgerRows] = await pool.query(
`SELECT COALESCE(SUM(available_delta), 0) AS availableDelta,
COALESCE(SUM(locked_delta), 0) AS lockedDelta,
COALESCE(SUM(loss_delta), 0) AS lossDelta,
COUNT(*) AS ledgerCount,
COUNT(DISTINCT version_after) AS versionCount
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ?`,
[context.tenantId, firstStock.id]
);
const [finalStockRows] = await pool.query(
`SELECT available_quantity AS availableQuantity,
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity
FROM qipai_product_inventory
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, firstStock.id]
);
assert.deepEqual(
{
availableQuantity: Number(ledgerRows[0].availableDelta),
lockedQuantity: Number(ledgerRows[0].lockedDelta),
lossQuantity: Number(ledgerRows[0].lossDelta)
},
{
availableQuantity: Number(finalStockRows[0].availableQuantity),
lockedQuantity: Number(finalStockRows[0].lockedQuantity),
lossQuantity: Number(finalStockRows[0].lossQuantity)
}
);
assert.equal(Number(ledgerRows[0].ledgerCount), Number(ledgerRows[0].versionCount));
const [winningLedgerRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ? AND request_id = ?`,
[context.tenantId, firstStock.id, winningLock.requestId]
);
assert.equal(Number(winningLedgerRows[0].total), 1);
await assert.rejects(
() => pool.query(
`UPDATE qipai_product_inventory_ledger SET reason = 'forbidden'
WHERE tenant_id = ? AND inventory_id = ? LIMIT 1`,
[context.tenantId, firstStock.id]
),
(error) => /PRODUCT_INVENTORY_LEDGER_IMMUTABLE/.test(error?.message ?? '')
);
await assert.rejects(
() => pool.query(
`DELETE FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND inventory_id = ? LIMIT 1`,
[context.tenantId, firstStock.id]
),
(error) => /PRODUCT_INVENTORY_LEDGER_IMMUTABLE/.test(error?.message ?? '')
);
const catalogView = await catalog.listStoreCatalog({
tenantId: context.tenantId,
storeId,
now: new Date('2026-08-10T15:00:00.000Z')
});
assert.equal(catalogView.salesOpen, true);
const publicSku = catalogView.categories[0]?.products[0]?.skus[0];
assert.equal(Object.hasOwn(publicSku ?? {}, 'costPriceCents'), false);
assert.equal(Object.hasOwn(publicSku ?? {}, 'availableQuantity'), false);
const [auditRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_audit_logs
WHERE tenant_id = ? AND (
action LIKE 'PRODUCT_%' OR action LIKE 'PRODUCT_INVENTORY_%'
)`,
[context.tenantId]
);
assert.ok(Number(auditRows[0].total) >= 10);
console.log(
'PASS: M09-D1 product catalog, cross-midnight hours, concurrent inventory lock, '
+ 'idempotency, reconciliation and immutable ledger are consistent.'
);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -3142,6 +4019,9 @@ try {
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
await assertMigrationAdvisoryLock(pool);
await assertProductInventoryMigrationRetry(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
assert.deepEqual(await readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' },
@@ -3168,7 +4048,8 @@ try {
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -3191,13 +4072,14 @@ try {
await assertDeviceTopology(pool, loginContext);
await assertIotMessages(pool, loginContext);
await assertCleaningTaskTransactions(pool, loginContext);
await assertProductInventoryFoundation(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M09-C migration tables.');
console.log('PASS: down removed all M01-B through M09-D1 migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -3227,7 +4109,8 @@ try {
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3375,7 +4258,15 @@ try {
'settlement reversal preserves history and releases the share',
'processing payout blocks cancellation until provider-confirmed failure',
'collaborative task settles only after every member share is paid',
'settlement page and CSV export reuse identical query ordering'
'settlement page and CSV export reuse identical query ordering',
'store-scoped product category uniqueness and soft-delete code reuse',
'product and SKU optimistic version conflicts',
'cross-midnight product hours and manual pause precedence',
'concurrent inventory lock has one winner without negative stock',
'inventory request replay and fingerprint conflict',
'atomic multi-SKU inventory mutation rollback',
'inventory lock release deduct loss and stocktake reconciliation',
'immutable inventory ledger update and delete rejection'
]
}, null, 2));
} finally {
+681
View File
@@ -0,0 +1,681 @@
import assert from 'node:assert/strict';
import {
ProductCatalogError,
ProductCatalogRepository,
isProductStoreOpenAt,
validateBusinessHours
} from '../dist/products/product-catalog-repository.js';
const tenantActor = {
tenantId: '7',
userId: '21',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId: 'product-catalog-test',
ip: '127.0.0.1',
userAgent: 'catalog-test'
};
const storeActor = {
...tenantActor,
userId: '22',
access: {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read', 'product.catalog.write'],
storeIds: ['11']
}
};
const mondayOvernight = [{
weekday: 1,
slotNo: 1,
openMinute: 22 * 60,
closeMinute: 2 * 60,
crossesMidnight: true
}];
validateBusinessHours(mondayOvernight);
const openSettings = {
timezone: 'Asia/Shanghai',
businessStatus: 'OPEN',
salesStatus: 'OPEN',
manualPausedAt: null,
manualPausedUntil: null,
hours: mondayOvernight
};
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T15:00:00.000Z')),
true,
'Monday 23:00 should be inside the Monday overnight window'
);
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T17:00:00.000Z')),
true,
'Tuesday 01:00 must be attributed to the previous-day overnight window'
);
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T19:00:00.000Z')),
false
);
assert.equal(isProductStoreOpenAt({
...openSettings,
manualPausedAt: new Date('2026-08-10T14:00:00.000Z'),
manualPausedUntil: null
}, new Date('2026-08-10T15:00:00.000Z')), false, 'manual pause overrides hours');
assert.throws(
() => validateBusinessHours([
{ weekday: 1, slotNo: 1, openMinute: 540, closeMinute: 720, crossesMidnight: false },
{ weekday: 1, slotNo: 2, openMinute: 600, closeMinute: 780, crossesMidnight: false }
]),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
);
class ScriptedConnection {
constructor(steps) {
this.steps = [...steps];
this.committed = false;
this.rolledBack = false;
this.released = false;
this.calls = [];
}
async beginTransaction() {}
async commit() { this.committed = true; }
async rollback() { this.rolledBack = true; }
release() { this.released = true; }
async execute(sql, params = []) {
this.calls.push({ sql, params });
const step = this.steps.shift();
assert.ok(step, `Unexpected SQL: ${sql}`);
assert.match(sql, step.match);
if (step.check) step.check(params, sql);
if (step.error) throw step.error;
return step.result;
}
}
function repositoryFor(connection) {
return new ProductCatalogRepository({
async getConnection() { return connection; },
async execute(sql, params) { return connection.execute(sql, params); }
});
}
function repositoryForConnections(connections) {
const pending = [...connections];
return new ProductCatalogRepository({
async getConnection() {
const connection = pending.shift();
assert.ok(connection, 'Unexpected transaction');
return connection;
},
async execute() { throw new Error('This scenario must use transactional connections'); }
});
}
function categoryInput(parentId) {
return {
parentId,
categoryCode: 'DRINKS',
name: 'Cold drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
}
const createConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /INSERT INTO qipai_product_categories/,
result: [{ insertId: 31, affectedRows: 1 }, []],
check(params) {
assert.equal(params[0], '7');
assert.equal(params[1], '11');
assert.equal(params[3], 'DRINKS');
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_CATEGORY_CREATED');
assert.equal(params[3], 'PRODUCT_CATEGORY');
assert.equal(params[4], '31');
assert.deepEqual(JSON.parse(params[8]), { storeId: '11', version: 1 });
}
}
]);
const created = await repositoryFor(createConnection).createCategory(storeActor, '11', {
parentId: null,
categoryCode: 'DRINKS',
name: 'Drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
});
assert.deepEqual(created, { categoryId: '31', version: 1 });
assert.equal(createConnection.committed, true);
assert.equal(createConnection.rolledBack, false);
assert.equal(createConnection.released, true);
const casConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{ match: /UPDATE qipai_product_categories/, result: [{ affectedRows: 0 }, []] },
{ match: /SELECT version FROM qipai_product_categories/, result: [[{ version: 3 }], []] }
]);
await assert.rejects(
() => repositoryFor(casConnection).updateCategory(storeActor, '11', '31', 2, {
parentId: null,
categoryCode: 'DRINKS',
name: 'Cold drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_VERSION_CONFLICT'
);
assert.equal(casConnection.committed, false);
assert.equal(casConnection.rolledBack, true);
assert.equal(casConnection.released, true);
for (const scenario of [
{
name: 'two-node cycle',
ancestors: [{ id: '32', parentId: '31' }]
},
{
name: 'multi-node cycle',
ancestors: [{ id: '32', parentId: '33' }, { id: '33', parentId: '31' }]
}
]) {
const cycleConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
...scenario.ancestors.map((ancestor, index) => ({
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
result: [[ancestor], []],
check(params, sql) {
assert.deepEqual(params, ['7', '11', index === 0 ? '32' : '33']);
assert.match(sql, /deleted_at IS NULL/);
}
}))
]);
await assert.rejects(
() => repositoryFor(cycleConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('32')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE',
scenario.name
);
assert.equal(cycleConnection.rolledBack, true, scenario.name);
assert.equal(cycleConnection.steps.length, 0, scenario.name);
}
const selfParentConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] }
]);
await assert.rejects(
() => repositoryFor(selfParentConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('31')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE'
);
assert.equal(selfParentConnection.rolledBack, true);
for (const invalidParent of ['cross-store parent', 'deleted parent']) {
const invalidParentConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
result: [[], []],
check(params, sql) {
assert.deepEqual(params, ['7', '11', '32']);
assert.match(sql, /store_id = \?/);
assert.match(sql, /deleted_at IS NULL/);
}
}
]);
await assert.rejects(
() => repositoryFor(invalidParentConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('32')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_NOT_FOUND',
invalidParent
);
assert.equal(invalidParentConnection.rolledBack, true, invalidParent);
}
for (const [bucket, balances] of [
['available', { availableQuantity: 1, lockedQuantity: 0, lossQuantity: 0 }],
['locked', { availableQuantity: 0, lockedQuantity: 1, lossQuantity: 0 }],
['loss', { availableQuantity: 0, lockedQuantity: 0, lossQuantity: 1 }]
]) {
const stockConnection = new ScriptedConnection([
{
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
result: [[{ id: '41', version: 4 }], []]
},
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []],
check(params, sql) {
assert.deepEqual(params, ['7', '41', '51']);
assert.match(sql, /product_id = \?/);
assert.match(sql, /deleted_at IS NULL/);
}
},
{
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
result: [[{ id: '71', storeId: '11', skuId: '51', ...balances }], []],
check(params, sql) {
assert.deepEqual(params, ['7', '51']);
assert.match(sql, /ORDER BY store_id, id[\s\S]*FOR UPDATE/);
}
}
]);
await assert.rejects(
() => repositoryFor(stockConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_HAS_INVENTORY_STOCK',
`${bucket} stock must block SKU archival`
);
assert.equal(stockConnection.rolledBack, true, bucket);
assert.equal(stockConnection.steps.length, 0, bucket);
}
const skuVersionConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 3 }], []]
}
]);
await assert.rejects(
() => repositoryFor(skuVersionConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
);
assert.equal(skuVersionConnection.rolledBack, true);
const skuOwnershipConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[], []],
check(params) { assert.deepEqual(params, ['7', '41', '51']); }
}
]);
await assert.rejects(
() => repositoryFor(skuOwnershipConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_SKU_NOT_FOUND'
);
const productStockConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 2
}], []],
check(params, sql) {
assert.deepEqual(params, ['7', '41']);
assert.match(sql, /ORDER BY s.id, i.store_id, i.id[\s\S]*FOR UPDATE/);
}
}
]);
await assert.rejects(
() => repositoryFor(productStockConnection).archiveProduct(tenantActor, '41', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_HAS_INVENTORY_STOCK'
);
assert.equal(productStockConnection.rolledBack, true);
const activeSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
{
match: /deleted_at AS deletedAt[\s\S]*FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2, deletedAt: null }], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
}
]);
await assert.rejects(
() => repositoryFor(activeSkuConnection).archiveProduct(tenantActor, '41', 2),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_HAS_ACTIVE_SKUS'
);
assert.equal(activeSkuConnection.rolledBack, true);
const skuArchiveConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []]
},
{
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
},
{ match: /UPDATE qipai_product_skus/, result: [{ affectedRows: 1 }, []] },
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_SKU_ARCHIVED');
assert.equal(params[4], '51');
}
}
]);
const productArchiveAfterSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
result: [[{
id: '51', productId: '41', version: 3, deletedAt: new Date('2026-08-11T00:00:00Z')
}], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
},
{ match: /FROM qipai_product_store_listings/, result: [[{ total: 0 }], []] },
{
match: /UPDATE qipai_products/,
result: [{ affectedRows: 1 }, []],
check(params, sql) {
assert.deepEqual(params, ['21', '7', '41', 4]);
assert.match(sql, /deleted_at = UTC_TIMESTAMP/);
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[2], 'PRODUCT_ARCHIVED'); }
}
]);
const reusedProductConnection = new ScriptedConnection([
{
match: /INSERT INTO qipai_products/,
result: [{ insertId: 42, affectedRows: 1 }, []],
check(params) { assert.equal(params[1], 'TEA'); }
},
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
]);
const reusedSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '42', version: 1 }], []] },
{
match: /INSERT INTO qipai_product_skus/,
result: [{ insertId: 52, affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'TEA-500');
assert.equal(params[5], '690000000001');
}
},
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
]);
const archiveAndReuseRepository = repositoryForConnections([
skuArchiveConnection,
productArchiveAfterSkuConnection,
reusedProductConnection,
reusedSkuConnection
]);
assert.deepEqual(
await archiveAndReuseRepository.archiveSku(tenantActor, '41', '51', 2),
{ skuId: '51', productId: '41', version: 3, archived: true }
);
assert.deepEqual(
await archiveAndReuseRepository.archiveProduct(tenantActor, '41', 4),
{ productId: '41', version: 5, archived: true }
);
assert.deepEqual(
await archiveAndReuseRepository.createProduct(tenantActor, {
productCode: 'TEA',
name: 'Tea replacement',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: false,
status: 'DRAFT',
sortOrder: 1
}),
{ productId: '42', version: 1 }
);
assert.deepEqual(
await archiveAndReuseRepository.createSku(tenantActor, '42', {
skuCode: 'TEA-500',
name: 'Tea 500ml replacement',
attributes: { size: '500ml' },
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
}),
{ skuId: '52', productId: '42', version: 1 }
);
assert.equal(skuArchiveConnection.committed, true);
assert.equal(productArchiveAfterSkuConnection.committed, true);
assert.equal(reusedProductConnection.committed, true);
assert.equal(reusedSkuConnection.committed, true);
const settingsConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{ match: /FROM qipai_product_store_settings/, result: [[], []] },
{
match: /INSERT INTO qipai_product_store_settings/,
result: [{ insertId: 41, affectedRows: 1 }, []]
},
{ match: /UPDATE qipai_product_store_hours/, result: [{ affectedRows: 0 }, []] },
{
match: /INSERT INTO qipai_product_store_hours/,
result: [{ insertId: 51, affectedRows: 1 }, []],
check(params) {
assert.deepEqual(params.slice(3), [1, 1, 1320, 120, true]);
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_STORE_SETTINGS_UPDATED');
}
}
]);
const pausedUntil = new Date('2026-08-12T00:00:00.000Z');
const settingsResult = await repositoryFor(settingsConnection).putStoreSettings(
storeActor,
'11',
0,
{
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: pausedUntil,
manualPauseReason: 'inventory count',
hours: mondayOvernight
},
new Date('2026-08-11T00:00:00.000Z')
);
assert.deepEqual(settingsResult, { settingsId: '41', storeId: '11', version: 1 });
assert.equal(settingsConnection.committed, true);
const firstListingArchive = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
result: [[{ id: '61', version: 1 }], []]
},
{
match: /UPDATE qipai_product_store_listings/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '61', 1]); }
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '61'); }
}
]);
const rebuiltListing = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
result: [[{
id: '41', status: 'ACTIVE', deliveryEnabled: true, storageEnabled: true
}], []]
},
{ match: /FROM qipai_product_categories/, result: [[{ id: '31' }], []] },
{ match: /INSERT INTO qipai_product_store_listings/, result: [{ insertId: 62, affectedRows: 1 }, []] },
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '62'); }
}
]);
const secondListingArchive = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
result: [[{ id: '62', version: 1 }], []]
},
{
match: /UPDATE qipai_product_store_listings/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '62', 1]); }
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '62'); }
}
]);
const listingLifecycleRepository = repositoryForConnections([
firstListingArchive, rebuiltListing, secondListingArchive
]);
assert.deepEqual(
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
{ listingId: '61', storeId: '11', productId: '41', version: 2, archived: true }
);
assert.deepEqual(
await listingLifecycleRepository.putListing(storeActor, '11', '41', 0, {
categoryId: '31',
status: 'INACTIVE',
fulfillmentMode: 'SELF_SERVICE',
sortOrder: 1
}),
{ listingId: '62', storeId: '11', productId: '41', version: 1 }
);
assert.deepEqual(
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
{ listingId: '62', storeId: '11', productId: '41', version: 2, archived: true }
);
assert.equal(firstListingArchive.committed, true);
assert.equal(rebuiltListing.committed, true);
assert.equal(secondListingArchive.committed, true);
const skuReadRow = {
id: '51',
productId: '41',
skuCode: 'TEA-500',
name: 'Tea 500ml',
attributesJson: '{"size":"500ml"}',
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE',
version: 2,
createdAt: new Date('2026-08-11T00:00:00Z'),
updatedAt: new Date('2026-08-11T00:00:00Z')
};
function skuReadConnection() {
return new ScriptedConnection([
{ match: /SELECT id FROM qipai_products/, result: [[{ id: '41' }], []] },
{ match: /FROM qipai_product_skus/, result: [[skuReadRow], []] }
]);
}
const storeAdminSkus = await repositoryFor(skuReadConnection()).listSkus(storeActor, '41');
assert.equal(storeAdminSkus.length, 1);
assert.equal(Object.hasOwn(storeAdminSkus[0], 'costPriceCents'), false,
'store administrators must not receive product cost');
const tenantManagerSkus = await repositoryFor(skuReadConnection()).listSkus(tenantActor, '41');
assert.equal(tenantManagerSkus[0].costPriceCents, 200,
'tenant managers retain product cost visibility');
const neverUsedPool = {
async execute() { throw new Error('scope checks must happen before SQL'); },
async getConnection() { throw new Error('scope checks must happen before SQL'); }
};
const guardedRepository = new ProductCatalogRepository(neverUsedPool);
await assert.rejects(
() => guardedRepository.listProducts({
...storeActor,
access: {
roles: ['STAFF'], capabilities: ['product.catalog.read'], storeIds: ['11']
}
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.listSkus({
...storeActor,
access: {
roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: []
}
}, '41'),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.listCategories({
...storeActor,
access: { ...storeActor.access, storeIds: ['12'] }
}, '11'),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.createProduct(storeActor, {
productCode: 'TEA',
name: 'Tea',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: false,
status: 'DRAFT',
sortOrder: 1
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_TENANT_WRITE_FORBIDDEN'
);
console.log('PASS: M09-D1 product catalog covers global-read scope, cost redaction, category cycles, archive locks, listing lifecycle, hours, CAS and audit.');
+285
View File
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { signAccessToken } from '../dist/auth/jwt.js';
import { ProductCatalogError } from '../dist/products/product-catalog-repository.js';
import { registerProductRoutes } from '../dist/routes/products.js';
const secret = 'test-only-product-route-jwt-secret-32';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 1
}, secret, 900);
const calls = [];
let currentAccess = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
storeIds: []
};
const record = (method, result) => async (...args) => {
calls.push({ method, args });
return typeof result === 'function' ? result(...args) : result;
};
const repository = {
listCategories: record('listCategories', []),
createCategory: record('createCategory', { categoryId: '31', version: 1 }),
updateCategory: record('updateCategory', { categoryId: '31', version: 2 }),
archiveCategory: record('archiveCategory', { categoryId: '31', version: 3, archived: true }),
listProducts: record('listProducts', []),
createProduct: record('createProduct', { productId: '41', version: 1 }),
updateProduct: record('updateProduct', { productId: '41', version: 2 }),
archiveProduct: record('archiveProduct', { productId: '41', version: 3, archived: true }),
listSkus: record('listSkus', []),
createSku: record('createSku', { skuId: '51', productId: '41', version: 1 }),
updateSku: record('updateSku', { skuId: '51', productId: '41', version: 2 }),
archiveSku: record('archiveSku', { skuId: '51', productId: '41', version: 3, archived: true }),
listListings: record('listListings', []),
putListing: record('putListing', { listingId: '61', storeId: '11', productId: '41', version: 1 }),
archiveListing: record('archiveListing', {
listingId: '61', storeId: '11', productId: '41', version: 2, archived: true
}),
getStoreSettings: record('getStoreSettings', { storeId: '11', version: 0, hours: [] }),
putStoreSettings: record('putStoreSettings', { settingsId: '71', storeId: '11', version: 1 })
};
const app = Fastify({ logger: false });
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
request.traceId = request.headers['x-trace-id'] || request.id;
reply.header('x-trace-id', request.traceId);
});
await registerProductRoutes(app, {
repository,
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() { return currentAccess; }
}
});
const unauthorized = await app.inject({
method: 'GET', url: '/admin-api/products'
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const forbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
});
assert.equal(forbidden.statusCode, 403);
assert.equal(forbidden.json().code, 'PRODUCT_READ_FORBIDDEN');
currentAccess = {
roles: ['STAFF'],
capabilities: ['product.catalog.read'],
storeIds: ['11']
};
const globalCallsBeforeStaff = calls.length;
const staffGlobalForbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
});
assert.equal(staffGlobalForbidden.statusCode, 403);
assert.equal(staffGlobalForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
assert.equal(calls.length, globalCallsBeforeStaff, 'forbidden global reads must not reach repository');
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
headers: { authorization: `Bearer ${token}` }
})).statusCode, 200, 'store-scoped listing reads remain available to scoped staff');
currentAccess = {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read'],
storeIds: []
};
const emptyStoreAdminForbidden = await app.inject({
method: 'GET', url: '/admin-api/products/41/skus',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(emptyStoreAdminForbidden.statusCode, 403);
assert.equal(emptyStoreAdminForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
currentAccess = {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read'],
storeIds: ['11']
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products/41/skus',
headers: { authorization: `Bearer ${token}` }
})).statusCode, 200);
currentAccess = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
storeIds: []
};
const authHeaders = { authorization: `Bearer ${token}`, 'x-trace-id': 'm09d1-product-route' };
const categoryPayload = {
parentId: null,
categoryCode: 'DRINKS',
name: 'Drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-categories?includeInactive=false',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/stores/11/product-categories',
headers: authHeaders, payload: categoryPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-categories/31',
headers: authHeaders, payload: { ...categoryPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/stores/11/product-categories/31?expectedVersion=2',
headers: authHeaders
})).statusCode, 200);
const productPayload = {
productCode: 'TEA',
name: 'Tea',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: true,
status: 'ACTIVE',
sortOrder: 1
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products?status=ACTIVE&search=Tea', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/products', headers: authHeaders, payload: productPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
payload: { ...productPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/products/41?expectedVersion=2', headers: authHeaders
})).statusCode, 200);
const skuPayload = {
skuCode: 'TEA-500',
name: 'Tea 500ml',
attributes: { size: '500ml' },
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products/41/skus?includeInactive=true', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/products/41/skus', headers: authHeaders, payload: skuPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/products/41/skus/51', headers: authHeaders,
payload: { ...skuPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/products/41/skus/51?expectedVersion=2',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-listings/41', headers: authHeaders,
payload: {
expectedVersion: 0,
categoryId: '31',
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
sortOrder: 1
}
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/stores/11/product-listings/41?expectedVersion=1',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
payload: {
expectedVersion: 0,
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: '2026-08-12T00:00:00.000Z',
manualPauseReason: 'inventory count',
hours: [{
weekday: 1, slotNo: 1, openMinute: 1320, closeMinute: 120, crossesMidnight: true
}]
}
})).statusCode, 200);
const createdSkuCall = calls.find((call) => call.method === 'createSku');
assert.equal(createdSkuCall.args[0].tenantId, '7');
assert.equal(createdSkuCall.args[0].traceId, 'm09d1-product-route');
assert.equal(createdSkuCall.args[2].defaultInventoryPolicy, 'TRACKED');
const settingsCall = calls.find((call) => call.method === 'putStoreSettings');
assert.equal(settingsCall.args[2], 0);
assert.equal(settingsCall.args[3].hours[0].crossesMidnight, true);
repository.updateProduct = async () => {
throw new ProductCatalogError('PRODUCT_VERSION_CONFLICT');
};
const conflict = await app.inject({
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
payload: { ...productPayload, expectedVersion: 1 }
});
assert.equal(conflict.statusCode, 409);
assert.equal(conflict.json().code, 'PRODUCT_VERSION_CONFLICT');
const invalid = await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
payload: {
expectedVersion: 0,
salesStatus: 'OPEN',
manualPaused: true,
manualPauseReason: '',
hours: []
}
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.json().code, 'INVALID_PRODUCT_REQUEST');
await app.close();
console.log('PASS: M09-D1 product routes cover global/store read boundaries, CRUD, CAS, auth and business settings.');
+4
View File
@@ -29,5 +29,9 @@ assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
await repository.ensureCustomerRole('7', '21');
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'") && sql.includes('store.operation.read')));
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'")
&& sql.includes('product.catalog.read') && sql.includes('inventory.read')));
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STORE_ADMIN'")
&& sql.includes('product.catalog.write') && sql.includes('inventory.adjust')));
console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');