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

This commit is contained in:
Codex
2026-08-11 03:51:18 +08:00
parent 2de796143f
commit 93af128002
37 changed files with 14683 additions and 58 deletions
+9 -8
View File
@@ -1,10 +1,10 @@
# 自助棋牌室系统 # 自助棋牌室系统
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.6.md`](./V5.6.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。 本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.7.md`](./V5.7.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
## 固定约束 ## 固定约束
- 当前权威总纲:`V5.6.md` - 当前权威总纲:`V5.7.md`
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git` - 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
- 默认分支:`main` - 默认分支:`main`
- 生产 API`https://api.txyundm.cn` - 生产 API`https://api.txyundm.cn`
@@ -35,7 +35,7 @@
| 用途 | 位置 | | 用途 | 位置 |
|---|---| |---|---|
| 项目入口 | `D:\qipai\README.md` | | 项目入口 | `D:\qipai\README.md` |
| 当前总纲 | `D:\qipai\V5.6.md` | | 当前总纲 | `D:\qipai\V5.7.md` |
| 完整配置索引 | `D:\qipai\docs\configuration.md` | | 完整配置索引 | `D:\qipai\docs\configuration.md` |
| 仓库目录映射 | `D:\qipai\docs\repository-map.md` | | 仓库目录映射 | `D:\qipai\docs\repository-map.md` |
| 后端开发配置 | `D:\qipai\backend\.env.development` | | 后端开发配置 | `D:\qipai\backend\.env.development` |
@@ -129,16 +129,16 @@ sh scripts/setup-git-hooks.sh
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。 - 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
- 每个子阶段必须完成工程编码、真实测试、增量文档、commit、push,并验证 `HEAD == origin/main` - 每个子阶段必须完成工程编码、真实测试、增量文档、commit、push,并验证 `HEAD == origin/main`
- 完成一个子阶段后,在会话资源允许时自动继续下一子阶段,不询问“是否继续”。 - 完成一个子阶段后,在会话资源允许时自动继续下一子阶段,不询问“是否继续”。
- 纯 Markdown 变更不计业务进度;V5.6 文档提交后的下一次普通开发必须产生工程增量 - 纯 Markdown 变更不计业务进度;状态文档只随对应工程阶段伴随更新
- 当前游标、最近工程提交和下一工程目标以 `docs/module-status.md``docs/current-baseline.md` 为准,不在 README 中猜测。 - 当前游标、最近工程提交和下一工程目标以 `docs/module-status.md``docs/current-baseline.md` 为准,不在 README 中猜测。
## 当前进度 ## 当前进度
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。 项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M09-D1`DOING);`M09-A-RECOVERY``M06-C-R1``M06-E-R1``M06-R-REGRESSION``M08-D-R1``M09-B/C` 已完成,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL` - 当前执行游标:`M09-D2`TODO);`M09-D1` 已在当前工作树完成商品目录与库存底座收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`
- 最近工程提交:`1f10715`,协作成员份额统计/结算唯一性、负向冲正、出款前 CAS、商户隔离回调、人工付款和 API/CSV 同源查询已完成;Windows 全量和 WSL MySQL 8.4.10 往返通过 - 最近工程结果:M09-D1 已建立 9 张商品/库存表、分类/商品/SKU/门店售卖与跨日营业时段、可用/锁定/损耗库存、幂等请求和不可变流水;增量/崩溃重试迁移、数据库级迁移互斥、生产触发器前置、API/RBAC 与 MySQL 8.4.10 往返门禁通过,随本次中文工程提交固化
- 下一工程目标:执行 M09-D1,建立分类、商品、SKU、门店售卖、营业时段、库存策略、可用/锁定库存与不可变库存流水底座 - 下一工程目标:执行 M09-D2,建立商品订单、订单项快照、支付状态衔接,以及库存锁定、扣减、释放和取消回补闭环
## 版本递进 ## 版本递进
@@ -147,11 +147,12 @@ sh scripts/setup-git-hooks.sh
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。 - **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。 - **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。 - **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
- **V5.7**:增加中文 Git 提交门禁,恢复并收口 M09-D1 商品目录/库存工作树,游标推进至 M09-D2。
## Codex 入口 ## Codex 入口
```text ```text
请阅读 V5.6.md,按当前进度继续开发。 请阅读 V5.7.md,按当前进度继续开发。
``` ```
+7199
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "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", "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: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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+10
View File
@@ -62,6 +62,8 @@ import {
type SystemOperationsRouteOptions type SystemOperationsRouteOptions
} from './routes/system-operations.js'; } from './routes/system-operations.js';
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.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 { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -90,6 +92,8 @@ export interface BuildAppOptions {
franchise?: FranchiseRouteOptions; franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions; systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions; adminAuth?: AdminAuthRouteOptions;
products?: ProductRouteOptions;
inventory?: InventoryRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -206,6 +210,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.adminAuth) { if (options.adminAuth) {
await registerAdminAuthRoutes(app, 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; return app;
} }
+8 -3
View File
@@ -141,14 +141,19 @@ export class AuthRepository {
INNER JOIN qipai_permissions p ON INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read')) (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF' 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' OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset', AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write', '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') OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage', 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 = ?`, WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId] [input.context.tenantId, input.context.tenantId]
); );
+6 -1
View File
@@ -37,7 +37,8 @@ export class RbacRepository {
INNER JOIN qipai_permissions p ON INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read')) (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF' 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' OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read', AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read')) 'cleaning.task.write', 'cleaning.statistics.read'))
@@ -45,11 +46,15 @@ export class RbacRepository {
AND p.code IN ('user.read', 'staff.manage', 'session.reset', AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write', 'store.operation.read', 'store.operation.write',
'device.read', 'device.write', 'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'cleaning.task.read', 'cleaning.task.write', 'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read')) 'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage', 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',
'cleaning.task.read', 'cleaning.task.write', 'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read')) 'cleaning.statistics.read'))
WHERE r.tenant_id = ?`, WHERE r.tenant_id = ?`,
+325 -6
View File
@@ -11,6 +11,15 @@ export interface MigrationPlan {
file: string; file: string;
checksum: string; checksum: string;
statements: readonly 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 { export interface MigrationExecutionResult extends MigrationPlan {
@@ -18,6 +27,12 @@ export interface MigrationExecutionResult extends MigrationPlan {
affectedRows: number; 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 repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = { const migrationFiles: Record<MigrationDirection, readonly string[]> = {
up: [ up: [
@@ -55,7 +70,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081003_m08d_franchise_leads.up.sql', 'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql', 'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql', 'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.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: [ down: [
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql', 'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
'database/migrations/2026081005_m09b_cleaning_rules.down.sql', 'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.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')) relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
); );
const sql = sqlParts.join('\n'); 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 { return {
direction, direction,
file: relativeFiles.join(','), file: relativeFiles.join(','),
checksum: createHash('sha256').update(sql).digest('hex'), checksum: createHash('sha256').update(sql).digest('hex'),
statements: splitSqlStatements(sql) statements: splitSqlStatements(sql),
migrations
}; };
} }
export async function executeMigrationPlan( export async function executeMigrationPlan(
pool: Pick<MySqlPool, 'query'>, pool: MigrationPool,
plan: MigrationPlan, plan: MigrationPlan,
dryRun = false dryRun = false
): Promise<MigrationExecutionResult> { ): Promise<MigrationExecutionResult> {
@@ -241,9 +272,64 @@ export async function executeMigrationPlan(
return { ...plan, executed: false, affectedRows: 0 }; 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; let affectedRows = 0;
for (const [index, statement] of plan.statements.entries()) { for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement); const result = await queryMigrationStatement(pool, statement);
if (plan.direction === 'verify') { if (plan.direction === 'verify') {
const minimumRows = [ const minimumRows = [
10, 26, 1, 10, 26, 1,
@@ -279,7 +365,8 @@ export async function executeMigrationPlan(
1, 2, 3, 1, 1, 2, 3, 1,
1, 2, 3, 1, 1, 2, 3, 1,
3, 7, 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; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
@@ -297,3 +384,235 @@ export async function executeMigrationPlan(
return { ...plan, executed: true, affectedRows }; return { ...plan, executed: true, affectedRows };
} }
interface AppliedMigrationRow extends Record<string, unknown> {
version?: unknown;
name?: unknown;
checksum?: unknown;
}
async function executeVersionedUpPlan(
pool: Pick<MySqlPool, 'query'>,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
const migrations = plan.migrations ?? [];
const appliedRows = await readAppliedMigrations(pool);
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
for (const migration of migrations) {
const row = applied.get(migration.version);
if (!row) continue;
if (String(row.name) !== migration.name) {
throw new Error(
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
);
}
const storedChecksum = String(row.checksum ?? '');
if (storedChecksum && storedChecksum !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
const pending = migrations.filter((migration) => !applied.has(migration.version));
await assertTriggerConfiguration(
pool,
pending.flatMap((migration) => [...migration.statements])
);
let affectedRows = 0;
for (const migration of pending) {
for (const statement of migration.statements) {
const result = await queryMigrationStatement(pool, statement);
if (result && typeof result === 'object' && 'affectedRows' in result) {
const value = Reflect.get(result, 'affectedRows');
if (typeof value === 'number') affectedRows += value;
}
}
const [markerRows] = await pool.query(
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const marker = Array.isArray(markerRows)
? markerRows[0] as Record<string, unknown> | undefined
: undefined;
if (!marker || String(marker.name) !== migration.name) {
throw new Error(
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
);
}
await storeMigrationChecksum(pool, migration);
}
for (const migration of migrations) {
await storeMigrationChecksum(pool, migration);
}
return { ...plan, executed: true, affectedRows };
}
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
try {
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
throw error;
}
}
async function storeMigrationChecksum(
pool: Pick<MySqlPool, 'query'>,
migration: MigrationFilePlan
) {
try {
const [result] = await pool.query(
`UPDATE qipai_schema_migrations
SET checksum = ?
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
[migration.checksum, migration.version, migration.checksum]
);
if (result && typeof result === 'object'
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
const [rows] = await pool.query(
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const stored = Array.isArray(rows)
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
: '';
if (stored && stored !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
throw error;
}
}
async function assertTriggerConfiguration(
pool: Pick<MySqlPool, 'query'>,
statements: readonly string[]
) {
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
const [rows] = await pool.query(
`SELECT @@GLOBAL.log_bin AS logBin,
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
);
const setting = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
if (Number(setting?.logBin ?? 0) === 1
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
throw new Error(
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
);
}
}
async function queryMigrationStatement(
pool: Pick<MySqlPool, 'query'>,
statement: string
) {
try {
const [result] = await pool.query(statement);
return result;
} catch (error) {
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
throw error;
}
}
async function isMissingM09D1DownObject(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (indexTarget) {
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${indexTarget.table}'
AND index_name = '${indexTarget.index}'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
async function isMatchingM09D1ChecksumColumn(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
is_nullable AS isNullable, column_default AS columnDefault,
collation_name AS collationName
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'`
);
const column = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
return column?.dataType === 'char'
&& Number(column.maxLength) === 64
&& column.isNullable === 'NO'
&& String(column.columnDefault ?? '') === ''
&& column.collationName === 'ascii_bin';
}
async function isMatchingM09D1ScopeIndex(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
return false;
}
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (!target) return false;
const [rows] = await pool.query(
`SELECT non_unique AS nonUnique,
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${target.table}'
AND index_name = '${target.index}'
GROUP BY non_unique`
);
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -113,6 +113,8 @@ function adminAccess(access: AccessProfile) {
const storeRead = tenant || access.capabilities.includes('store.operation.read'); const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [ const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []), ...(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 ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []), ...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : []) ...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
+250
View File
@@ -0,0 +1,250 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
const version = z.number().int().min(1).max(4_294_967_295);
const createVersion = z.number().int().min(0).max(4_294_967_295);
const quantity = z.number().int().min(1).max(1_000_000_000);
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
const reason = z.string().trim().min(1).max(512);
const stockParams = z.object({ skuId: id }).strict();
const ledgerParams = z.object({ inventoryId: id }).strict();
const stockListQuery = z.object({
storeId: id,
page,
pageSize,
skuId: id.optional(),
productId: id.optional(),
search: z.string().trim().max(64).optional()
}).strict();
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
const mutationBase = {
storeId: id,
requestId,
reason,
expectedVersion: version.optional()
};
const policyBody = z.object({
...mutationBase,
expectedVersion: createVersion,
policyType: z.enum(['TRACKED', 'UNLIMITED']),
lowStockThreshold: nonNegativeQuantity
}).strict();
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
const adjustmentBody = z.object({
...mutationBase,
availableDelta: delta,
lossDelta: delta.default(0)
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
const stocktakeBody = z.object({
...mutationBase,
expectedVersion: version,
availableQuantity: nonNegativeQuantity,
lossQuantity: nonNegativeQuantity
}).strict();
const lossBody = z.object({ ...mutationBase, quantity }).strict();
export interface InventoryRouteOptions {
service: Pick<InventoryService,
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
| 'stocktake' | 'recordLoss'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerInventoryRoutes(
app: FastifyInstance,
options: InventoryRouteOptions
) {
app.get('/admin-api/inventory/stocks', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const query = stockListQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listStocks(actor, query.data),
traceId: request.traceId
}));
});
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const params = ledgerParams.safeParse(request.params);
const query = ledgerQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listLedger(actor, {
inventoryId: params.data.inventoryId,
...query.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = policyBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.configurePolicy(actor, {
skuId: params.data.skuId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = inboundBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = adjustmentBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = stocktakeBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = lossBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
}
async function requireInventoryActor(
request: FastifyRequest,
reply: FastifyReply,
options: InventoryRouteOptions,
write: boolean
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
const manager = access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const allowed = manager || (write
? access.capabilities.includes('inventory.adjust')
: access.capabilities.includes('inventory.read')
|| access.capabilities.includes('inventory.adjust'));
if (!allowed) {
reply.status(403).send({
code: 'INVENTORY_OPERATION_FORBIDDEN',
message: 'Inventory permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function inventoryResponse(
reply: FastifyReply,
traceId: string,
work: () => Promise<unknown>
) {
try {
return await work();
} catch (error) {
if (!(error instanceof InventoryError)) throw error;
const status = inventoryErrorStatus(error.code);
return reply.status(status).send({
code: error.code,
message: 'The inventory operation is not allowed.',
traceId
});
}
}
function inventoryErrorStatus(code: string) {
if (code.endsWith('_FORBIDDEN')) return 403;
if (code.endsWith('_NOT_FOUND')) return 404;
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
return 400;
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_INVENTORY_REQUEST',
message: 'The inventory request is invalid.',
traceId
});
}
+487
View File
@@ -0,0 +1,487 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import {
ProductCatalogError,
type ProductCatalogRepository
} from '../products/product-catalog-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const version = z.coerce.number().int().min(0).max(0xffffffff);
const code = z.string().trim().min(1).max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
const storeParamsSchema = z.object({ storeId: id }).strict();
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
const productParamsSchema = z.object({ productId: id }).strict();
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
const includeInactiveQuerySchema = z.object({
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
}).strict();
const categoryFields = {
parentId: id.nullable().optional(),
categoryCode: code,
name: z.string().trim().min(1).max(128),
description: z.string().trim().max(1024).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createCategorySchema = z.object(categoryFields).strict();
const updateCategorySchema = z.object({
...categoryFields,
expectedVersion: version.min(1)
}).strict();
const productFields = {
productCode: code,
name: z.string().trim().min(1).max(128),
unitName: z.string().trim().min(1).max(32),
description: z.string().trim().max(10000).default(''),
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
images: z.array(z.string().url().max(512)).max(20).default([]),
deliveryEnabled: z.boolean().default(true),
storageEnabled: z.boolean().default(false),
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createProductSchema = z.object(productFields).strict();
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
const productListQuerySchema = z.object({
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
search: z.string().trim().min(1).max(128).optional()
}).strict();
const attributesSchema = z.record(
z.string().trim().min(1).max(64),
z.string().trim().max(128)
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
const skuFields = {
skuCode: code,
name: z.string().trim().min(1).max(128),
attributes: attributesSchema.default({}),
barcode: z.string().trim().max(64).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
salePriceCents: z.number().int().min(0).max(100000000),
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
costPriceCents: z.number().int().min(0).max(100000000).default(0),
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
};
const createSkuSchema = z.object(skuFields).strict();
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
const listingSchema = z.object({
expectedVersion: version,
categoryId: id,
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
salesStartAt: z.coerce.date().nullable().optional(),
salesEndAt: z.coerce.date().nullable().optional(),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|| value.salesEndAt > value.salesStartAt, {
message: 'salesEndAt must be later than salesStartAt'
});
const hourSchema = z.object({
weekday: z.number().int().min(1).max(7),
slotNo: z.number().int().min(1).max(8),
openMinute: z.number().int().min(0).max(1439),
closeMinute: z.number().int().min(0).max(1439),
crossesMidnight: z.boolean()
}).strict();
const settingsSchema = z.object({
expectedVersion: version,
salesStatus: z.enum(['OPEN', 'CLOSED']),
manualPaused: z.boolean(),
manualPausedUntil: z.coerce.date().nullable().optional(),
manualPauseReason: z.string().trim().max(512).default(''),
hours: z.array(hourSchema).max(56).default([])
}).strict().superRefine((value, context) => {
if (value.manualPaused && value.manualPauseReason.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['manualPauseReason'],
message: 'manualPauseReason is required while manually paused'
});
}
});
export interface ProductRouteOptions {
repository: Pick<ProductCatalogRepository,
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
| 'listListings' | 'putListing' | 'archiveListing'
| 'getStoreSettings' | 'putStoreSettings'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
for (const path of [
'/admin-api/stores/:storeId/product-categories',
'/app-api/management/stores/:storeId/product-categories'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listCategories(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = createCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const body = updateCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateCategory(
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveCategory(
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const query = productListQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listProducts(actor, query.data),
traceId: request.traceId
}));
});
app.post('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const body = createProductSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createProduct(actor, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = updateProductSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateProduct(
actor, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveProduct(
actor, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const params = productParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSkus(
actor, params.data.productId, query.data.includeInactive
),
traceId: request.traceId
}));
});
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = createSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createSku(actor, params.data.productId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const body = updateSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateSku(
actor, params.data.productId, params.data.skuId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveSku(
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-listings',
'/app-api/management/stores/:storeId/product-listings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listListings(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const body = listingSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putListing(
actor, params.data.storeId, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveListing(
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-sales-settings',
'/app-api/management/stores/:storeId/product-sales-settings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getStoreSettings(actor, params.data.storeId),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = settingsSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putStoreSettings(
actor, params.data.storeId, expectedVersion, input
),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: ProductRouteOptions,
mode: 'read' | 'write',
scope: 'store' | 'global-catalog' = 'store'
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
const allowed = access.capabilities.includes(capability)
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|| access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
if (!allowed) {
reply.status(403).send({
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
message: 'Product catalog permission is required.',
traceId: request.traceId
});
return null;
}
if (scope === 'global-catalog') {
const tenantManager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
&& access.storeIds.length > 0
&& (access.capabilities.includes('product.catalog.read')
|| access.capabilities.includes('product.catalog.write'));
if (!tenantManager && !authorizedStoreAdmin) {
reply.status(403).send({
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
message: 'Tenant management or an authorized store administrator is required.',
traceId: request.traceId
});
return null;
}
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ProductCatalogError)) throw error;
const code = error.code;
const status = code.endsWith('_FORBIDDEN') ? 403
: code.endsWith('_NOT_FOUND') ? 404
: code.includes('INVALID') ? 400
: 409;
return reply.status(status).send({
code,
message: 'The product catalog request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
});
}
+16
View File
@@ -44,6 +44,8 @@ import { BusinessStatisticsRepository } from './operations/business-statistics-r
import { FranchiseRepository } from './franchise/franchise-repository.js'; import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js'; import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-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 config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
@@ -54,6 +56,8 @@ const orderManagementRepository = new OrderManagementRepository(pool);
const walletLedgerService = new WalletLedgerService(pool); const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool); const marketingBenefits = new MarketingBenefitService(pool);
const cleaningTaskRepository = new CleaningTaskRepository(pool); const cleaningTaskRepository = new CleaningTaskRepository(pool);
const productCatalogRepository = new ProductCatalogRepository(pool);
const inventoryService = new InventoryService(pool);
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits); const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson); const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport()); const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
@@ -241,6 +245,18 @@ const app = await buildApp({
jwtSecret: config.auth.jwtSecret, jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds, accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds 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 () => { app.addHook('onClose', async () => {
+1
View File
@@ -43,6 +43,7 @@ assert.equal(loginInput.tenantCode, 'demo');
assert.equal(login.json().data.user.phone, undefined); assert.equal(login.json().data.user.phone, undefined);
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']); 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('system'));
assert.ok(login.json().data.access.menus.includes('products'));
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/); assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/); assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
const successfulSessionId = loginInput.sessionId; const successfulSessionId = loginInput.sessionId;
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { signAccessToken } from '../dist/auth/jwt.js';
import { InventoryError } from '../dist/inventory/inventory-service.js';
import { registerInventoryRoutes } from '../dist/routes/inventory.js';
const secret = 'test-only-inventory-route-jwt-secret';
const sessionId = '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e';
const token = signAccessToken({
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
}, secret, 900);
const headers = {
authorization: `Bearer ${token}`,
'x-trace-id': 'm09d1-inventory-route'
};
const calls = [];
let currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const mutationResult = (input, operation) => ({
inventoryId: '101',
ledgerId: '1001',
storeId: input.storeId,
skuId: input.skuId,
operation,
policyType: input.policyType ?? 'TRACKED',
availableQuantity: input.availableQuantity ?? 8,
lockedQuantity: 0,
lossQuantity: input.lossQuantity ?? 1,
lowStockThreshold: input.lowStockThreshold ?? 2,
version: 2,
idempotent: false
});
const record = (method, result) => async (...args) => {
calls.push({ method, args });
return typeof result === 'function' ? result(...args) : result;
};
const mutate = (operation) => record(operation.toLowerCase(), (_actor, input) => {
if (input.storeId === '12') throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
if (input.requestId === 'insufficient') {
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
}
return mutationResult(input, operation);
});
const service = {
listStocks: record('listStocks', (_actor, input) => ({
items: [], total: 0, page: input.page, pageSize: input.pageSize
})),
listLedger: record('listLedger', (_actor, input) => ({
items: [], total: 0, page: input.page, pageSize: input.pageSize
})),
configurePolicy: mutate('CONFIGURE'),
inbound: mutate('INBOUND'),
adjust: mutate('ADJUST'),
stocktake: mutate('STOCKTAKE'),
recordLoss: mutate('LOSS')
};
const app = Fastify({ logger: false });
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
request.traceId = request.headers['x-trace-id'] || request.id;
reply.header('x-trace-id', request.traceId);
});
await registerInventoryRoutes(app, {
service,
authRepository: {
async validateSession() {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60_000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() { return currentAccess; }
},
jwtSecret: secret
});
const unauthorized = await app.inject({
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11'
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
const forbidden = await app.inject({
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
});
assert.equal(forbidden.statusCode, 403);
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
currentAccess = {
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
};
const listed = await app.inject({
method: 'GET',
url: '/admin-api/inventory/stocks?storeId=11&page=2&pageSize=25&search=tea',
headers
});
assert.equal(listed.statusCode, 200);
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
const listCall = calls.find((call) => call.method === 'listStocks');
assert.equal(listCall.args[0].tenantId, '7');
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
assert.deepEqual(listCall.args[1], {
storeId: '11', page: 2, pageSize: 25, search: 'tea'
});
const ledger = await app.inject({
method: 'GET',
url: '/admin-api/inventory/stocks/101/ledger?storeId=11&page=1&pageSize=10',
headers
});
assert.equal(ledger.statusCode, 200);
const ledgerCall = calls.find((call) => call.method === 'listLedger');
assert.equal(ledgerCall.args[1].inventoryId, '101');
const readOnlyWrite = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
payload: { storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2 }
});
assert.equal(readOnlyWrite.statusCode, 403);
currentAccess = {
roles: ['STORE_ADMIN'], capabilities: ['inventory.adjust'], storeIds: ['11']
};
const invalid = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/adjust', headers,
payload: {
storeId: '11', requestId: 'adjust-zero', reason: 'nothing',
availableDelta: 0, lossDelta: 0
}
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.json().code, 'INVALID_INVENTORY_REQUEST');
const requests = [
['PUT', '/admin-api/inventory/stocks/5/policy', {
storeId: '11', requestId: 'policy-1', reason: 'configure',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
}, 'configure'],
['POST', '/admin-api/inventory/stocks/5/inbound', {
storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2
}, 'inbound'],
['POST', '/admin-api/inventory/stocks/5/adjust', {
storeId: '11', requestId: 'adjust-1', reason: 'manual correction',
availableDelta: -1, lossDelta: 1
}, 'adjust'],
['POST', '/admin-api/inventory/stocks/5/stocktake', {
storeId: '11', requestId: 'stocktake-1', reason: 'count',
expectedVersion: 1, availableQuantity: 8, lossQuantity: 1
}, 'stocktake'],
['POST', '/admin-api/inventory/stocks/5/loss', {
storeId: '11', requestId: 'loss-1', reason: 'damaged', quantity: 1
}, 'loss']
];
for (const [method, url, payload, calledMethod] of requests) {
const response = await app.inject({ method, url, headers, payload });
assert.equal(response.statusCode, 200, `${method} ${url}`);
assert.equal(response.json().code, 0);
const call = calls.findLast((item) => item.method === calledMethod);
assert.equal(call.args[1].skuId, '5');
assert.equal(call.args[1].storeId, '11');
}
for (const [method, url, payload] of [
['PUT', '/admin-api/inventory/stocks/5/policy', {
storeId: '11', requestId: 'policy-no-version', reason: 'configure',
policyType: 'TRACKED', lowStockThreshold: 2
}],
['POST', '/admin-api/inventory/stocks/5/stocktake', {
storeId: '11', requestId: 'stocktake-no-version', reason: 'count',
availableQuantity: 8, lossQuantity: 1
}]
]) {
const response = await app.inject({ method, url, headers, payload });
assert.equal(response.statusCode, 400, `${method} ${url} requires expectedVersion`);
assert.equal(response.json().code, 'INVALID_INVENTORY_REQUEST');
}
const insufficient = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
payload: { storeId: '11', requestId: 'insufficient', reason: 'conflict', quantity: 2 }
});
assert.equal(insufficient.statusCode, 409);
assert.equal(insufficient.json().code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
const outOfScope = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/loss', headers,
payload: { storeId: '12', requestId: 'loss-outside', reason: 'wrong store', quantity: 1 }
});
assert.equal(outOfScope.statusCode, 403);
assert.equal(outOfScope.json().code, 'INVENTORY_STORE_SCOPE_FORBIDDEN');
const internalRoute = await app.inject({
method: 'POST', url: '/admin-api/inventory/stocks/5/lock', headers,
payload: { storeId: '11', requestId: 'lock-1', reason: 'must stay internal', quantity: 1 }
});
assert.equal(internalRoute.statusCode, 404);
await app.close();
console.log('PASS: M09-D1 inventory routes enforce auth, inventory.adjust, validation, error mapping and HTTP boundaries.');
+446
View File
@@ -0,0 +1,446 @@
import assert from 'node:assert/strict';
import {
InventoryError,
InventoryService
} from '../dist/inventory/inventory-service.js';
const state = {
stocks: [
stock('101', '5', 10, 0, 1, 2, 'TRACKED'),
stock('102', '2', 4, 0, 0, 1, 'TRACKED'),
stock('103', '9', 0, 0, 0, 0, 'UNLIMITED')
],
skus: new Map([
['2', 'TRACKED'], ['4', 'TRACKED'], ['5', 'TRACKED'], ['9', 'UNLIMITED']
]),
inactiveSkus: new Set(),
archivedSkus: new Set(),
requests: [],
ledgers: [],
audits: [],
lockOrder: [],
nextStockId: 104,
nextLedgerId: 1001,
commits: 0,
rollbacks: 0
};
function stock(id, skuId, availableQuantity, lockedQuantity, lossQuantity,
lowStockThreshold, policyType) {
return {
id, tenantId: '7', storeId: '11', skuId, policyType,
availableQuantity, lockedQuantity, lossQuantity, lowStockThreshold, version: 1
};
}
function cloneMutableState() {
return structuredClone({
stocks: state.stocks,
requests: state.requests,
ledgers: state.ledgers,
audits: state.audits,
nextStockId: state.nextStockId,
nextLedgerId: state.nextLedgerId
});
}
let transactionSnapshot = null;
const pool = {
async getConnection() { return connection; },
async execute(sql, params = []) { return execute(sql, params); }
};
const connection = {
async beginTransaction() { transactionSnapshot = cloneMutableState(); },
async commit() { state.commits += 1; transactionSnapshot = null; },
async rollback() {
state.rollbacks += 1;
if (transactionSnapshot) {
state.stocks = transactionSnapshot.stocks;
state.requests = transactionSnapshot.requests;
state.ledgers = transactionSnapshot.ledgers;
state.audits = transactionSnapshot.audits;
state.nextStockId = transactionSnapshot.nextStockId;
state.nextLedgerId = transactionSnapshot.nextLedgerId;
}
transactionSnapshot = null;
},
release() {},
async execute(sql, params = []) { return execute(sql, params); }
};
function execute(sql, params) {
if (/\b(UPDATE|DELETE)\s+qipai_product_inventory_ledger/i.test(sql)) {
throw new Error('immutable ledger was mutated');
}
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory_ledger')) {
const matches = state.ledgers.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]));
return [[{ total: matches.length }], []];
}
if (sql.includes('FROM qipai_product_inventory_ledger')
&& sql.includes('ORDER BY id DESC')) {
const rows = state.ledgers.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]))
.sort((left, right) => Number(right.id) - Number(left.id));
return [rows, []];
}
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory i')) {
const matches = state.stocks.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1]));
return [[{ total: matches.length }], []];
}
if (sql.includes('FROM qipai_product_inventory i') && sql.includes('ORDER BY p.sort_order')) {
const rows = state.stocks.filter((item) => item.tenantId === String(params[0])
&& item.storeId === String(params[1])).map((item) => ({
...item,
skuCode: `SKU-${item.skuId}`,
skuName: `规格 ${item.skuId}`,
productId: `8${item.skuId}`,
productCode: `P-${item.skuId}`,
productName: `商品 ${item.skuId}`,
salePriceCents: 1200
}));
return [rows, []];
}
if (sql.includes('INSERT IGNORE INTO qipai_product_inventory')) {
const tenantId = String(params[0]);
const storeId = String(params[3]);
const skuId = String(params[5]);
const existing = findStock(tenantId, storeId, skuId);
if (existing || tenantId !== '7' || storeId !== '11' || !state.skus.has(skuId)) {
return [{ affectedRows: 0 }, []];
}
state.stocks.push({
id: String(state.nextStockId++), tenantId, storeId, skuId,
policyType: state.skus.get(skuId), availableQuantity: 0, lockedQuantity: 0,
lossQuantity: 0, lowStockThreshold: 0, version: 1
});
return [{ affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_product_inventory_requests')) {
const tenantId = String(params[0]);
const requestId = String(params[2]);
if (state.requests.some((item) => item.tenantId === tenantId
&& item.requestId === requestId)) {
throw Object.assign(new Error('duplicate inventory request'), { code: 'ER_DUP_ENTRY' });
}
state.requests.push({
tenantId, storeId: String(params[1]), requestId,
operation: params[3], businessType: String(params[4]), businessId: String(params[5]),
fingerprint: String(params[6]), itemCount: Number(params[7])
});
return [{ insertId: state.requests.length, affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_product_inventory_requests') && sql.includes('FOR UPDATE')) {
const row = state.requests.find((item) => item.tenantId === String(params[0])
&& item.requestId === String(params[1]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('SELECT product_id AS productId')
&& sql.includes('FROM qipai_product_skus')) {
const skuId = String(params[1]);
return [state.skus.has(skuId) ? [{ productId: `8${skuId}` }] : [], []];
}
if (sql.includes('SELECT id FROM qipai_products') && sql.includes('FOR SHARE')) {
return [[{ id: String(params[1]) }], []];
}
if (sql.includes('SELECT id FROM qipai_product_skus') && sql.includes('FOR SHARE')) {
const skuId = String(params[2]);
const requiresActive = sql.includes("status = 'ACTIVE'");
return [state.skus.has(skuId) && !state.archivedSkus.has(skuId)
&& (!requiresActive || !state.inactiveSkus.has(skuId))
? [{ id: skuId }] : [], []];
}
if (sql.includes('SELECT id FROM qipai_product_inventory')) {
const found = findStock(String(params[0]), String(params[1]), String(params[2]));
return [found ? [{ id: found.id }] : [], []];
}
if (sql.includes('FROM qipai_product_inventory') && sql.includes('FOR UPDATE')) {
const skuId = String(params[2]);
state.lockOrder.push(skuId);
const found = findStock(String(params[0]), String(params[1]), skuId);
return [found ? [{ ...found }] : [], []];
}
if (sql.includes('FROM qipai_product_inventory_ledger')
&& sql.includes('request_id = ?')) {
const row = state.ledgers.find((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
const inventoryIds = new Set(params.slice(4).map(String));
const balances = new Map();
for (const item of state.ledgers) {
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|| !inventoryIds.has(item.inventoryId)) continue;
const metadata = JSON.parse(item.metadata);
const reservationDelta = metadata.inventoryReservationDelta ?? Number(item.lockedDelta);
balances.set(item.inventoryId,
(balances.get(item.inventoryId) ?? 0) + Number(reservationDelta));
}
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
inventoryId, reservedQuantity
})), []];
}
if (sql.includes('AS reservedQuantity')
&& sql.includes('WHERE tenant_id = ? AND inventory_id = ?')) {
const reservedQuantity = state.ledgers
.filter((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]))
.reduce((total, item) => {
const metadata = JSON.parse(item.metadata);
return total + Number(metadata.inventoryReservationDelta ?? item.lockedDelta);
}, 0);
return [[{ reservedQuantity }], []];
}
if (sql.startsWith('UPDATE qipai_product_inventory')) {
const found = state.stocks.find((item) => item.tenantId === String(params[7])
&& item.id === String(params[8]) && item.version === Number(params[9]));
if (!found) return [{ affectedRows: 0 }, []];
Object.assign(found, {
policyType: params[0], availableQuantity: Number(params[1]),
lockedQuantity: Number(params[2]), lossQuantity: Number(params[3]),
lowStockThreshold: Number(params[4]), version: Number(params[5])
});
return [{ affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_product_inventory_ledger')) {
const duplicate = state.ledgers.some((item) => item.tenantId === String(params[0])
&& item.inventoryId === String(params[1]) && item.requestId === String(params[4]));
if (duplicate) throw new Error('duplicate inventory ledger request');
const row = {
id: String(state.nextLedgerId++),
tenantId: String(params[0]), inventoryId: String(params[1]),
storeId: String(params[2]), skuId: String(params[3]), requestId: String(params[4]),
businessType: String(params[5]), businessId: String(params[6]), operation: params[7],
availableDelta: Number(params[8]), lockedDelta: Number(params[9]),
lossDelta: Number(params[10]), availableAfter: Number(params[11]),
lockedAfter: Number(params[12]), lossAfter: Number(params[13]),
versionAfter: Number(params[14]), operatorId: params[15] == null ? null : String(params[15]),
traceId: String(params[16]), reason: String(params[17]), metadata: String(params[18]),
createdAt: new Date()
};
state.ledgers.push(row);
return [{ insertId: row.id, affectedRows: 1 }, []];
}
if (sql.includes('INSERT INTO qipai_audit_logs')) {
state.audits.push({ action: params[3], resourceId: String(params[4]), metadata: params[8] });
return [{ insertId: state.audits.length, affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
function findStock(tenantId, storeId, skuId) {
return state.stocks.find((item) => item.tenantId === tenantId
&& item.storeId === storeId && item.skuId === skuId);
}
const manager = {
tenantId: '7', userId: '21',
access: { roles: ['STORE_ADMIN'], capabilities: ['inventory.read', 'inventory.adjust'], storeIds: ['11'] },
traceId: 'inventory-test', ip: '127.0.0.1', userAgent: 'inventory-test-agent'
};
const service = new InventoryService(pool);
const listed = await service.listStocks(manager, { storeId: '11', page: 1, pageSize: 20 });
assert.equal(listed.total, 3);
assert.equal(listed.items[0].tenantId, '7');
assert.equal(listed.items[0].lossQuantity, 1);
const inboundInput = {
storeId: '11', skuId: '5', requestId: 'admin-inbound-1', reason: '首批入库',
expectedVersion: 1, quantity: 5
};
const inbound = await service.inbound(manager, inboundInput);
assert.equal(inbound.availableQuantity, 15);
assert.equal(inbound.version, 2);
const auditCountAfterInbound = state.audits.length;
const replay = await service.inbound(manager, inboundInput);
assert.equal(replay.idempotent, true);
assert.equal(replay.availableQuantity, 15);
assert.equal(state.audits.length, auditCountAfterInbound);
await assert.rejects(
() => service.inbound(manager, { ...inboundInput, quantity: 6 }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
const adjusted = await service.adjust(manager, {
storeId: '11', skuId: '5', requestId: 'admin-adjust-1', reason: '人工纠偏',
expectedVersion: 2, availableDelta: -2, lossDelta: 1
});
assert.deepEqual(
[adjusted.availableQuantity, adjusted.lossQuantity, adjusted.version],
[13, 2, 3]
);
const counted = await service.stocktake(manager, {
storeId: '11', skuId: '5', requestId: 'admin-stocktake-1', reason: '闭店盘点',
expectedVersion: 3, availableQuantity: 12, lossQuantity: 3
});
assert.deepEqual(
[counted.availableQuantity, counted.lossQuantity, counted.version],
[12, 3, 4]
);
const lost = await service.recordLoss(manager, {
storeId: '11', skuId: '5', requestId: 'admin-loss-1', reason: '包装破损',
expectedVersion: 4, quantity: 2
});
assert.deepEqual([lost.availableQuantity, lost.lossQuantity, lost.version], [10, 5, 5]);
const configured = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
});
assert.equal(configured.policyType, 'TRACKED');
assert.equal(configured.lowStockThreshold, 3);
assert.ok(findStock('7', '11', '4'));
const reconfigured = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-2', reason: 'raise warning threshold',
policyType: 'TRACKED', lowStockThreshold: 7, expectedVersion: 2
});
assert.equal(reconfigured.lowStockThreshold, 7);
const configuredReplay = await service.configurePolicy(manager, {
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
});
assert.equal(configuredReplay.idempotent, true);
assert.equal(configuredReplay.lowStockThreshold, 3);
await assert.rejects(
() => service.configurePolicy(manager, {
storeId: '11', skuId: '5', requestId: 'admin-policy-unlimited', reason: '错误切换',
policyType: 'UNLIMITED', lowStockThreshold: 0, expectedVersion: 5
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_POLICY_STOCK_NOT_ZERO'
);
const scopedOut = { ...manager, access: { ...manager.access, storeIds: ['12'] } };
await assert.rejects(
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
);
state.lockOrder.length = 0;
const batchBase = {
tenantId: '7', storeId: '11', requestId: 'order-lock-1',
businessType: 'PRODUCT_ORDER', businessId: 'order-500', traceId: 'order-lock-trace',
operatorId: '21', reason: '创建商品订单', metadata: { source: 'ORDER' },
items: [{ skuId: '5', quantity: 3 }, { skuId: '2', quantity: 1 }, { skuId: '2', quantity: 1 }]
};
const locked = await service.lockMany(batchBase);
assert.equal(locked.idempotent, false);
assert.deepEqual(state.lockOrder.slice(-2), ['2', '5']);
assert.deepEqual(
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
[2, 2]
);
assert.deepEqual(
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
[7, 3]
);
const ledgerCountAfterLock = state.ledgers.length;
const lockedReplay = await service.lockMany(batchBase);
assert.equal(lockedReplay.idempotent, true);
assert.equal(state.ledgers.length, ledgerCountAfterLock);
await assert.rejects(
() => service.lockMany({ ...batchBase, items: [{ skuId: '5', quantity: 4 }, { skuId: '2', quantity: 2 }] }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.lockMany({ ...batchBase, items: [{ skuId: '9', quantity: 1 }] }),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.releaseMany({
...batchBase, requestId: 'foreign-order-release', businessId: 'order-foreign',
items: [{ skuId: '5', quantity: 1 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
);
await service.releaseMany({
...batchBase, requestId: 'order-release-1', reason: '部分取消',
items: [{ skuId: '5', quantity: 1 }, { skuId: '2', quantity: 1 }]
});
await service.deductMany({
...batchBase, requestId: 'order-deduct-1', reason: '支付确认扣减',
items: [{ skuId: '5', quantity: 2 }, { skuId: '2', quantity: 1 }]
});
assert.deepEqual(
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
[3, 0]
);
assert.deepEqual(
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
[8, 0]
);
const beforeFailedBatch = structuredClone(state.stocks);
await assert.rejects(
() => service.lockMany({
...batchBase, requestId: 'order-lock-too-many', businessId: 'order-501',
items: [{ skuId: '5', quantity: 999 }]
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
);
assert.deepEqual(state.stocks, beforeFailedBatch);
assert.ok(state.rollbacks > 0);
const unlimited = await service.lockMany({
...batchBase, requestId: 'order-unlimited-lock', businessId: 'order-502',
items: [{ skuId: '9', quantity: 999 }]
});
assert.equal(unlimited.items[0].availableQuantity, 0);
assert.equal(unlimited.items[0].lockedQuantity, 0);
await assert.rejects(
() => service.configurePolicy(manager, {
storeId: '11', skuId: '9', requestId: 'unlimited-switch-active', reason: '切换追踪',
expectedVersion: unlimited.items[0].version, policyType: 'TRACKED', lowStockThreshold: 0
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
);
const unlimitedDeduct = await service.deductMany({
...batchBase, requestId: 'order-unlimited-deduct', businessId: 'order-502',
items: [{ skuId: '9', quantity: 999 }]
});
const trackedAfterUnlimited = await service.configurePolicy(manager, {
storeId: '11', skuId: '9', requestId: 'unlimited-switch-settled', reason: '切换追踪',
expectedVersion: unlimitedDeduct.items[0].version,
policyType: 'TRACKED', lowStockThreshold: 0
});
assert.equal(trackedAfterUnlimited.policyType, 'TRACKED');
state.inactiveSkus.add('2');
const inactiveReplay = await service.lockMany(batchBase);
assert.equal(inactiveReplay.idempotent, true);
await assert.rejects(
() => service.lockMany({
...batchBase, requestId: 'inactive-sku-lock', businessId: 'order-503',
items: [{ skuId: '2', quantity: 1 }]
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_SELLABLE'
);
state.inactiveSkus.delete('2');
state.archivedSkus.add('5');
const archivedAdminReplay = await service.inbound(manager, inboundInput);
assert.equal(archivedAdminReplay.idempotent, true);
await assert.rejects(
() => service.inbound(manager, {
storeId: '11', skuId: '5', requestId: 'archived-sku-inbound',
reason: '归档规格不得重新入库', quantity: 1
}),
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
);
state.archivedSkus.delete('5');
const history = await service.listLedger(manager, {
storeId: '11', inventoryId: '101', page: 1, pageSize: 100
});
assert.ok(history.items.length >= 7);
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
assert.equal(state.audits.length, state.ledgers.length);
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
+76 -1
View File
@@ -120,6 +120,15 @@ const cleaningSettlementIntegrityDownSql = read(
const cleaningSettlementIntegrityVerifySql = read( const cleaningSettlementIntegrityVerifySql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql' '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 = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -536,4 +545,70 @@ assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/); assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/); assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M09-C migration contracts are present.'); for (const table of [
'qipai_product_categories',
'qipai_products',
'qipai_product_skus',
'qipai_product_store_listings',
'qipai_product_store_settings',
'qipai_product_store_hours',
'qipai_product_inventory',
'qipai_product_inventory_requests',
'qipai_product_inventory_ledger'
]) {
assert.match(productInventoryUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(productInventoryDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(productInventoryVerifySql, new RegExp(`'${table}'`));
}
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_stores_tenant_id/);
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_users_tenant_id/);
assert.match(productInventoryUpSql, /ADD COLUMN checksum CHAR\(64\)/);
assert.match(productInventoryUpSql, /store_id BIGINT UNSIGNED NOT NULL/);
assert.match(productInventoryUpSql, /uq_qipai_product_category_scope_id/);
assert.match(
productInventoryUpSql,
/FOREIGN KEY \(tenant_id, store_id, category_id\)[\s\S]*qipai_product_categories/
);
assert.match(productInventoryUpSql, /delivery_enabled TINYINT\(1\) NOT NULL DEFAULT 1/);
assert.match(productInventoryUpSql, /storage_enabled TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /default_inventory_policy VARCHAR\(16\)/);
assert.match(productInventoryUpSql, /default_inventory_policy IN \('TRACKED', 'UNLIMITED'\)/);
assert.match(productInventoryUpSql, /manual_paused_at DATETIME\(3\) NULL/);
assert.match(productInventoryUpSql, /manual_paused_until DATETIME\(3\) NULL/);
assert.match(productInventoryUpSql, /manual_pause_reason VARCHAR\(512\)/);
assert.match(productInventoryUpSql, /crosses_midnight TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /crosses_midnight = 1 AND close_minute <= open_minute/);
assert.match(productInventoryUpSql, /loss_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /loss_delta BIGINT NOT NULL DEFAULT 0/);
assert.match(productInventoryUpSql, /loss_after BIGINT UNSIGNED NOT NULL/);
for (const operation of [
'CONFIGURE', 'INBOUND', 'ADJUST', 'LOCK', 'RELEASE',
'DEDUCT', 'STOCKTAKE', 'LOSS', 'RETURN'
]) assert.match(productInventoryUpSql, new RegExp(`'${operation}'`));
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_request/);
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_request/);
assert.match(productInventoryUpSql, /fk_qipai_product_inventory_ledger_request/);
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_version/);
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_update/);
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_delete/);
for (const permission of [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
]) {
assert.match(productInventoryUpSql, new RegExp(permission.replace('.', '\\.')));
assert.match(productInventoryDownSql, new RegExp(permission.replace('.', '\\.')));
assert.match(productInventoryVerifySql, new RegExp(permission.replace('.', '\\.')));
}
assert.match(productInventoryUpSql, /r\.code = 'STAFF'/);
assert.match(productInventoryUpSql, /r\.code IN \('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_stores_tenant_id/);
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_users_tenant_id/);
assert.match(productInventoryDownSql, /DROP COLUMN checksum/);
assert.match(
productInventoryUpSql,
/FOREIGN KEY \(tenant_id, operator_id\) REFERENCES qipai_users\(tenant_id, id\)/
);
assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
assert.match(productInventoryVerifySql, /'2026081107'/);
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
+195 -2
View File
@@ -45,7 +45,8 @@ assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/); assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); 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, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.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, /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'); 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.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( assert.ok(
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql') downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.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(dryRun.executed, false);
assert.equal(calls.length, 0); 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, { const liveResult = await executeMigrationPlan(fakePool, {
direction: 'up', direction: 'up',
file: 'test.sql', file: 'test.sql',
@@ -86,6 +141,144 @@ assert.equal(liveResult.executed, true);
assert.equal(liveResult.affectedRows, 2); assert.equal(liveResult.affectedRows, 2);
assert.deepEqual(calls, ['SELECT 1', 'SELECT 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( await assert.rejects(
() => executeMigrationPlan({ () => executeMigrationPlan({
async query() { async query() {
@@ -48,6 +48,12 @@ import { hashPassword } from '../dist/auth/password.js';
import { import {
CleaningTaskError, CleaningTaskRepository CleaningTaskError, CleaningTaskRepository
} from '../dist/cleaning/cleaning-task-repository.js'; } 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 { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -97,6 +103,15 @@ const expectedTables = [
'qipai_payments', 'qipai_payments',
'qipai_permissions', 'qipai_permissions',
'qipai_platform_apps', '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_policies',
'qipai_profit_share_receivers', 'qipai_profit_share_receivers',
'qipai_profit_shares', 'qipai_profit_shares',
@@ -140,18 +155,95 @@ async function readCoreTables(pool) {
return rows.map((row) => row.tableName); 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) { async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014', '2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219', '2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005', '2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
'2026081006'] '2026081006', '2026081107']
); );
return rows; return rows;
} }
@@ -1839,7 +1931,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible'); assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId); const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, 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); assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, { await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone 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(); const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match( assert.match(
@@ -3142,6 +4019,9 @@ try {
await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify); 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 readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersions(pool), [ assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' }, { version: '2026061601', name: 'm01b_core_schema' },
@@ -3168,7 +4048,8 @@ try {
{ version: '2026081003', name: 'm08d_franchise_leads' }, { version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }, { version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }, { 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); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -3191,13 +4072,14 @@ try {
await assertDeviceTopology(pool, loginContext); await assertDeviceTopology(pool, loginContext);
await assertIotMessages(pool, loginContext); await assertIotMessages(pool, loginContext);
await assertCleaningTaskTransactions(pool, loginContext); await assertCleaningTaskTransactions(pool, loginContext);
await assertProductInventoryFoundation(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(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.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -3227,7 +4109,8 @@ try {
{ version: '2026081003', name: 'm08d_franchise_leads' }, { version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }, { version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -3375,7 +4258,15 @@ try {
'settlement reversal preserves history and releases the share', 'settlement reversal preserves history and releases the share',
'processing payout blocks cancellation until provider-confirmed failure', 'processing payout blocks cancellation until provider-confirmed failure',
'collaborative task settles only after every member share is paid', '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)); }, null, 2));
} finally { } finally {
+681
View File
@@ -0,0 +1,681 @@
import assert from 'node:assert/strict';
import {
ProductCatalogError,
ProductCatalogRepository,
isProductStoreOpenAt,
validateBusinessHours
} from '../dist/products/product-catalog-repository.js';
const tenantActor = {
tenantId: '7',
userId: '21',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId: 'product-catalog-test',
ip: '127.0.0.1',
userAgent: 'catalog-test'
};
const storeActor = {
...tenantActor,
userId: '22',
access: {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read', 'product.catalog.write'],
storeIds: ['11']
}
};
const mondayOvernight = [{
weekday: 1,
slotNo: 1,
openMinute: 22 * 60,
closeMinute: 2 * 60,
crossesMidnight: true
}];
validateBusinessHours(mondayOvernight);
const openSettings = {
timezone: 'Asia/Shanghai',
businessStatus: 'OPEN',
salesStatus: 'OPEN',
manualPausedAt: null,
manualPausedUntil: null,
hours: mondayOvernight
};
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T15:00:00.000Z')),
true,
'Monday 23:00 should be inside the Monday overnight window'
);
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T17:00:00.000Z')),
true,
'Tuesday 01:00 must be attributed to the previous-day overnight window'
);
assert.equal(
isProductStoreOpenAt(openSettings, new Date('2026-08-10T19:00:00.000Z')),
false
);
assert.equal(isProductStoreOpenAt({
...openSettings,
manualPausedAt: new Date('2026-08-10T14:00:00.000Z'),
manualPausedUntil: null
}, new Date('2026-08-10T15:00:00.000Z')), false, 'manual pause overrides hours');
assert.throws(
() => validateBusinessHours([
{ weekday: 1, slotNo: 1, openMinute: 540, closeMinute: 720, crossesMidnight: false },
{ weekday: 1, slotNo: 2, openMinute: 600, closeMinute: 780, crossesMidnight: false }
]),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
);
class ScriptedConnection {
constructor(steps) {
this.steps = [...steps];
this.committed = false;
this.rolledBack = false;
this.released = false;
this.calls = [];
}
async beginTransaction() {}
async commit() { this.committed = true; }
async rollback() { this.rolledBack = true; }
release() { this.released = true; }
async execute(sql, params = []) {
this.calls.push({ sql, params });
const step = this.steps.shift();
assert.ok(step, `Unexpected SQL: ${sql}`);
assert.match(sql, step.match);
if (step.check) step.check(params, sql);
if (step.error) throw step.error;
return step.result;
}
}
function repositoryFor(connection) {
return new ProductCatalogRepository({
async getConnection() { return connection; },
async execute(sql, params) { return connection.execute(sql, params); }
});
}
function repositoryForConnections(connections) {
const pending = [...connections];
return new ProductCatalogRepository({
async getConnection() {
const connection = pending.shift();
assert.ok(connection, 'Unexpected transaction');
return connection;
},
async execute() { throw new Error('This scenario must use transactional connections'); }
});
}
function categoryInput(parentId) {
return {
parentId,
categoryCode: 'DRINKS',
name: 'Cold drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
}
const createConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /INSERT INTO qipai_product_categories/,
result: [{ insertId: 31, affectedRows: 1 }, []],
check(params) {
assert.equal(params[0], '7');
assert.equal(params[1], '11');
assert.equal(params[3], 'DRINKS');
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_CATEGORY_CREATED');
assert.equal(params[3], 'PRODUCT_CATEGORY');
assert.equal(params[4], '31');
assert.deepEqual(JSON.parse(params[8]), { storeId: '11', version: 1 });
}
}
]);
const created = await repositoryFor(createConnection).createCategory(storeActor, '11', {
parentId: null,
categoryCode: 'DRINKS',
name: 'Drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
});
assert.deepEqual(created, { categoryId: '31', version: 1 });
assert.equal(createConnection.committed, true);
assert.equal(createConnection.rolledBack, false);
assert.equal(createConnection.released, true);
const casConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{ match: /UPDATE qipai_product_categories/, result: [{ affectedRows: 0 }, []] },
{ match: /SELECT version FROM qipai_product_categories/, result: [[{ version: 3 }], []] }
]);
await assert.rejects(
() => repositoryFor(casConnection).updateCategory(storeActor, '11', '31', 2, {
parentId: null,
categoryCode: 'DRINKS',
name: 'Cold drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_VERSION_CONFLICT'
);
assert.equal(casConnection.committed, false);
assert.equal(casConnection.rolledBack, true);
assert.equal(casConnection.released, true);
for (const scenario of [
{
name: 'two-node cycle',
ancestors: [{ id: '32', parentId: '31' }]
},
{
name: 'multi-node cycle',
ancestors: [{ id: '32', parentId: '33' }, { id: '33', parentId: '31' }]
}
]) {
const cycleConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
...scenario.ancestors.map((ancestor, index) => ({
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
result: [[ancestor], []],
check(params, sql) {
assert.deepEqual(params, ['7', '11', index === 0 ? '32' : '33']);
assert.match(sql, /deleted_at IS NULL/);
}
}))
]);
await assert.rejects(
() => repositoryFor(cycleConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('32')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE',
scenario.name
);
assert.equal(cycleConnection.rolledBack, true, scenario.name);
assert.equal(cycleConnection.steps.length, 0, scenario.name);
}
const selfParentConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] }
]);
await assert.rejects(
() => repositoryFor(selfParentConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('31')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE'
);
assert.equal(selfParentConnection.rolledBack, true);
for (const invalidParent of ['cross-store parent', 'deleted parent']) {
const invalidParentConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
result: [[], []],
check(params, sql) {
assert.deepEqual(params, ['7', '11', '32']);
assert.match(sql, /store_id = \?/);
assert.match(sql, /deleted_at IS NULL/);
}
}
]);
await assert.rejects(
() => repositoryFor(invalidParentConnection).updateCategory(
storeActor, '11', '31', 2, categoryInput('32')
),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_CATEGORY_PARENT_NOT_FOUND',
invalidParent
);
assert.equal(invalidParentConnection.rolledBack, true, invalidParent);
}
for (const [bucket, balances] of [
['available', { availableQuantity: 1, lockedQuantity: 0, lossQuantity: 0 }],
['locked', { availableQuantity: 0, lockedQuantity: 1, lossQuantity: 0 }],
['loss', { availableQuantity: 0, lockedQuantity: 0, lossQuantity: 1 }]
]) {
const stockConnection = new ScriptedConnection([
{
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
result: [[{ id: '41', version: 4 }], []]
},
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []],
check(params, sql) {
assert.deepEqual(params, ['7', '41', '51']);
assert.match(sql, /product_id = \?/);
assert.match(sql, /deleted_at IS NULL/);
}
},
{
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
result: [[{ id: '71', storeId: '11', skuId: '51', ...balances }], []],
check(params, sql) {
assert.deepEqual(params, ['7', '51']);
assert.match(sql, /ORDER BY store_id, id[\s\S]*FOR UPDATE/);
}
}
]);
await assert.rejects(
() => repositoryFor(stockConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_HAS_INVENTORY_STOCK',
`${bucket} stock must block SKU archival`
);
assert.equal(stockConnection.rolledBack, true, bucket);
assert.equal(stockConnection.steps.length, 0, bucket);
}
const skuVersionConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 3 }], []]
}
]);
await assert.rejects(
() => repositoryFor(skuVersionConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
);
assert.equal(skuVersionConnection.rolledBack, true);
const skuOwnershipConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[], []],
check(params) { assert.deepEqual(params, ['7', '41', '51']); }
}
]);
await assert.rejects(
() => repositoryFor(skuOwnershipConnection).archiveSku(tenantActor, '41', '51', 2),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_SKU_NOT_FOUND'
);
const productStockConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 2
}], []],
check(params, sql) {
assert.deepEqual(params, ['7', '41']);
assert.match(sql, /ORDER BY s.id, i.store_id, i.id[\s\S]*FOR UPDATE/);
}
}
]);
await assert.rejects(
() => repositoryFor(productStockConnection).archiveProduct(tenantActor, '41', 2),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_HAS_INVENTORY_STOCK'
);
assert.equal(productStockConnection.rolledBack, true);
const activeSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
{
match: /deleted_at AS deletedAt[\s\S]*FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2, deletedAt: null }], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
}
]);
await assert.rejects(
() => repositoryFor(activeSkuConnection).archiveProduct(tenantActor, '41', 2),
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_HAS_ACTIVE_SKUS'
);
assert.equal(activeSkuConnection.rolledBack, true);
const skuArchiveConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{ id: '51', productId: '41', version: 2 }], []]
},
{
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
},
{ match: /UPDATE qipai_product_skus/, result: [{ affectedRows: 1 }, []] },
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_SKU_ARCHIVED');
assert.equal(params[4], '51');
}
}
]);
const productArchiveAfterSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
{
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
result: [[{
id: '51', productId: '41', version: 3, deletedAt: new Date('2026-08-11T00:00:00Z')
}], []]
},
{
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
result: [[{
id: '71', storeId: '11', skuId: '51',
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
}], []]
},
{ match: /FROM qipai_product_store_listings/, result: [[{ total: 0 }], []] },
{
match: /UPDATE qipai_products/,
result: [{ affectedRows: 1 }, []],
check(params, sql) {
assert.deepEqual(params, ['21', '7', '41', 4]);
assert.match(sql, /deleted_at = UTC_TIMESTAMP/);
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[2], 'PRODUCT_ARCHIVED'); }
}
]);
const reusedProductConnection = new ScriptedConnection([
{
match: /INSERT INTO qipai_products/,
result: [{ insertId: 42, affectedRows: 1 }, []],
check(params) { assert.equal(params[1], 'TEA'); }
},
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
]);
const reusedSkuConnection = new ScriptedConnection([
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '42', version: 1 }], []] },
{
match: /INSERT INTO qipai_product_skus/,
result: [{ insertId: 52, affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'TEA-500');
assert.equal(params[5], '690000000001');
}
},
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
]);
const archiveAndReuseRepository = repositoryForConnections([
skuArchiveConnection,
productArchiveAfterSkuConnection,
reusedProductConnection,
reusedSkuConnection
]);
assert.deepEqual(
await archiveAndReuseRepository.archiveSku(tenantActor, '41', '51', 2),
{ skuId: '51', productId: '41', version: 3, archived: true }
);
assert.deepEqual(
await archiveAndReuseRepository.archiveProduct(tenantActor, '41', 4),
{ productId: '41', version: 5, archived: true }
);
assert.deepEqual(
await archiveAndReuseRepository.createProduct(tenantActor, {
productCode: 'TEA',
name: 'Tea replacement',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: false,
status: 'DRAFT',
sortOrder: 1
}),
{ productId: '42', version: 1 }
);
assert.deepEqual(
await archiveAndReuseRepository.createSku(tenantActor, '42', {
skuCode: 'TEA-500',
name: 'Tea 500ml replacement',
attributes: { size: '500ml' },
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
}),
{ skuId: '52', productId: '42', version: 1 }
);
assert.equal(skuArchiveConnection.committed, true);
assert.equal(productArchiveAfterSkuConnection.committed, true);
assert.equal(reusedProductConnection.committed, true);
assert.equal(reusedSkuConnection.committed, true);
const settingsConnection = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{ match: /FROM qipai_product_store_settings/, result: [[], []] },
{
match: /INSERT INTO qipai_product_store_settings/,
result: [{ insertId: 41, affectedRows: 1 }, []]
},
{ match: /UPDATE qipai_product_store_hours/, result: [{ affectedRows: 0 }, []] },
{
match: /INSERT INTO qipai_product_store_hours/,
result: [{ insertId: 51, affectedRows: 1 }, []],
check(params) {
assert.deepEqual(params.slice(3), [1, 1, 1320, 120, true]);
}
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) {
assert.equal(params[2], 'PRODUCT_STORE_SETTINGS_UPDATED');
}
}
]);
const pausedUntil = new Date('2026-08-12T00:00:00.000Z');
const settingsResult = await repositoryFor(settingsConnection).putStoreSettings(
storeActor,
'11',
0,
{
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: pausedUntil,
manualPauseReason: 'inventory count',
hours: mondayOvernight
},
new Date('2026-08-11T00:00:00.000Z')
);
assert.deepEqual(settingsResult, { settingsId: '41', storeId: '11', version: 1 });
assert.equal(settingsConnection.committed, true);
const firstListingArchive = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
result: [[{ id: '61', version: 1 }], []]
},
{
match: /UPDATE qipai_product_store_listings/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '61', 1]); }
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '61'); }
}
]);
const rebuiltListing = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
result: [[{
id: '41', status: 'ACTIVE', deliveryEnabled: true, storageEnabled: true
}], []]
},
{ match: /FROM qipai_product_categories/, result: [[{ id: '31' }], []] },
{ match: /INSERT INTO qipai_product_store_listings/, result: [{ insertId: 62, affectedRows: 1 }, []] },
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '62'); }
}
]);
const secondListingArchive = new ScriptedConnection([
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
{
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
result: [[{ id: '62', version: 1 }], []]
},
{
match: /UPDATE qipai_product_store_listings/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '62', 1]); }
},
{
match: /INSERT INTO qipai_audit_logs/,
result: [{ affectedRows: 1 }, []],
check(params) { assert.equal(params[4], '62'); }
}
]);
const listingLifecycleRepository = repositoryForConnections([
firstListingArchive, rebuiltListing, secondListingArchive
]);
assert.deepEqual(
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
{ listingId: '61', storeId: '11', productId: '41', version: 2, archived: true }
);
assert.deepEqual(
await listingLifecycleRepository.putListing(storeActor, '11', '41', 0, {
categoryId: '31',
status: 'INACTIVE',
fulfillmentMode: 'SELF_SERVICE',
sortOrder: 1
}),
{ listingId: '62', storeId: '11', productId: '41', version: 1 }
);
assert.deepEqual(
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
{ listingId: '62', storeId: '11', productId: '41', version: 2, archived: true }
);
assert.equal(firstListingArchive.committed, true);
assert.equal(rebuiltListing.committed, true);
assert.equal(secondListingArchive.committed, true);
const skuReadRow = {
id: '51',
productId: '41',
skuCode: 'TEA-500',
name: 'Tea 500ml',
attributesJson: '{"size":"500ml"}',
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE',
version: 2,
createdAt: new Date('2026-08-11T00:00:00Z'),
updatedAt: new Date('2026-08-11T00:00:00Z')
};
function skuReadConnection() {
return new ScriptedConnection([
{ match: /SELECT id FROM qipai_products/, result: [[{ id: '41' }], []] },
{ match: /FROM qipai_product_skus/, result: [[skuReadRow], []] }
]);
}
const storeAdminSkus = await repositoryFor(skuReadConnection()).listSkus(storeActor, '41');
assert.equal(storeAdminSkus.length, 1);
assert.equal(Object.hasOwn(storeAdminSkus[0], 'costPriceCents'), false,
'store administrators must not receive product cost');
const tenantManagerSkus = await repositoryFor(skuReadConnection()).listSkus(tenantActor, '41');
assert.equal(tenantManagerSkus[0].costPriceCents, 200,
'tenant managers retain product cost visibility');
const neverUsedPool = {
async execute() { throw new Error('scope checks must happen before SQL'); },
async getConnection() { throw new Error('scope checks must happen before SQL'); }
};
const guardedRepository = new ProductCatalogRepository(neverUsedPool);
await assert.rejects(
() => guardedRepository.listProducts({
...storeActor,
access: {
roles: ['STAFF'], capabilities: ['product.catalog.read'], storeIds: ['11']
}
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.listSkus({
...storeActor,
access: {
roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: []
}
}, '41'),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.listCategories({
...storeActor,
access: { ...storeActor.access, storeIds: ['12'] }
}, '11'),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
);
await assert.rejects(
() => guardedRepository.createProduct(storeActor, {
productCode: 'TEA',
name: 'Tea',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: false,
status: 'DRAFT',
sortOrder: 1
}),
(error) => error instanceof ProductCatalogError
&& error.code === 'PRODUCT_TENANT_WRITE_FORBIDDEN'
);
console.log('PASS: M09-D1 product catalog covers global-read scope, cost redaction, category cycles, archive locks, listing lifecycle, hours, CAS and audit.');
+285
View File
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { signAccessToken } from '../dist/auth/jwt.js';
import { ProductCatalogError } from '../dist/products/product-catalog-repository.js';
import { registerProductRoutes } from '../dist/routes/products.js';
const secret = 'test-only-product-route-jwt-secret-32';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 1
}, secret, 900);
const calls = [];
let currentAccess = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
storeIds: []
};
const record = (method, result) => async (...args) => {
calls.push({ method, args });
return typeof result === 'function' ? result(...args) : result;
};
const repository = {
listCategories: record('listCategories', []),
createCategory: record('createCategory', { categoryId: '31', version: 1 }),
updateCategory: record('updateCategory', { categoryId: '31', version: 2 }),
archiveCategory: record('archiveCategory', { categoryId: '31', version: 3, archived: true }),
listProducts: record('listProducts', []),
createProduct: record('createProduct', { productId: '41', version: 1 }),
updateProduct: record('updateProduct', { productId: '41', version: 2 }),
archiveProduct: record('archiveProduct', { productId: '41', version: 3, archived: true }),
listSkus: record('listSkus', []),
createSku: record('createSku', { skuId: '51', productId: '41', version: 1 }),
updateSku: record('updateSku', { skuId: '51', productId: '41', version: 2 }),
archiveSku: record('archiveSku', { skuId: '51', productId: '41', version: 3, archived: true }),
listListings: record('listListings', []),
putListing: record('putListing', { listingId: '61', storeId: '11', productId: '41', version: 1 }),
archiveListing: record('archiveListing', {
listingId: '61', storeId: '11', productId: '41', version: 2, archived: true
}),
getStoreSettings: record('getStoreSettings', { storeId: '11', version: 0, hours: [] }),
putStoreSettings: record('putStoreSettings', { settingsId: '71', storeId: '11', version: 1 })
};
const app = Fastify({ logger: false });
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
request.traceId = request.headers['x-trace-id'] || request.id;
reply.header('x-trace-id', request.traceId);
});
await registerProductRoutes(app, {
repository,
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() { return currentAccess; }
}
});
const unauthorized = await app.inject({
method: 'GET', url: '/admin-api/products'
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
const forbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
});
assert.equal(forbidden.statusCode, 403);
assert.equal(forbidden.json().code, 'PRODUCT_READ_FORBIDDEN');
currentAccess = {
roles: ['STAFF'],
capabilities: ['product.catalog.read'],
storeIds: ['11']
};
const globalCallsBeforeStaff = calls.length;
const staffGlobalForbidden = await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
});
assert.equal(staffGlobalForbidden.statusCode, 403);
assert.equal(staffGlobalForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
assert.equal(calls.length, globalCallsBeforeStaff, 'forbidden global reads must not reach repository');
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
headers: { authorization: `Bearer ${token}` }
})).statusCode, 200, 'store-scoped listing reads remain available to scoped staff');
currentAccess = {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read'],
storeIds: []
};
const emptyStoreAdminForbidden = await app.inject({
method: 'GET', url: '/admin-api/products/41/skus',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(emptyStoreAdminForbidden.statusCode, 403);
assert.equal(emptyStoreAdminForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
currentAccess = {
roles: ['STORE_ADMIN'],
capabilities: ['product.catalog.read'],
storeIds: ['11']
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products/41/skus',
headers: { authorization: `Bearer ${token}` }
})).statusCode, 200);
currentAccess = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
storeIds: []
};
const authHeaders = { authorization: `Bearer ${token}`, 'x-trace-id': 'm09d1-product-route' };
const categoryPayload = {
parentId: null,
categoryCode: 'DRINKS',
name: 'Drinks',
description: '',
imageUrl: '',
status: 'ACTIVE',
sortOrder: 1
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-categories?includeInactive=false',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/stores/11/product-categories',
headers: authHeaders, payload: categoryPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-categories/31',
headers: authHeaders, payload: { ...categoryPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/stores/11/product-categories/31?expectedVersion=2',
headers: authHeaders
})).statusCode, 200);
const productPayload = {
productCode: 'TEA',
name: 'Tea',
unitName: 'bottle',
description: '',
coverUrl: '',
images: [],
deliveryEnabled: true,
storageEnabled: true,
status: 'ACTIVE',
sortOrder: 1
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products?status=ACTIVE&search=Tea', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/products', headers: authHeaders, payload: productPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
payload: { ...productPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/products/41?expectedVersion=2', headers: authHeaders
})).statusCode, 200);
const skuPayload = {
skuCode: 'TEA-500',
name: 'Tea 500ml',
attributes: { size: '500ml' },
barcode: '690000000001',
imageUrl: '',
salePriceCents: 500,
marketPriceCents: 600,
costPriceCents: 200,
defaultInventoryPolicy: 'TRACKED',
status: 'ACTIVE'
};
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/products/41/skus?includeInactive=true', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'POST', url: '/admin-api/products/41/skus', headers: authHeaders, payload: skuPayload
})).statusCode, 201);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/products/41/skus/51', headers: authHeaders,
payload: { ...skuPayload, expectedVersion: 1 }
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/products/41/skus/51?expectedVersion=2',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-listings/41', headers: authHeaders,
payload: {
expectedVersion: 0,
categoryId: '31',
status: 'ACTIVE',
fulfillmentMode: 'BOTH',
sortOrder: 1
}
})).statusCode, 200);
assert.equal((await app.inject({
method: 'DELETE', url: '/admin-api/stores/11/product-listings/41?expectedVersion=1',
headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'GET', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders
})).statusCode, 200);
assert.equal((await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
payload: {
expectedVersion: 0,
salesStatus: 'OPEN',
manualPaused: true,
manualPausedUntil: '2026-08-12T00:00:00.000Z',
manualPauseReason: 'inventory count',
hours: [{
weekday: 1, slotNo: 1, openMinute: 1320, closeMinute: 120, crossesMidnight: true
}]
}
})).statusCode, 200);
const createdSkuCall = calls.find((call) => call.method === 'createSku');
assert.equal(createdSkuCall.args[0].tenantId, '7');
assert.equal(createdSkuCall.args[0].traceId, 'm09d1-product-route');
assert.equal(createdSkuCall.args[2].defaultInventoryPolicy, 'TRACKED');
const settingsCall = calls.find((call) => call.method === 'putStoreSettings');
assert.equal(settingsCall.args[2], 0);
assert.equal(settingsCall.args[3].hours[0].crossesMidnight, true);
repository.updateProduct = async () => {
throw new ProductCatalogError('PRODUCT_VERSION_CONFLICT');
};
const conflict = await app.inject({
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
payload: { ...productPayload, expectedVersion: 1 }
});
assert.equal(conflict.statusCode, 409);
assert.equal(conflict.json().code, 'PRODUCT_VERSION_CONFLICT');
const invalid = await app.inject({
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
payload: {
expectedVersion: 0,
salesStatus: 'OPEN',
manualPaused: true,
manualPauseReason: '',
hours: []
}
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.json().code, 'INVALID_PRODUCT_REQUEST');
await app.close();
console.log('PASS: M09-D1 product routes cover global/store read boundaries, CRUD, CAS, auth and business settings.');
+4
View File
@@ -29,5 +29,9 @@ assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/); assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
await repository.ensureCustomerRole('7', '21'); 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('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.'); console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');
@@ -0,0 +1,36 @@
DROP TRIGGER IF EXISTS qipai_product_inventory_ledger_no_delete;
DROP TRIGGER IF EXISTS qipai_product_inventory_ledger_no_update;
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN (
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'
);
DELETE FROM qipai_permissions
WHERE code IN (
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'
);
DROP TABLE IF EXISTS qipai_product_inventory_ledger;
DROP TABLE IF EXISTS qipai_product_inventory_requests;
DROP TABLE IF EXISTS qipai_product_inventory;
DROP TABLE IF EXISTS qipai_product_store_hours;
DROP TABLE IF EXISTS qipai_product_store_settings;
DROP TABLE IF EXISTS qipai_product_store_listings;
DROP TABLE IF EXISTS qipai_product_skus;
DROP TABLE IF EXISTS qipai_products;
DROP TABLE IF EXISTS qipai_product_categories;
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;
DELETE FROM qipai_schema_migrations WHERE version = '2026081107';
@@ -0,0 +1,441 @@
ALTER TABLE qipai_stores
ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id);
ALTER TABLE qipai_users
ADD UNIQUE KEY uq_qipai_users_tenant_id (tenant_id, id);
ALTER TABLE qipai_schema_migrations
ADD COLUMN checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin
NOT NULL DEFAULT '' AFTER name;
CREATE TABLE IF NOT EXISTS qipai_product_categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
parent_id BIGINT UNSIGNED NULL,
category_code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
description VARCHAR(1024) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
sort_order INT NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
active_code_key VARCHAR(64)
GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN category_code ELSE NULL END) STORED,
CONSTRAINT fk_qipai_product_category_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_category_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_category_parent
FOREIGN KEY (tenant_id, store_id, parent_id)
REFERENCES qipai_product_categories(tenant_id, store_id, id),
CONSTRAINT fk_qipai_product_category_created_by
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_category_updated_by
FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_category_scope_id (tenant_id, store_id, id),
UNIQUE KEY uq_qipai_product_category_active_code
(tenant_id, store_id, active_code_key),
KEY idx_qipai_product_category_parent
(tenant_id, store_id, parent_id, status, sort_order),
CONSTRAINT chk_qipai_product_category_status
CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT chk_qipai_product_category_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
product_code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
unit_name VARCHAR(32) NOT NULL,
description TEXT NULL,
cover_url VARCHAR(512) NOT NULL DEFAULT '',
images_json JSON NULL,
delivery_enabled TINYINT(1) NOT NULL DEFAULT 1,
storage_enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
sort_order INT NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
active_code_key VARCHAR(64)
GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN product_code ELSE NULL END) STORED,
CONSTRAINT fk_qipai_product_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_created_by
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_updated_by
FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_tenant_id (tenant_id, id),
UNIQUE KEY uq_qipai_product_active_code (tenant_id, active_code_key),
KEY idx_qipai_product_catalog (tenant_id, status, sort_order),
CONSTRAINT chk_qipai_product_status
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE')),
CONSTRAINT chk_qipai_product_capabilities CHECK (
delivery_enabled IN (0, 1) AND storage_enabled IN (0, 1)
),
CONSTRAINT chk_qipai_product_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_skus (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
sku_code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
attributes_json JSON NULL,
barcode VARCHAR(64) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
sale_price_cents INT UNSIGNED NOT NULL,
market_price_cents INT UNSIGNED NOT NULL DEFAULT 0,
cost_price_cents INT UNSIGNED NOT NULL DEFAULT 0,
default_inventory_policy VARCHAR(16) NOT NULL DEFAULT 'TRACKED',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
active_code_key VARCHAR(64)
GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN sku_code ELSE NULL END) STORED,
active_barcode_key VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN deleted_at IS NULL AND barcode <> '' THEN barcode ELSE NULL END
) STORED,
CONSTRAINT fk_qipai_product_sku_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_sku_product
FOREIGN KEY (tenant_id, product_id) REFERENCES qipai_products(tenant_id, id),
CONSTRAINT fk_qipai_product_sku_created_by
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_sku_updated_by
FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_sku_tenant_id (tenant_id, id),
UNIQUE KEY uq_qipai_product_sku_active_code (tenant_id, active_code_key),
UNIQUE KEY uq_qipai_product_sku_active_barcode (tenant_id, active_barcode_key),
KEY idx_qipai_product_sku_product (tenant_id, product_id, status),
CONSTRAINT chk_qipai_product_sku_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT chk_qipai_product_sku_inventory_policy CHECK (
default_inventory_policy IN ('TRACKED', 'UNLIMITED')
),
CONSTRAINT chk_qipai_product_sku_version CHECK (version >= 1),
CONSTRAINT chk_qipai_product_sku_amounts CHECK (
sale_price_cents <= 100000000
AND market_price_cents <= 100000000
AND cost_price_cents <= 100000000
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_store_listings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE',
fulfillment_mode VARCHAR(16) NOT NULL DEFAULT 'DELIVERY',
sales_start_at DATETIME(3) NULL,
sales_end_at DATETIME(3) NULL,
sort_order INT NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
active_product_id BIGINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN product_id ELSE NULL END) STORED,
CONSTRAINT fk_qipai_product_listing_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_listing_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_listing_product
FOREIGN KEY (tenant_id, product_id) REFERENCES qipai_products(tenant_id, id),
CONSTRAINT fk_qipai_product_listing_category
FOREIGN KEY (tenant_id, store_id, category_id)
REFERENCES qipai_product_categories(tenant_id, store_id, id),
CONSTRAINT fk_qipai_product_listing_created_by
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_listing_updated_by
FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_store_listing_active
(tenant_id, store_id, active_product_id),
KEY idx_qipai_product_store_listing_state
(tenant_id, store_id, category_id, status, sort_order),
CONSTRAINT chk_qipai_product_listing_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT chk_qipai_product_listing_fulfillment
CHECK (fulfillment_mode IN ('DELIVERY', 'SELF_SERVICE', 'BOTH')),
CONSTRAINT chk_qipai_product_listing_window CHECK (
sales_start_at IS NULL OR sales_end_at IS NULL OR sales_end_at > sales_start_at
),
CONSTRAINT chk_qipai_product_listing_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_store_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
sales_status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
manual_paused_at DATETIME(3) NULL,
manual_paused_until DATETIME(3) NULL,
manual_pause_reason VARCHAR(512) NOT NULL DEFAULT '',
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_store_setting_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_store_setting_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_store_setting_created_by
FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_store_setting_updated_by
FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_store_setting (tenant_id, store_id),
UNIQUE KEY uq_qipai_product_store_setting_scope_id (tenant_id, store_id, id),
CONSTRAINT chk_qipai_product_store_setting_status
CHECK (sales_status IN ('OPEN', 'CLOSED')),
CONSTRAINT chk_qipai_product_store_setting_pause CHECK (
(manual_paused_at IS NULL
AND manual_paused_until IS NULL
AND manual_pause_reason = '')
OR (manual_paused_at IS NOT NULL
AND (manual_paused_until IS NULL OR manual_paused_until > manual_paused_at)
AND manual_pause_reason <> '')
),
CONSTRAINT chk_qipai_product_store_setting_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_store_hours (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
settings_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
weekday TINYINT UNSIGNED NOT NULL,
slot_no TINYINT UNSIGNED NOT NULL,
open_minute SMALLINT UNSIGNED NOT NULL,
close_minute SMALLINT UNSIGNED NOT NULL,
crosses_midnight TINYINT(1) NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
active_slot_no TINYINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN slot_no ELSE NULL END) STORED,
CONSTRAINT fk_qipai_product_store_hour_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_store_hour_setting
FOREIGN KEY (tenant_id, store_id, settings_id)
REFERENCES qipai_product_store_settings(tenant_id, store_id, id),
CONSTRAINT fk_qipai_product_store_hour_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
UNIQUE KEY uq_qipai_product_store_hour_active
(tenant_id, store_id, weekday, active_slot_no),
KEY idx_qipai_product_store_hour_window
(tenant_id, store_id, weekday, open_minute, close_minute),
CONSTRAINT chk_qipai_product_store_hour_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT chk_qipai_product_store_hour_slot CHECK (slot_no BETWEEN 1 AND 8),
CONSTRAINT chk_qipai_product_store_hour_window CHECK (
open_minute <= 1439
AND close_minute <= 1439
AND crosses_midnight IN (0, 1)
AND ((crosses_midnight = 0 AND close_minute > open_minute)
OR (crosses_midnight = 1 AND close_minute <= open_minute))
),
CONSTRAINT chk_qipai_product_store_hour_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_inventory (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
sku_id BIGINT UNSIGNED NOT NULL,
policy_type VARCHAR(16) NOT NULL DEFAULT 'TRACKED',
available_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0,
locked_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0,
loss_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0,
low_stock_threshold BIGINT UNSIGNED NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 1,
updated_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_inventory_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_inventory_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_inventory_sku
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
CONSTRAINT fk_qipai_product_inventory_updated_by
FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_inventory_scope (tenant_id, store_id, sku_id),
UNIQUE KEY uq_qipai_product_inventory_scope_id
(tenant_id, store_id, sku_id, id),
KEY idx_qipai_product_inventory_low_stock
(tenant_id, store_id, policy_type, available_quantity, low_stock_threshold),
CONSTRAINT chk_qipai_product_inventory_policy CHECK (
policy_type IN ('TRACKED', 'UNLIMITED')
AND (policy_type = 'TRACKED'
OR (available_quantity = 0
AND locked_quantity = 0
AND loss_quantity = 0
AND low_stock_threshold = 0))
),
CONSTRAINT chk_qipai_product_inventory_quantity CHECK (
available_quantity <= 1000000000
AND locked_quantity <= 1000000000
AND loss_quantity <= 1000000000
AND low_stock_threshold <= 1000000000
),
CONSTRAINT chk_qipai_product_inventory_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_inventory_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
operation VARCHAR(32) NOT NULL,
business_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
business_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL,
fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
item_count SMALLINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NULL,
trace_id VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_inventory_request_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_inventory_request_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_inventory_request_operator
FOREIGN KEY (tenant_id, operator_id) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_product_inventory_request
(tenant_id, request_id),
KEY idx_qipai_product_inventory_request_business
(tenant_id, business_type, business_id, created_at, id),
CONSTRAINT chk_qipai_product_inventory_request_operation CHECK (
operation IN (
'CONFIGURE', 'INBOUND', 'ADJUST', 'LOCK', 'RELEASE',
'DEDUCT', 'STOCKTAKE', 'LOSS', 'RETURN'
)
),
CONSTRAINT chk_qipai_product_inventory_request_items CHECK (
item_count BETWEEN 1 AND 100
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_product_inventory_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
inventory_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
sku_id BIGINT UNSIGNED NOT NULL,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
business_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
business_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL,
operation VARCHAR(32) NOT NULL,
available_delta BIGINT NOT NULL DEFAULT 0,
locked_delta BIGINT NOT NULL DEFAULT 0,
loss_delta BIGINT NOT NULL DEFAULT 0,
available_after BIGINT UNSIGNED NOT NULL,
locked_after BIGINT UNSIGNED NOT NULL,
loss_after BIGINT UNSIGNED NOT NULL,
version_after INT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NULL,
trace_id VARCHAR(128) NOT NULL,
reason VARCHAR(512) NOT NULL DEFAULT '',
metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_product_inventory_ledger_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_product_inventory_ledger_inventory
FOREIGN KEY (tenant_id, store_id, sku_id, inventory_id)
REFERENCES qipai_product_inventory(tenant_id, store_id, sku_id, id),
CONSTRAINT fk_qipai_product_inventory_ledger_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_product_inventory_ledger_sku
FOREIGN KEY (tenant_id, sku_id) REFERENCES qipai_product_skus(tenant_id, id),
CONSTRAINT fk_qipai_product_inventory_ledger_operator
FOREIGN KEY (tenant_id, operator_id) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_product_inventory_ledger_request
FOREIGN KEY (tenant_id, request_id)
REFERENCES qipai_product_inventory_requests(tenant_id, request_id),
UNIQUE KEY uq_qipai_product_inventory_ledger_request
(tenant_id, inventory_id, request_id),
UNIQUE KEY uq_qipai_product_inventory_ledger_version
(tenant_id, inventory_id, version_after),
KEY idx_qipai_product_inventory_ledger_history
(tenant_id, store_id, sku_id, created_at, id),
KEY idx_qipai_product_inventory_ledger_business
(tenant_id, business_type, business_id),
CONSTRAINT chk_qipai_product_inventory_ledger_operation CHECK (
operation IN (
'CONFIGURE', 'INBOUND', 'ADJUST', 'LOCK', 'RELEASE',
'DEDUCT', 'STOCKTAKE', 'LOSS', 'RETURN'
)
),
CONSTRAINT chk_qipai_product_inventory_ledger_delta CHECK (
available_delta BETWEEN -1000000000 AND 1000000000
AND locked_delta BETWEEN -1000000000 AND 1000000000
AND loss_delta BETWEEN -1000000000 AND 1000000000
),
CONSTRAINT chk_qipai_product_inventory_ledger_after CHECK (
available_after <= 1000000000
AND locked_after <= 1000000000
AND loss_after <= 1000000000
AND version_after >= 1
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
DROP TRIGGER IF EXISTS qipai_product_inventory_ledger_no_update;
CREATE TRIGGER qipai_product_inventory_ledger_no_update
BEFORE UPDATE ON qipai_product_inventory_ledger
FOR EACH ROW SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PRODUCT_INVENTORY_LEDGER_IMMUTABLE';
DROP TRIGGER IF EXISTS qipai_product_inventory_ledger_no_delete;
CREATE TRIGGER qipai_product_inventory_ledger_no_delete
BEFORE DELETE ON qipai_product_inventory_ledger
FOR EACH ROW SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PRODUCT_INVENTORY_LEDGER_IMMUTABLE';
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('product.catalog.read', '查看商品目录', 'product'),
('product.catalog.write', '管理商品目录', 'product'),
('inventory.read', '查看商品库存', 'inventory'),
('inventory.adjust', '调整商品库存', 'inventory');
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT r.tenant_id, r.id, p.id
FROM qipai_roles r
INNER JOIN qipai_permissions p
ON (r.code = 'STAFF'
AND p.code IN ('product.catalog.read', 'inventory.read'))
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN (
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'
))
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081107', 'm09d1_product_inventory_foundation');
@@ -0,0 +1,203 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'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'
)
ORDER BY table_name;
SELECT table_name, column_name, data_type, is_nullable, generation_expression
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND (
(table_name = 'qipai_product_categories'
AND column_name IN ('store_id', 'version', 'deleted_at', 'active_code_key'))
OR (table_name = 'qipai_products'
AND column_name IN (
'images_json', 'delivery_enabled', 'storage_enabled',
'version', 'deleted_at', 'active_code_key'
))
OR (table_name = 'qipai_schema_migrations'
AND column_name = 'checksum')
OR (table_name = 'qipai_product_skus'
AND column_name IN (
'sale_price_cents', 'market_price_cents', 'cost_price_cents',
'default_inventory_policy', 'version', 'deleted_at',
'active_code_key', 'active_barcode_key'
))
OR (table_name = 'qipai_product_store_listings'
AND column_name IN (
'category_id', 'fulfillment_mode', 'version', 'deleted_at', 'active_product_id'
))
OR (table_name = 'qipai_product_store_settings'
AND column_name IN (
'sales_status', 'manual_paused_at', 'manual_paused_until',
'manual_pause_reason', 'version'
))
OR (table_name = 'qipai_product_store_hours'
AND column_name IN (
'weekday', 'slot_no', 'open_minute', 'close_minute', 'crosses_midnight',
'version', 'deleted_at', 'active_slot_no'
))
OR (table_name = 'qipai_product_inventory'
AND column_name IN (
'policy_type', 'available_quantity', 'locked_quantity', 'loss_quantity',
'low_stock_threshold', 'version', 'updated_by'
))
OR (table_name = 'qipai_product_inventory_requests'
AND column_name IN (
'request_id', 'operation', 'business_type', 'business_id',
'fingerprint', 'item_count', 'operator_id', 'trace_id'
))
OR (table_name = 'qipai_product_inventory_ledger'
AND column_name IN (
'request_id', 'business_type', 'business_id', 'operation',
'available_delta', 'locked_delta', 'loss_delta',
'available_after', 'locked_after', 'loss_after', 'version_after'
))
)
ORDER BY table_name, ordinal_position;
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN (
'uq_qipai_stores_tenant_id',
'uq_qipai_users_tenant_id',
'uq_qipai_product_category_scope_id',
'uq_qipai_product_category_active_code',
'uq_qipai_product_tenant_id',
'uq_qipai_product_active_code',
'uq_qipai_product_sku_tenant_id',
'uq_qipai_product_sku_active_code',
'uq_qipai_product_sku_active_barcode',
'uq_qipai_product_store_listing_active',
'uq_qipai_product_store_setting',
'uq_qipai_product_store_setting_scope_id',
'uq_qipai_product_store_hour_active',
'uq_qipai_product_inventory_scope',
'uq_qipai_product_inventory_scope_id',
'uq_qipai_product_inventory_request',
'uq_qipai_product_inventory_ledger_request',
'uq_qipai_product_inventory_ledger_version'
)
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
SELECT constraint_name
FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_type = 'CHECK'
AND constraint_name IN (
'chk_qipai_product_category_status',
'chk_qipai_product_category_version',
'chk_qipai_product_status',
'chk_qipai_product_capabilities',
'chk_qipai_product_version',
'chk_qipai_product_sku_status',
'chk_qipai_product_sku_inventory_policy',
'chk_qipai_product_sku_version',
'chk_qipai_product_sku_amounts',
'chk_qipai_product_listing_status',
'chk_qipai_product_listing_fulfillment',
'chk_qipai_product_listing_window',
'chk_qipai_product_listing_version',
'chk_qipai_product_store_setting_status',
'chk_qipai_product_store_setting_pause',
'chk_qipai_product_store_setting_version',
'chk_qipai_product_store_hour_weekday',
'chk_qipai_product_store_hour_slot',
'chk_qipai_product_store_hour_window',
'chk_qipai_product_store_hour_version',
'chk_qipai_product_inventory_policy',
'chk_qipai_product_inventory_quantity',
'chk_qipai_product_inventory_version',
'chk_qipai_product_inventory_request_operation',
'chk_qipai_product_inventory_request_items',
'chk_qipai_product_inventory_ledger_operation',
'chk_qipai_product_inventory_ledger_delta',
'chk_qipai_product_inventory_ledger_after'
)
ORDER BY constraint_name;
SELECT constraint_name
FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_type = 'FOREIGN KEY'
AND constraint_name IN (
'fk_qipai_product_category_store',
'fk_qipai_product_category_parent',
'fk_qipai_product_listing_store',
'fk_qipai_product_listing_product',
'fk_qipai_product_listing_category',
'fk_qipai_product_store_setting_store',
'fk_qipai_product_store_hour_setting',
'fk_qipai_product_store_hour_store',
'fk_qipai_product_inventory_store',
'fk_qipai_product_inventory_sku',
'fk_qipai_product_inventory_request_store',
'fk_qipai_product_inventory_ledger_inventory',
'fk_qipai_product_inventory_ledger_store',
'fk_qipai_product_inventory_ledger_sku',
'fk_qipai_product_inventory_ledger_request'
)
ORDER BY constraint_name;
SELECT trigger_name, event_manipulation
FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN (
'qipai_product_inventory_ledger_no_update',
'qipai_product_inventory_ledger_no_delete'
)
ORDER BY trigger_name;
SELECT code
FROM qipai_permissions
WHERE code IN (
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'
)
ORDER BY code;
SELECT COUNT(*) AS managed_role_count
FROM qipai_roles r
WHERE r.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND r.status = 'ACTIVE'
AND r.deleted_at IS NULL
HAVING COUNT(*) = (
SELECT COUNT(*)
FROM (
SELECT granted_role.tenant_id, granted_role.id, granted_role.code
FROM qipai_roles granted_role
INNER JOIN qipai_role_permissions rp
ON rp.tenant_id = granted_role.tenant_id AND rp.role_id = granted_role.id
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE granted_role.code IN ('STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND granted_role.status = 'ACTIVE'
AND granted_role.deleted_at IS NULL
AND p.code IN (
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust'
)
GROUP BY granted_role.tenant_id, granted_role.id, granted_role.code
HAVING COUNT(DISTINCT p.code) = CASE
WHEN granted_role.code = 'STAFF' THEN 2
ELSE 4
END
) fully_granted_product_roles
);
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081107'
AND name = 'm09d1_product_inventory_foundation'
AND checksum REGEXP '^[0-9a-f]{64}$';
@@ -0,0 +1,34 @@
# 2026-08-11 M09-D1 商品目录与库存 API
## 商品目录
- `GET/POST /admin-api/stores/:storeId/product-categories`
- `PUT/DELETE /admin-api/stores/:storeId/product-categories/:categoryId`
- `GET/POST /admin-api/products`
- `PUT/DELETE /admin-api/products/:productId`
- `GET/POST /admin-api/products/:productId/skus`
- `PUT/DELETE /admin-api/products/:productId/skus/:skuId`
- `GET /admin-api/stores/:storeId/product-listings`
- `PUT/DELETE /admin-api/stores/:storeId/product-listings/:productId`
- `GET/PUT /admin-api/stores/:storeId/product-sales-settings`
分类、门店上架和销售设置的只读接口同时提供 `/app-api/management/stores/:storeId/...` 管理端别名。写接口统一由后台 API 承载;更新/归档必须携带 `expectedVersion`,服务端以版本 CAS 返回明确冲突。
## 库存管理
- `GET /admin-api/inventory/stocks`
- `GET /admin-api/inventory/stocks/:inventoryId/ledger`
- `PUT /admin-api/inventory/stocks/:skuId/policy`
- `POST /admin-api/inventory/stocks/:skuId/inbound`
- `POST /admin-api/inventory/stocks/:skuId/adjust`
- `POST /admin-api/inventory/stocks/:skuId/stocktake`
- `POST /admin-api/inventory/stocks/:skuId/loss`
库存写请求必填门店、requestId 和原因;需要绝对状态或并发保护的操作携带 `expectedVersion`。批量锁定、释放、扣减作为领域服务供 M09-D2 商品订单事务调用,不开放可由客户端伪造业务归属的裸管理接口。
## 权限与错误边界
- 商品读取/写入使用 `product.catalog.read` / `product.catalog.write`,库存读取/调整使用 `inventory.read` / `inventory.adjust`
- STAFF 默认只读,STORE_ADMIN 只能访问授权门店;TENANT_ADMIN 与 PLATFORM_ADMIN 仍分别受租户和显式平台范围约束。全局商品/SKU 目录读取不向普通 STAFF 开放。
- 所有仓储和服务查询重复校验 tenantId/storeId,不依赖菜单隐藏或路由参数实现隔离。
- 版本冲突、幂等指纹冲突、库存不足、锁定归属不匹配返回冲突类错误;越权、未找到和参数越界分别保持 403、404 和 400 语义。
+5 -4
View File
@@ -1,8 +1,8 @@
# 配置总表 # 配置总表
> 当前总纲:V5.6.md > 当前总纲:V5.7.md
> 最后更新:2026-08-10 > 最后更新:2026-08-11
> 维护规则:任何配置路径、账号、密码、域名、端口或加载顺序变化,必须与 `README.md``V5.6.md` 和实际配置在同一提交中更新。 > 维护规则:任何配置路径、账号、密码、域名、端口或加载顺序变化,必须与 `README.md``V5.7.md` 和实际配置在同一提交中更新。
## 当前已知凭据 ## 当前已知凭据
@@ -109,7 +109,7 @@ MYSQL_DATABASE=<以项目实际数据库名为准>
## 文件位置 ## 文件位置
完整位置表见根目录 `README.md` 的“配置文件位置”章节,以及 `V5.6.md` 继承的“0.6 README、配置文件与明文凭据同步规则”。 完整位置表见根目录 `README.md` 的“配置文件位置”章节,以及 `V5.7.md` 继承的“0.6 README、配置文件与明文凭据同步规则”。
## 变更记录 ## 变更记录
@@ -120,4 +120,5 @@ MYSQL_DATABASE=<以项目实际数据库名为准>
- 2026-06-22M05-C 增加 `QIPAI_THIRD_PARTY_CREDENTIALS`、人工/Mock/API 模式、回调 HMAC 和券码哈希规则。 - 2026-06-22M05-C 增加 `QIPAI_THIRD_PARTY_CREDENTIALS`、人工/Mock/API 模式、回调 HMAC 和券码哈希规则。
- 2026-06-22M05-D 增加门店收款账户、`profitShareReceivers` 环境映射、分账授权状态和非生产 Mock 门禁。 - 2026-06-22M05-D 增加门店收款账户、`profitShareReceivers` 环境映射、分账授权状态和非生产 Mock 门禁。
- 2026-08-10:总纲升级为 V5.6;同步最新源码接管、M09-A-RECOVERY 完成证据和 M06-C-R1 固定执行游标,既有凭据与配置位置不变。 - 2026-08-10:总纲升级为 V5.6;同步最新源码接管、M09-A-RECOVERY 完成证据和 M06-C-R1 固定执行游标,既有凭据与配置位置不变。
- 2026-08-11:总纲升级为 V5.7;增加中文 Git 提交门禁并将当前游标推进至 M09-D2,既有凭据与配置位置不变。
- 2026-08-10M06-C-R1 增加门锁烟测 subtype 变量,确保实物 `CtrlDevice` 始终携带 14/15;未增加任何仓库内明文凭据。 - 2026-08-10M06-C-R1 增加门锁烟测 subtype 变量,确保实物 `CtrlDevice` 始终携带 14/15;未增加任何仓库内明文凭据。
+12 -11
View File
@@ -1,21 +1,22 @@
# 当前开发成果基线 # 当前开发成果基线
> V5.0 首次核验日期:2026-06-16 > V5.0 首次核验日期:2026-06-16
> audited_commit: `1f10715` > audited_commit: `2de7961`
> audited_at: `2026-08-11T02:08:40+08:00` > audited_worktree: M09-D1 当前工作树(待本次中文工程提交)
> next_engineering_target: 按 V5.6 固定队列执行 M09-D1,建立分类、商品、SKU、门店售卖、营业时段与不可变库存流水底座 > audited_at: `2026-08-11T03:41:30+08:00`
> next_engineering_target: 按 V5.7 固定队列执行 M09-D2,建立商品订单与库存锁定、扣减、释放、取消回补闭环
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
| 范围 | 当前状态 | 证据 | 结论 | | 范围 | 当前状态 | 证据 | 结论 |
|---|---|---|---| |---|---|---|---|
| 总纲版本 | V5.6 已成为当前权威总纲,V5.4 及更早版本仅作历史备份 | 根目录 `V5.6.md`第 29 章固定队列 | 必须按 V5.6 游标开发,不得回退到历史队列 | | 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md`增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 |
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 | | Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 |
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 APIM09-A/B/C 已完成保洁事务并发、规则快照与版本验收、协作成员份额统计/结算唯一性、负向冲正、出款 CAS、商户隔离回调和人工付款 | `backend/src/**``database/migrations/**``backend/tests/**`、工程提交 `9b2f055``bc7cb8f``1f10715` | M01-M04、M07 已完成;M05/M06/M08 可独立工程内容完成但外部联调保持 PARTIAL;当前进入 M09-D1 | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 APIM09-A/B/C 已完成保洁收口,M09-D1 已完成商品分类、商品、SKU、门店售卖/营业时段、库存策略、可用/锁定/损耗库存、幂等请求与不可变流水 | `backend/src/products/**``backend/src/inventory/**``backend/src/routes/{products,inventory}.ts``database/migrations/2026081107_m09d1_*``backend/tests/product-*.test.mjs``backend/tests/inventory-*.test.mjs``backend/tests/mysql-migration-roundtrip.test.mjs` | M09-D1 当前工作树的 build、全测、静态门禁和真实 MySQL 门禁通过,游标进入 M09-D2;提交 SHA 待本次中文工程提交取得 |
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;12 个业务入口按路由懒加载;保洁工作台覆盖规则、任务、版本验收、同源结算导出、冲正历史、状态事件和人工付款 | `admin/src/router.ts``admin/src/components/CleaningWorkspace.vue``admin/src/components/CleaningSettlementsPanel.vue``admin/src/components/CleaningStatisticsPanel.vue`、工程提交 `3faf868``bc7cb8f``1f10715` | 最大 JS 分块 218,014 BM09-C 全量门禁通过,进入 M09-D1 | | 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;12 个业务入口按路由懒加载;保洁工作台覆盖规则、任务、版本验收、同源结算导出、冲正历史、状态事件和人工付款;商品/库存菜单由服务端按 capability 返回 | `admin/src/router.ts``admin/src/components/CleaningWorkspace.vue``backend/src/routes/admin-auth.ts` | M09-D1 管理端 build/test 通过;商品库存专用运营页面归入后续业务切片,当前进入 M09-D2 |
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路、保洁任务端照片上传/补做主体链路,以及管理员门店/房态/订单/代下单/验券/会员/员工/保洁/设备/经营统计 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/pages/cleaner/*``miniapp/pages/manager/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs``scripts/check-miniapp-m08-c.mjs` | M08-A/B/C/D 可独立工程内容完成,执行游标进入 M09-A;真实团购、微信和硬件现场验收保持外部阻塞,M08 维持 PARTIAL | | 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路、保洁任务端照片上传/补做主体链路,以及管理员门店/房态/订单/代下单/验券/会员/员工/保洁/设备/经营统计 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/pages/cleaner/*``miniapp/pages/manager/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs``scripts/check-miniapp-m08-c.mjs` | M08-A/B/C/D 可独立工程内容完成,执行游标进入 M09-A;真实团购、微信和硬件现场验收保持外部阻塞,M08 维持 PARTIAL |
| 数据库迁移 | 已形成 M01-B 至 M09-C 连续 up/down/verify 计划、最小脱敏 seed、旧库 fixture、执行 CLI、验证结果门禁和 MySQL 8 集成测试;结算冲正、状态事件、活动成员份额唯一键和稳定出款请求号已纳入往返 | `database/migrations/**``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 160 up、132 verify、154 down;历史取消回填、并发单赢家、冲正重生成与协作任务最终结算实测通过 | | 数据库迁移 | 已形成 M01-B 至 M09-D1 连续 up/down/verify 计划;M09-D1 新增 9 张商品/库存表和不可变流水双触发器,迁移 runner 按增量 marker/checksum 执行,并可在 interrupted up/down DDL 后原地重试;同库迁移全流程由专用连接和数据库命名锁串行化 | `database/migrations/2026081107_m09d1_product_inventory_foundation.{up,down,verify}.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 179 up、141 verify、171 down 的 up/verify/down/up/verify;增量跳过、崩溃恢复、双连接迁移互斥、触发器前置、并发单赢家、幂等、负库存拒绝、流水不可变和 cleanup probe 通过 |
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 | | M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 |
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 | | 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
| 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*``docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 | | 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*``docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 |
@@ -25,7 +26,7 @@
## 状态校准 ## 状态校准
- `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。 - `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。
- M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`M09-A/B/C、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前按 V5.6 执行 M09-D1M10 尚未进入。 - M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`M09-A/B/C/D1、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前按 V5.7 执行 M09-D2M10 尚未进入。
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。 - 任何“目录存在”只能证明骨架存在,不能证明模块完成。
## Git 历史敏感信息核验 ## Git 历史敏感信息核验
@@ -36,6 +37,6 @@
## 下一步 ## 下一步
1. 执行 M09-D1:建立租户隔离的分类、商品、SKU、门店售卖、营业时段和库存策略底表 1. 执行 M09-D2:建立商品订单与订单项金额、商品、SKU、规格、履约方式等服务端快照,明确与支付订单分离的状态机
2. 建立可用/锁定库存与不可变库存流水,使用版本号和事务条件更新禁止负库存,并补齐金额/数量边界 2. 复用 M09-D1 库存服务,在下单/支付/取消/超时链路中按稳定业务号完成锁定、扣减、释放和取消回补,保证重放幂等与并发单赢家
3. 补 migration down/verify、真实 MySQL 并发库存测试和 API/管理端最小闭环;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。 3. 补商品订单 API、权限、真实 MySQL 事务/崩溃恢复和订单金额服务端复算;M06-G 真实硬件联调继续等待外部条件,不阻断内部主队列。
@@ -0,0 +1,14 @@
# 2026-08-11 M09-D1 商品目录与库存底座
- 版本:`2026081107_m09d1_product_inventory_foundation`
- 新增 9 张表:`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`
- 分类按租户/门店隔离,商品与 SKU 属于租户目录,门店上架连接同租户商品和本门店分类;复合外键阻止跨租户、跨门店引用,软删除生成列使活动代码/条码唯一且归档后可复用。
- 门店销售设置保存总开关、人工暂停期限/原因和版本;营业时段按星期与 slotNo 唯一,显式记录跨日,数据库检查分钟范围和跨日一致性。
- 库存保存 `TRACKED` / `UNLIMITED` 策略、低库存阈值、可用/锁定/损耗数量及版本;CHECK 和条件更新共同限制数量为 `0..1,000,000,000`,不允许负库存。
- `qipai_product_inventory_requests` 在租户/门店/requestId 维度唯一并保存规范化请求指纹,保证跨 SKU 批量请求只处理一次;大小写敏感排序规则确保 opaque requestId 与业务号不被错误折叠。
- `qipai_product_inventory_ledger` 保存操作类型、三类数量增量、变动后快照、业务归属、请求、操作人和 `version_after`;请求与版本唯一键防重复,`qipai_product_inventory_ledger_no_update/no_delete` 双触发器禁止修改或删除流水。
- 迁移 runner 按 `qipai_schema_migrations` marker 与 checksum 增量执行 pending 文件;完整迁移、M09-C 旧库直升、interrupted up DDL 和 interrupted down DDL 均可原地重跑,checksum/name 不一致则拒绝继续。
- up/verify/down 使用专用连接持有按 `DATABASE()` 派生的 MySQL 命名锁,锁等待超时会明确失败;并发发布必须串行到读取 marker/checksum 之前,禁止不同构建交错执行 DDL。
- 触发器 DDL 是生产迁移前置:启用 binlog 时必须配置 `log_bin_trust_function_creators=1`,并使用具备所需权限的独立迁移身份。runner 在任何触发器 DDL 和建表执行前检查,不满足时拒绝迁移,禁止临时删触发器过门禁。
- down 先删除双触发器,再按依赖逆序删除 9 张表、权限和迁移 marker;verify 校验表、生成列、索引、CHECK、复合外键、触发器及角色权限。
- MySQL 8.4.10 已完成 179 up、141 verify、171 down 的 `up → verify → down → up → verify`,双连接迁移互斥、增量/崩溃重试、cleanup probe、并发库存、幂等冲突、流水对账和不可变约束均通过。
@@ -0,0 +1,40 @@
# 2026-08-11 M09-D1 商品目录与库存底座
## 工程结果
- 工程状态:当前工作树已完成,待以 `feat(M09-D1): 完成商品目录与库存流水底座` 中文标题提交并取得 SHA。
- 新增分类、商品、SKU、门店上架、门店销售设置、每周最多 8 段且支持跨日的营业时段;分类/商品/SKU 支持软删除后的代码或条码复用,写操作统一使用 `expectedVersion` 乐观锁。
- 新增 `TRACKED` / `UNLIMITED` 库存策略、可用/锁定/损耗三类数量,以及入库、调整、盘点、损耗、批量锁定、释放和扣减服务;批量操作同事务完成,任何一个 SKU 失败均整体回滚。
- 库存请求以租户、门店和大小写敏感 requestId 建立全局幂等记录;请求指纹不一致明确拒绝,锁定归属按业务类型和业务号核对,重复释放、跨订单释放和超额扣减不会改动库存。
- 每次库存变动写入带 `version_after` 的不可变流水,数据库双触发器拒绝 UPDATE/DELETE;流水增量与库存最终值可对账,版本唯一键防止遗漏或重复版本。
- 商品/库存路由接入 Fastify,补 `product.catalog.read/write``inventory.read/adjust` capability、角色默认授权、服务端菜单和租户/门店范围校验;STAFF 只读、STORE_ADMIN 授权门店、TENANT_ADMIN 与 PLATFORM_ADMIN 边界均有测试。
- 迁移 runner 改为按单个迁移 marker/checksum 只执行 pending 文件,旧库可直接增量应用 M09-D1interrupted up/down DDL 后可在同一库原地重试,不需要清空或重建。
- up/verify/down 在同一专用连接上持有按数据库名派生的 MySQL 命名锁,两个发布进程会串行执行;不同构建必须等前一进程完成后再校验 marker/checksum,避免交错 DDL 形成混合 schema。
- 迁移包含触发器时,runner 在执行任何触发器 DDL 前检查生产前置;binlog 开启时必须启用 `log_bin_trust_function_creators=1` 并使用独立迁移身份,否则在建表前拒绝执行。
## 数据库结果
`2026081107_m09d1_product_inventory_foundation` 新增 9 张表:
1. `qipai_product_categories`
2. `qipai_products`
3. `qipai_product_skus`
4. `qipai_product_store_listings`
5. `qipai_product_store_settings`
6. `qipai_product_store_hours`
7. `qipai_product_inventory`
8. `qipai_product_inventory_requests`
9. `qipai_product_inventory_ledger`
## 验证证据
- `node scripts/check-m09-d1-product-inventory.mjs`:PASS,静态门禁确认迁移、路由、RBAC、服务、真实 MySQL 断言和 Windows/WSL 门禁均已接线。
- 后端 `npm run build`、后端全量 `npm test`PASS。
- 管理端 `npm run build``npm test`PASS。
- WSL 原生临时副本 + MySQL 8.4.10:完成 `up → verify → down → up → verify`179 条 up、141 条 verify、171 条 down;双连接迁移锁、interrupted up/down DDL 原地重跑、增量 marker/checksum、触发器生产前置和 cleanup probe 均 PASS。
- 真实 MySQL 业务用例覆盖门店唯一性、跨日时段、商品/SKU 版本冲突、并发锁定单赢家、重复 requestId 重放与冲突、批量原子回滚、锁定/释放/扣减、损耗/盘点、负库存拒绝、流水对账与不可变触发器。
- Windows 汇总门禁在全部 M09-D1 与 V5.7 文件显式暂存后执行最终复跑;不得将提交前的未跟踪完整性提示记为功能失败。
## 阶段结论
`M09-D1=DONE`。唯一执行游标进入 `M09-D2`,下一阶段建立商品订单、订单项快照、支付衔接与库存占用生命周期。生产触发器前置属于部署强制条件,不是可绕过的外部阻塞。
+4
View File
@@ -48,6 +48,8 @@
> 2026-08-11 M09-C:工程提交 `1f10715``CLN-003``CLN-004` 统一协作成员份额的待结算/已结算口径,补活动份额数据库唯一键、并发生成单赢家、成员奖励冻结、不可变负向冲正与状态事件;微信转账在出站前 CAS 为处理中,回调按商户隔离且成功重放幂等,并支持失败后的人工付款。列表/API/CSV 共用查询,所有保洁 CSV 金额输出整数分。Windows 全量与 WSL MySQL 8.4.10 的 160 up、132 verify、154 down 往返通过,游标进入 M09-D1。 > 2026-08-11 M09-C:工程提交 `1f10715``CLN-003``CLN-004` 统一协作成员份额的待结算/已结算口径,补活动份额数据库唯一键、并发生成单赢家、成员奖励冻结、不可变负向冲正与状态事件;微信转账在出站前 CAS 为处理中,回调按商户隔离且成功重放幂等,并支持失败后的人工付款。列表/API/CSV 共用查询,所有保洁 CSV 金额输出整数分。Windows 全量与 WSL MySQL 8.4.10 的 160 up、132 verify、154 down 往返通过,游标进入 M09-D1。
> 2026-08-11 M09-D1:当前工作树为 `PRD-001``INV-001` 建立租户/门店隔离的商品目录和库存底座;9 张表覆盖分类、商品、SKU、门店上架、销售设置、跨日时段、库存、幂等请求和不可变流水。增量迁移可跳过历史 markerinterrupted up/down DDL 可原地重试,同库发布由数据库命名锁串行化;触发器生产前置、API/RBAC、并发库存、幂等冲突、负库存拒绝和流水 UPDATE/DELETE 拒绝已通过。WSL MySQL 8.4.10 完成 179 up、141 verify、171 down 往返及 cleanup probe,游标进入 M09-D2;结果随本次中文工程提交固化。
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 | | ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| ENV-001 | 固定 Windows 工作区 | M00-A/M00-B | DONE | 远端 HEAD | 已确认 `D:\qipai`Git 根、main 分支、固定 origin 和 push 校验通过。 | - | 后续每模块继续复检。 | | ENV-001 | 固定 Windows 工作区 | M00-A/M00-B | DONE | 远端 HEAD | 已确认 `D:\qipai`Git 根、main 分支、固定 origin 和 push 校验通过。 | - | 后续每模块继续复检。 |
@@ -73,6 +75,8 @@
| CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 | | CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 |
| CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 | | CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 |
| CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 | | CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 |
| PRD-001 | 商品目录与门店售卖 | M09-D1 | DONE | M09-D1 当前工作树(待本次中文工程提交) | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;代码/条码软删除后可复用,写操作用 expectedVersion CAS,跨日营业判断和租户/门店/角色越权拒绝有路由、仓储和真实 MySQL 证据。 | - | M09-D2 复用目录快照创建商品订单,不重建第二套商品主数据。 |
| INV-001 | 库存策略与不可变流水 | M09-D1 | DONE | M09-D1 当前工作树(待本次中文工程提交) | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;请求指纹全局幂等、复合外键、数量边界、版本 CAS 和事务保证负库存拒绝与批量原子性,流水版本唯一且双触发器禁止 UPDATE/DELETE。MySQL 8.4.10 覆盖并发单赢家、重放/冲突、锁定归属、对账与 interrupted up/down 恢复。 | - | M09-D2 把订单状态机接入库存锁定、扣减、释放和取消回补。 |
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 | | STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 | | BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 |
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 | | TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
+7 -6
View File
@@ -3,15 +3,16 @@
```yaml ```yaml
execution_cursor: execution_cursor:
current_module: M09 current_module: M09
current_stage: M09-D1 current_stage: M09-D2
stage_status: DOING stage_status: TODO
last_completed_stage: M09-C last_completed_stage: M09-D1
next_stage: M09-D2 next_stage: M09-D3
last_engineering_commit: `1f10715` last_engineering_commit: `1f10715`
pending_engineering_commit: "M09-D1 当前工作树(待本次中文工程提交)"
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。" blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-08-11T02:08:40+08:00 updated_at: 2026-08-11T03:41:30+08:00
``` ```
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 | | 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
@@ -25,6 +26,6 @@ execution_cursor:
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `da5751f` | docs/devlogs/2026-06-24-M06-E-智慧插座.md | M06-C/E-R1 已按原件校正控制箱、Sub-1G 与智慧插座 Wire/事件/脱敏;M06-R 已通过 Windows 后端全量、MQTT fixture、四项静态检查的中文/空格路径回归及 WSL MySQL 8.4.10 完整往返。M06-G 仍缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 | | M06 设备、MQTT 与真实硬件联动 | PARTIAL | `da5751f` | docs/devlogs/2026-06-24-M06-E-智慧插座.md | M06-C/E-R1 已按原件校正控制箱、Sub-1G 与智慧插座 Wire/事件/脱敏;M06-R 已通过 Windows 后端全量、MQTT fixture、四项静态检查的中文/空格路径回归及 WSL MySQL 8.4.10 完整往返。M06-G 仍缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 | | M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
| M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 | | M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 |
| M09 保洁任务、结算、商品和库存 | DOING | `1f10715` | docs/devlogs/2026-08-11-M09-C-统计与结算收口.md | M09-A/B/C 已完成任务事务并发、规则与验收、协作份额统计与结算唯一性、负向冲正、出款竞态与同源导出;当前进入 M09-D1,建设商品/SKU/库存底座。 | | M09 保洁任务、结算、商品和库存 | DOING | M09-D1 当前工作树(待本次中文工程提交) | docs/devlogs/2026-08-11-M09-D1-商品目录与库存底座.md | M09-A/B/C 已完成保洁收口;M09-D1 已完成 9 张表、商品/SKU/门店售卖与跨日时段、库存策略、幂等请求、不可变流水、API/RBAC、增量/崩溃重试迁移和真实 MySQL 门禁。当前进入 M09-D2,建设商品订单与库存占用生命周期。 |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const root = process.cwd();
const read = (path) => readFileSync(join(root, path), 'utf8');
for (const suffix of ['up', 'down', 'verify']) {
assert.ok(
existsSync(join(root,
`database/migrations/2026081107_m09d1_product_inventory_foundation.${suffix}.sql`)),
`M09-D1 ${suffix} migration is missing`
);
}
const migration = read('database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql');
for (const pattern of [
'qipai_product_categories',
'qipai_products',
'qipai_product_skus',
'qipai_product_store_listings',
'qipai_product_store_hours',
'qipai_product_inventory',
'qipai_product_inventory_requests',
'qipai_product_inventory_ledger',
'delivery_enabled',
'storage_enabled',
'crosses_midnight',
'manual_paused_at',
'loss_quantity',
'loss_delta',
'uq_qipai_product_inventory_request',
'ADD COLUMN checksum',
'uq_qipai_product_inventory_ledger_request',
'uq_qipai_product_inventory_ledger_version',
'PRODUCT_INVENTORY_LEDGER_IMMUTABLE',
'product.catalog.read',
'inventory.adjust'
]) assert.ok(migration.includes(pattern), `migration is missing ${pattern}`);
assert.match(migration, /sale_price_cents INT UNSIGNED/);
assert.match(migration, /available_quantity BIGINT UNSIGNED/);
assert.match(migration, /operation IN \([\s\S]*'INBOUND'[\s\S]*'STOCKTAKE'[\s\S]*'LOSS'/);
const catalog = read('backend/src/products/product-catalog-repository.ts');
for (const pattern of [
'ProductCatalogRepository',
'expectedVersion',
'PRODUCT_CATEGORY_VERSION_CONFLICT',
'PRODUCT_VERSION_CONFLICT',
'PRODUCT_SKU_VERSION_CONFLICT',
'PRODUCT_LISTING_VERSION_CONFLICT',
'PRODUCT_STORE_SETTINGS_VERSION_CONFLICT',
'crossesMidnight',
'manualPaused'
]) assert.ok(catalog.includes(pattern), `product catalog is missing ${pattern}`);
assert.match(catalog, /INSERT INTO qipai_audit_logs/);
const inventory = read('backend/src/inventory/inventory-service.ts');
for (const pattern of [
'InventoryService',
'lockMany',
'releaseMany',
'deductMany',
'INVENTORY_INSUFFICIENT_AVAILABLE',
'INVENTORY_INSUFFICIENT_LOCKED',
'INVENTORY_IDEMPOTENCY_CONFLICT',
'FOR UPDATE',
'qipai_product_inventory_ledger',
'qipai_audit_logs'
]) assert.ok(inventory.includes(pattern), `inventory service is missing ${pattern}`);
const productRoutes = read('backend/src/routes/products.ts');
const inventoryRoutes = read('backend/src/routes/inventory.ts');
assert.match(productRoutes, /product-categories/);
assert.match(productRoutes, /\/admin-api\/products/);
assert.match(productRoutes, /product-sales-settings/);
assert.match(inventoryRoutes, /inventory\/stocks/);
assert.match(inventoryRoutes, /\/adjust/);
const mysqlTest = read('backend/tests/mysql-migration-roundtrip.test.mjs');
for (const pattern of [
'assertProductInventoryFoundation',
'INVENTORY_INSUFFICIENT_AVAILABLE',
'PRODUCT_INVENTORY_LEDGER_IMMUTABLE',
'concurrent inventory lock',
'cross-midnight product hours'
]) assert.ok(mysqlTest.includes(pattern), `live MySQL test is missing ${pattern}`);
console.log('PASS: M09-D1 catalog, store-hours, stock concurrency and immutable-ledger gates are present.');
+11
View File
@@ -21,6 +21,10 @@ $requiredFiles = @(
"backend/src/routes/health.ts", "backend/src/routes/health.ts",
"backend/src/routes/platform-bootstrap.ts", "backend/src/routes/platform-bootstrap.ts",
"backend/src/routes/platform-management.ts", "backend/src/routes/platform-management.ts",
"backend/src/routes/products.ts",
"backend/src/routes/inventory.ts",
"backend/src/products/product-catalog-repository.ts",
"backend/src/inventory/inventory-service.ts",
"backend/src/routes/auth.ts", "backend/src/routes/auth.ts",
"backend/src/tenancy/platform-config-repository.ts", "backend/src/tenancy/platform-config-repository.ts",
"backend/src/tenancy/platform-admin-repository.ts", "backend/src/tenancy/platform-admin-repository.ts",
@@ -36,6 +40,10 @@ $requiredFiles = @(
"backend/tests/platform-management.test.mjs", "backend/tests/platform-management.test.mjs",
"backend/tests/auth.test.mjs", "backend/tests/auth.test.mjs",
"backend/tests/rbac.test.mjs", "backend/tests/rbac.test.mjs",
"backend/tests/product-catalog.test.mjs",
"backend/tests/product-route.test.mjs",
"backend/tests/inventory-service.test.mjs",
"backend/tests/inventory-route.test.mjs",
"scripts/dev/wsl/mysql-migration-roundtrip.sh", "scripts/dev/wsl/mysql-migration-roundtrip.sh",
"database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.up.sql",
"database/migrations/2026061601_m01b_core_schema.down.sql", "database/migrations/2026061601_m01b_core_schema.down.sql",
@@ -64,6 +72,9 @@ $requiredFiles = @(
"database/migrations/2026062015_m05a_payment_domain.up.sql", "database/migrations/2026062015_m05a_payment_domain.up.sql",
"database/migrations/2026062015_m05a_payment_domain.down.sql", "database/migrations/2026062015_m05a_payment_domain.down.sql",
"database/migrations/2026062015_m05a_payment_domain.verify.sql", "database/migrations/2026062015_m05a_payment_domain.verify.sql",
"database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql",
"database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql",
"database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql",
"database/seeds/2026061601_m01b_minimal_seed.sql", "database/seeds/2026061601_m01b_minimal_seed.sql",
"deploy/pm2/ecosystem.config.cjs" "deploy/pm2/ecosystem.config.cjs"
) )
+9 -5
View File
@@ -3,7 +3,7 @@ $ErrorActionPreference = "Stop"
$readmePath = "README.md" $readmePath = "README.md"
$configPath = "docs/configuration.md" $configPath = "docs/configuration.md"
$baselinePath = "docs/current-baseline.md" $baselinePath = "docs/current-baseline.md"
$expectedSpec = "V5.6.md" $expectedSpec = "V5.7.md"
$expectedOrigin = "ssh://git@git.txyundm.cn:2222/panda/qipai.git" $expectedOrigin = "ssh://git@git.txyundm.cn:2222/panda/qipai.git"
foreach ($path in @($readmePath, $configPath, $baselinePath, $expectedSpec)) { foreach ($path in @($readmePath, $configPath, $baselinePath, $expectedSpec)) {
@@ -23,9 +23,11 @@ $config = Get-Content -Raw -Encoding UTF8 $configPath
$spec = Get-Content -Raw -Encoding UTF8 $expectedSpec $spec = Get-Content -Raw -Encoding UTF8 $expectedSpec
$baseline = Get-Content -Raw -Encoding UTF8 $baselinePath $baseline = Get-Content -Raw -Encoding UTF8 $baselinePath
$recentEngineeringLabel = [regex]::Unescape("\u6700\u8fd1\u5de5\u7a0b\u63d0\u4ea4") $recentEngineeringCommitLabel = [regex]::Unescape("\u6700\u8fd1\u5de5\u7a0b\u63d0\u4ea4")
$recentEngineeringResultLabel = [regex]::Unescape("\u6700\u8fd1\u5de5\u7a0b\u7ed3\u679c")
$nextEngineeringLabel = [regex]::Unescape("\u4e0b\u4e00\u5de5\u7a0b\u76ee\u6807") $nextEngineeringLabel = [regex]::Unescape("\u4e0b\u4e00\u5de5\u7a0b\u76ee\u6807")
$pendingWord = [regex]::Unescape("\u5f85") $pendingWord = [regex]::Unescape("\u5f85")
$currentEngineeringPhrase = [regex]::Unescape("\u968f\u672c\u6b21\u4e2d\u6587\u5de5\u7a0b\u63d0\u4ea4\u56fa\u5316")
$requiredSharedValues = @( $requiredSharedValues = @(
$expectedSpec, $expectedSpec,
@@ -59,7 +61,9 @@ foreach ($value in @($expectedSpec, $expectedOrigin, "admin123", "root123", "101
} }
$readmeLines = Get-Content -Encoding UTF8 $readmePath $readmeLines = Get-Content -Encoding UTF8 $readmePath
$recentLine = $readmeLines | Where-Object { $_.Contains($recentEngineeringLabel) } | Select-Object -First 1 $recentLine = $readmeLines | Where-Object {
$_.Contains($recentEngineeringCommitLabel) -or $_.Contains($recentEngineeringResultLabel)
} | Select-Object -Last 1
$nextLine = $readmeLines | Where-Object { $_.Contains($nextEngineeringLabel) } | Select-Object -First 1 $nextLine = $readmeLines | Where-Object { $_.Contains($nextEngineeringLabel) } | Select-Object -First 1
if (-not $recentLine) { if (-not $recentLine) {
throw "README missing recent engineering commit line" throw "README missing recent engineering commit line"
@@ -87,8 +91,8 @@ if (-not $latestEngineeringCommit) {
throw "Could not find a recent engineering commit" throw "Could not find a recent engineering commit"
} }
if ($readme -notmatch [regex]::Escape($latestEngineeringCommit)) { if ($readme -notmatch [regex]::Escape($latestEngineeringCommit) -and -not $readme.Contains($currentEngineeringPhrase)) {
throw "README recent engineering commit must reference latest engineering commit $latestEngineeringCommit" throw "README recent engineering result must reference $latestEngineeringCommit or identify the current staged engineering commit"
} }
if ($baseline -notmatch '(?m)^>\s*audited_commit:\s*`?[0-9a-f]{7,40}`?') { if ($baseline -notmatch '(?m)^>\s*audited_commit:\s*`?[0-9a-f]{7,40}`?') {
+2
View File
@@ -38,6 +38,8 @@ node scripts/check-m09-b-cleaning-rules.mjs
Assert-NativeSuccess "check-m09-b-cleaning-rules" Assert-NativeSuccess "check-m09-b-cleaning-rules"
node scripts/check-m09-c-settlement-integrity.mjs node scripts/check-m09-c-settlement-integrity.mjs
Assert-NativeSuccess "check-m09-c-settlement-integrity" Assert-NativeSuccess "check-m09-c-settlement-integrity"
node scripts/check-m09-d1-product-inventory.mjs
Assert-NativeSuccess "check-m09-d1-product-inventory"
if (Test-Path "admin/package.json") { if (Test-Path "admin/package.json") {
Push-Location admin Push-Location admin
+17 -3
View File
@@ -37,6 +37,8 @@ database="qipai_m01c_test_${run_id}"
username="qmb_$$_$(openssl rand -hex 4)" username="qmb_$$_$(openssl rand -hex 4)"
password="$(openssl rand -hex 24)" password="$(openssl rand -hex 24)"
created=0 created=0
trust_function_creators_before="$(mysql --batch --skip-column-names -e 'SELECT @@GLOBAL.log_bin_trust_function_creators')"
trust_function_creators_changed=0
cleanup() { cleanup() {
if [[ "${created}" == "1" ]]; then if [[ "${created}" == "1" ]]; then
@@ -45,6 +47,11 @@ DROP DATABASE IF EXISTS \`${database}\`;
DROP USER IF EXISTS '${username}'@'127.0.0.1'; DROP USER IF EXISTS '${username}'@'127.0.0.1';
SQL SQL
fi fi
if [[ "${trust_function_creators_changed}" == "1" ]]; then
mysql --batch --skip-column-names \
-e "SET GLOBAL log_bin_trust_function_creators = ${trust_function_creators_before}" >/dev/null
trust_function_creators_changed=0
fi
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
@@ -55,6 +62,11 @@ CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
CREATE USER '${username}'@'127.0.0.1' IDENTIFIED BY '${password}'; CREATE USER '${username}'@'127.0.0.1' IDENTIFIED BY '${password}';
GRANT ALL PRIVILEGES ON \`${database}\`.* TO '${username}'@'127.0.0.1'; GRANT ALL PRIVILEGES ON \`${database}\`.* TO '${username}'@'127.0.0.1';
SQL SQL
if [[ "${trust_function_creators_before}" != "1" ]]; then
mysql --batch --skip-column-names \
-e 'SET GLOBAL log_bin_trust_function_creators = 1' >/dev/null
trust_function_creators_changed=1
fi
} }
if [[ "${1:-}" == "--force-failure" ]]; then if [[ "${1:-}" == "--force-failure" ]]; then
@@ -65,6 +77,7 @@ fi
if [[ "${1:-}" == "--cleanup-probe" ]]; then if [[ "${1:-}" == "--cleanup-probe" ]]; then
before_database_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'qipai_m01c_test_%'")" before_database_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'qipai_m01c_test_%'")"
before_user_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM mysql.user WHERE User LIKE 'qmb_%' AND Host = '127.0.0.1'")" before_user_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM mysql.user WHERE User LIKE 'qmb_%' AND Host = '127.0.0.1'")"
before_trust_function_creators="$(mysql --batch --skip-column-names -e 'SELECT @@GLOBAL.log_bin_trust_function_creators')"
set +e set +e
bash "${BASH_SOURCE[0]}" --force-failure bash "${BASH_SOURCE[0]}" --force-failure
probe_status=$? probe_status=$?
@@ -75,7 +88,8 @@ if [[ "${1:-}" == "--cleanup-probe" ]]; then
} }
after_database_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'qipai_m01c_test_%'")" after_database_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'qipai_m01c_test_%'")"
after_user_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM mysql.user WHERE User LIKE 'qmb_%' AND Host = '127.0.0.1'")" after_user_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM mysql.user WHERE User LIKE 'qmb_%' AND Host = '127.0.0.1'")"
if [[ "${before_database_count}" != "${after_database_count}" || "${before_user_count}" != "${after_user_count}" ]]; then after_trust_function_creators="$(mysql --batch --skip-column-names -e 'SELECT @@GLOBAL.log_bin_trust_function_creators')"
if [[ "${before_database_count}" != "${after_database_count}" || "${before_user_count}" != "${after_user_count}" || "${before_trust_function_creators}" != "${after_trust_function_creators}" ]]; then
echo "FAIL: temporary MySQL resources survived an interrupted test." >&2 echo "FAIL: temporary MySQL resources survived an interrupted test." >&2
exit 1 exit 1
fi fi
@@ -93,6 +107,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-C integration roundtrip in a temporary database." echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D1 integration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M09-C live MySQL integration roundtrip completed." echo "PASS: M01-B through M09-D1 live MySQL integration roundtrip completed."