feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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 = ?`,
|
||||
|
||||
@@ -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
@@ -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'] : [])
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user