feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -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,按当前进度继续开发。
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = ?`,
|
||||||
|
|||||||
@@ -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
@@ -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'] : [])
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
|
||||||
|
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
|
||||||
|
const version = z.number().int().min(1).max(4_294_967_295);
|
||||||
|
const createVersion = z.number().int().min(0).max(4_294_967_295);
|
||||||
|
const quantity = z.number().int().min(1).max(1_000_000_000);
|
||||||
|
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
|
||||||
|
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
|
||||||
|
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
|
||||||
|
const reason = z.string().trim().min(1).max(512);
|
||||||
|
const stockParams = z.object({ skuId: id }).strict();
|
||||||
|
const ledgerParams = z.object({ inventoryId: id }).strict();
|
||||||
|
const stockListQuery = z.object({
|
||||||
|
storeId: id,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
skuId: id.optional(),
|
||||||
|
productId: id.optional(),
|
||||||
|
search: z.string().trim().max(64).optional()
|
||||||
|
}).strict();
|
||||||
|
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
|
||||||
|
const mutationBase = {
|
||||||
|
storeId: id,
|
||||||
|
requestId,
|
||||||
|
reason,
|
||||||
|
expectedVersion: version.optional()
|
||||||
|
};
|
||||||
|
const policyBody = z.object({
|
||||||
|
...mutationBase,
|
||||||
|
expectedVersion: createVersion,
|
||||||
|
policyType: z.enum(['TRACKED', 'UNLIMITED']),
|
||||||
|
lowStockThreshold: nonNegativeQuantity
|
||||||
|
}).strict();
|
||||||
|
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
|
||||||
|
const adjustmentBody = z.object({
|
||||||
|
...mutationBase,
|
||||||
|
availableDelta: delta,
|
||||||
|
lossDelta: delta.default(0)
|
||||||
|
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
|
||||||
|
const stocktakeBody = z.object({
|
||||||
|
...mutationBase,
|
||||||
|
expectedVersion: version,
|
||||||
|
availableQuantity: nonNegativeQuantity,
|
||||||
|
lossQuantity: nonNegativeQuantity
|
||||||
|
}).strict();
|
||||||
|
const lossBody = z.object({ ...mutationBase, quantity }).strict();
|
||||||
|
|
||||||
|
export interface InventoryRouteOptions {
|
||||||
|
service: Pick<InventoryService,
|
||||||
|
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
|
||||||
|
| 'stocktake' | 'recordLoss'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerInventoryRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: InventoryRouteOptions
|
||||||
|
) {
|
||||||
|
app.get('/admin-api/inventory/stocks', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, false);
|
||||||
|
const query = stockListQuery.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.listStocks(actor, query.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, false);
|
||||||
|
const params = ledgerParams.safeParse(request.params);
|
||||||
|
const query = ledgerQuery.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.listLedger(actor, {
|
||||||
|
inventoryId: params.data.inventoryId,
|
||||||
|
...query.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = policyBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.configurePolicy(actor, {
|
||||||
|
skuId: params.data.skuId,
|
||||||
|
...body.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = inboundBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = adjustmentBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = stocktakeBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = lossBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireInventoryActor(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: InventoryRouteOptions,
|
||||||
|
write: boolean
|
||||||
|
): Promise<ManagementActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization,
|
||||||
|
options.authRepository,
|
||||||
|
options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID',
|
||||||
|
message: 'Authentication required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId,
|
||||||
|
auth.session.user.id
|
||||||
|
);
|
||||||
|
const manager = access.capabilities.includes('tenant.manage')
|
||||||
|
|| access.roles.includes('PLATFORM_ADMIN');
|
||||||
|
const allowed = manager || (write
|
||||||
|
? access.capabilities.includes('inventory.adjust')
|
||||||
|
: access.capabilities.includes('inventory.read')
|
||||||
|
|| access.capabilities.includes('inventory.adjust'));
|
||||||
|
if (!allowed) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'INVENTORY_OPERATION_FORBIDDEN',
|
||||||
|
message: 'Inventory permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
access,
|
||||||
|
traceId: request.traceId,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventoryResponse(
|
||||||
|
reply: FastifyReply,
|
||||||
|
traceId: string,
|
||||||
|
work: () => Promise<unknown>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof InventoryError)) throw error;
|
||||||
|
const status = inventoryErrorStatus(error.code);
|
||||||
|
return reply.status(status).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The inventory operation is not allowed.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryErrorStatus(code: string) {
|
||||||
|
if (code.endsWith('_FORBIDDEN')) return 403;
|
||||||
|
if (code.endsWith('_NOT_FOUND')) return 404;
|
||||||
|
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|
||||||
|
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_INVENTORY_REQUEST',
|
||||||
|
message: 'The inventory request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import {
|
||||||
|
ProductCatalogError,
|
||||||
|
type ProductCatalogRepository
|
||||||
|
} from '../products/product-catalog-repository.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const version = z.coerce.number().int().min(0).max(0xffffffff);
|
||||||
|
const code = z.string().trim().min(1).max(64)
|
||||||
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
||||||
|
const storeParamsSchema = z.object({ storeId: id }).strict();
|
||||||
|
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
|
||||||
|
const productParamsSchema = z.object({ productId: id }).strict();
|
||||||
|
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
|
||||||
|
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
|
||||||
|
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
|
||||||
|
const includeInactiveQuerySchema = z.object({
|
||||||
|
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const categoryFields = {
|
||||||
|
parentId: id.nullable().optional(),
|
||||||
|
categoryCode: code,
|
||||||
|
name: z.string().trim().min(1).max(128),
|
||||||
|
description: z.string().trim().max(1024).default(''),
|
||||||
|
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||||
|
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
|
||||||
|
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||||
|
};
|
||||||
|
const createCategorySchema = z.object(categoryFields).strict();
|
||||||
|
const updateCategorySchema = z.object({
|
||||||
|
...categoryFields,
|
||||||
|
expectedVersion: version.min(1)
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const productFields = {
|
||||||
|
productCode: code,
|
||||||
|
name: z.string().trim().min(1).max(128),
|
||||||
|
unitName: z.string().trim().min(1).max(32),
|
||||||
|
description: z.string().trim().max(10000).default(''),
|
||||||
|
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||||
|
images: z.array(z.string().url().max(512)).max(20).default([]),
|
||||||
|
deliveryEnabled: z.boolean().default(true),
|
||||||
|
storageEnabled: z.boolean().default(false),
|
||||||
|
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
|
||||||
|
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||||
|
};
|
||||||
|
const createProductSchema = z.object(productFields).strict();
|
||||||
|
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
|
||||||
|
const productListQuerySchema = z.object({
|
||||||
|
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
|
||||||
|
search: z.string().trim().min(1).max(128).optional()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const attributesSchema = z.record(
|
||||||
|
z.string().trim().min(1).max(64),
|
||||||
|
z.string().trim().max(128)
|
||||||
|
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
|
||||||
|
const skuFields = {
|
||||||
|
skuCode: code,
|
||||||
|
name: z.string().trim().min(1).max(128),
|
||||||
|
attributes: attributesSchema.default({}),
|
||||||
|
barcode: z.string().trim().max(64).default(''),
|
||||||
|
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||||
|
salePriceCents: z.number().int().min(0).max(100000000),
|
||||||
|
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
|
||||||
|
costPriceCents: z.number().int().min(0).max(100000000).default(0),
|
||||||
|
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
|
||||||
|
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
|
||||||
|
};
|
||||||
|
const createSkuSchema = z.object(skuFields).strict();
|
||||||
|
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
|
||||||
|
|
||||||
|
const listingSchema = z.object({
|
||||||
|
expectedVersion: version,
|
||||||
|
categoryId: id,
|
||||||
|
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
|
||||||
|
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
|
||||||
|
salesStartAt: z.coerce.date().nullable().optional(),
|
||||||
|
salesEndAt: z.coerce.date().nullable().optional(),
|
||||||
|
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||||
|
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|
||||||
|
|| value.salesEndAt > value.salesStartAt, {
|
||||||
|
message: 'salesEndAt must be later than salesStartAt'
|
||||||
|
});
|
||||||
|
|
||||||
|
const hourSchema = z.object({
|
||||||
|
weekday: z.number().int().min(1).max(7),
|
||||||
|
slotNo: z.number().int().min(1).max(8),
|
||||||
|
openMinute: z.number().int().min(0).max(1439),
|
||||||
|
closeMinute: z.number().int().min(0).max(1439),
|
||||||
|
crossesMidnight: z.boolean()
|
||||||
|
}).strict();
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
expectedVersion: version,
|
||||||
|
salesStatus: z.enum(['OPEN', 'CLOSED']),
|
||||||
|
manualPaused: z.boolean(),
|
||||||
|
manualPausedUntil: z.coerce.date().nullable().optional(),
|
||||||
|
manualPauseReason: z.string().trim().max(512).default(''),
|
||||||
|
hours: z.array(hourSchema).max(56).default([])
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
if (value.manualPaused && value.manualPauseReason.length === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['manualPauseReason'],
|
||||||
|
message: 'manualPauseReason is required while manually paused'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ProductRouteOptions {
|
||||||
|
repository: Pick<ProductCatalogRepository,
|
||||||
|
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
|
||||||
|
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
|
||||||
|
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
|
||||||
|
| 'listListings' | 'putListing' | 'archiveListing'
|
||||||
|
| 'getStoreSettings' | 'putStoreSettings'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/stores/:storeId/product-categories',
|
||||||
|
'/app-api/management/stores/:storeId/product-categories'
|
||||||
|
]) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
const params = storeParamsSchema.safeParse(request.params);
|
||||||
|
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listCategories(
|
||||||
|
actor, params.data.storeId, query.data.includeInactive
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = storeParamsSchema.safeParse(request.params);
|
||||||
|
const body = createCategorySchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = categoryParamsSchema.safeParse(request.params);
|
||||||
|
const body = updateCategorySchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
const { expectedVersion, ...input } = body.data;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.updateCategory(
|
||||||
|
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
|
||||||
|
async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = categoryParamsSchema.safeParse(request.params);
|
||||||
|
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.archiveCategory(
|
||||||
|
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/products', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
|
||||||
|
const query = productListQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listProducts(actor, query.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/products', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const body = createProductSchema.safeParse(request.body);
|
||||||
|
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.createProduct(actor, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/products/:productId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = productParamsSchema.safeParse(request.params);
|
||||||
|
const body = updateProductSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
const { expectedVersion, ...input } = body.data;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.updateProduct(
|
||||||
|
actor, params.data.productId, expectedVersion, input
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin-api/products/:productId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = productParamsSchema.safeParse(request.params);
|
||||||
|
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.archiveProduct(
|
||||||
|
actor, params.data.productId, query.data.expectedVersion
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
|
||||||
|
const params = productParamsSchema.safeParse(request.params);
|
||||||
|
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listSkus(
|
||||||
|
actor, params.data.productId, query.data.includeInactive
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = productParamsSchema.safeParse(request.params);
|
||||||
|
const body = createSkuSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.createSku(actor, params.data.productId, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = skuParamsSchema.safeParse(request.params);
|
||||||
|
const body = updateSkuSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
const { expectedVersion, ...input } = body.data;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.updateSku(
|
||||||
|
actor, params.data.productId, params.data.skuId, expectedVersion, input
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = skuParamsSchema.safeParse(request.params);
|
||||||
|
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.archiveSku(
|
||||||
|
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/stores/:storeId/product-listings',
|
||||||
|
'/app-api/management/stores/:storeId/product-listings'
|
||||||
|
]) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
const params = storeParamsSchema.safeParse(request.params);
|
||||||
|
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listListings(
|
||||||
|
actor, params.data.storeId, query.data.includeInactive
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = listingParamsSchema.safeParse(request.params);
|
||||||
|
const body = listingSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
const { expectedVersion, ...input } = body.data;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.putListing(
|
||||||
|
actor, params.data.storeId, params.data.productId, expectedVersion, input
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
|
||||||
|
async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = listingParamsSchema.safeParse(request.params);
|
||||||
|
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.archiveListing(
|
||||||
|
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/stores/:storeId/product-sales-settings',
|
||||||
|
'/app-api/management/stores/:storeId/product-sales-settings'
|
||||||
|
]) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
const params = storeParamsSchema.safeParse(request.params);
|
||||||
|
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.getStoreSettings(actor, params.data.storeId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
const params = storeParamsSchema.safeParse(request.params);
|
||||||
|
const body = settingsSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
const { expectedVersion, ...input } = body.data;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.putStoreSettings(
|
||||||
|
actor, params.data.storeId, expectedVersion, input
|
||||||
|
),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireActor(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: ProductRouteOptions,
|
||||||
|
mode: 'read' | 'write',
|
||||||
|
scope: 'store' | 'global-catalog' = 'store'
|
||||||
|
): Promise<ManagementActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId, auth.session.user.id
|
||||||
|
);
|
||||||
|
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
|
||||||
|
const allowed = access.capabilities.includes(capability)
|
||||||
|
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|
||||||
|
|| access.capabilities.includes('tenant.manage')
|
||||||
|
|| access.capabilities.includes('platform.manage')
|
||||||
|
|| access.roles.includes('PLATFORM_ADMIN');
|
||||||
|
if (!allowed) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
|
||||||
|
message: 'Product catalog permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (scope === 'global-catalog') {
|
||||||
|
const tenantManager = access.capabilities.includes('tenant.manage')
|
||||||
|
|| access.capabilities.includes('platform.manage')
|
||||||
|
|| access.roles.includes('PLATFORM_ADMIN');
|
||||||
|
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
|
||||||
|
&& access.storeIds.length > 0
|
||||||
|
&& (access.capabilities.includes('product.catalog.read')
|
||||||
|
|| access.capabilities.includes('product.catalog.write'));
|
||||||
|
if (!tenantManager && !authorizedStoreAdmin) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
|
||||||
|
message: 'Tenant management or an authorized store administrator is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
access,
|
||||||
|
traceId: request.traceId,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ProductCatalogError)) throw error;
|
||||||
|
const code = error.code;
|
||||||
|
const status = code.endsWith('_FORBIDDEN') ? 403
|
||||||
|
: code.endsWith('_NOT_FOUND') ? 404
|
||||||
|
: code.includes('INVALID') ? 400
|
||||||
|
: 409;
|
||||||
|
return reply.status(status).send({
|
||||||
|
code,
|
||||||
|
message: 'The product catalog request cannot be completed.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ import { BusinessStatisticsRepository } from './operations/business-statistics-r
|
|||||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
import { 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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||||
|
import { InventoryError } from '../dist/inventory/inventory-service.js';
|
||||||
|
import { registerInventoryRoutes } from '../dist/routes/inventory.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-inventory-route-jwt-secret';
|
||||||
|
const sessionId = '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e';
|
||||||
|
const token = signAccessToken({
|
||||||
|
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||||
|
}, secret, 900);
|
||||||
|
const headers = {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'x-trace-id': 'm09d1-inventory-route'
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
let currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
|
||||||
|
const mutationResult = (input, operation) => ({
|
||||||
|
inventoryId: '101',
|
||||||
|
ledgerId: '1001',
|
||||||
|
storeId: input.storeId,
|
||||||
|
skuId: input.skuId,
|
||||||
|
operation,
|
||||||
|
policyType: input.policyType ?? 'TRACKED',
|
||||||
|
availableQuantity: input.availableQuantity ?? 8,
|
||||||
|
lockedQuantity: 0,
|
||||||
|
lossQuantity: input.lossQuantity ?? 1,
|
||||||
|
lowStockThreshold: input.lowStockThreshold ?? 2,
|
||||||
|
version: 2,
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
const record = (method, result) => async (...args) => {
|
||||||
|
calls.push({ method, args });
|
||||||
|
return typeof result === 'function' ? result(...args) : result;
|
||||||
|
};
|
||||||
|
const mutate = (operation) => record(operation.toLowerCase(), (_actor, input) => {
|
||||||
|
if (input.storeId === '12') throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
|
||||||
|
if (input.requestId === 'insufficient') {
|
||||||
|
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||||
|
}
|
||||||
|
return mutationResult(input, operation);
|
||||||
|
});
|
||||||
|
const service = {
|
||||||
|
listStocks: record('listStocks', (_actor, input) => ({
|
||||||
|
items: [], total: 0, page: input.page, pageSize: input.pageSize
|
||||||
|
})),
|
||||||
|
listLedger: record('listLedger', (_actor, input) => ({
|
||||||
|
items: [], total: 0, page: input.page, pageSize: input.pageSize
|
||||||
|
})),
|
||||||
|
configurePolicy: mutate('CONFIGURE'),
|
||||||
|
inbound: mutate('INBOUND'),
|
||||||
|
adjust: mutate('ADJUST'),
|
||||||
|
stocktake: mutate('STOCKTAKE'),
|
||||||
|
recordLoss: mutate('LOSS')
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
app.decorateRequest('traceId', '');
|
||||||
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
|
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||||
|
reply.header('x-trace-id', request.traceId);
|
||||||
|
});
|
||||||
|
await registerInventoryRoutes(app, {
|
||||||
|
service,
|
||||||
|
authRepository: {
|
||||||
|
async validateSession() {
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
tenantId: '7',
|
||||||
|
platformAppId: '9',
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
user: {
|
||||||
|
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
|
||||||
|
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accessControl: {
|
||||||
|
async getAccessProfile() { return currentAccess; }
|
||||||
|
},
|
||||||
|
jwtSecret: secret
|
||||||
|
});
|
||||||
|
|
||||||
|
const unauthorized = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11'
|
||||||
|
});
|
||||||
|
assert.equal(unauthorized.statusCode, 401);
|
||||||
|
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||||
|
|
||||||
|
const forbidden = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
|
||||||
|
});
|
||||||
|
assert.equal(forbidden.statusCode, 403);
|
||||||
|
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
|
||||||
|
};
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin-api/inventory/stocks?storeId=11&page=2&pageSize=25&search=tea',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
assert.equal(listed.statusCode, 200);
|
||||||
|
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
|
||||||
|
const listCall = calls.find((call) => call.method === 'listStocks');
|
||||||
|
assert.equal(listCall.args[0].tenantId, '7');
|
||||||
|
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
|
||||||
|
assert.deepEqual(listCall.args[1], {
|
||||||
|
storeId: '11', page: 2, pageSize: 25, search: 'tea'
|
||||||
|
});
|
||||||
|
|
||||||
|
const ledger = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin-api/inventory/stocks/101/ledger?storeId=11&page=1&pageSize=10',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
assert.equal(ledger.statusCode, 200);
|
||||||
|
const ledgerCall = calls.find((call) => call.method === 'listLedger');
|
||||||
|
assert.equal(ledgerCall.args[1].inventoryId, '101');
|
||||||
|
|
||||||
|
const readOnlyWrite = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||||
|
payload: { storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2 }
|
||||||
|
});
|
||||||
|
assert.equal(readOnlyWrite.statusCode, 403);
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STORE_ADMIN'], capabilities: ['inventory.adjust'], storeIds: ['11']
|
||||||
|
};
|
||||||
|
const invalid = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/inventory/stocks/5/adjust', headers,
|
||||||
|
payload: {
|
||||||
|
storeId: '11', requestId: 'adjust-zero', reason: 'nothing',
|
||||||
|
availableDelta: 0, lossDelta: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(invalid.statusCode, 400);
|
||||||
|
assert.equal(invalid.json().code, 'INVALID_INVENTORY_REQUEST');
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
['PUT', '/admin-api/inventory/stocks/5/policy', {
|
||||||
|
storeId: '11', requestId: 'policy-1', reason: 'configure',
|
||||||
|
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
|
||||||
|
}, 'configure'],
|
||||||
|
['POST', '/admin-api/inventory/stocks/5/inbound', {
|
||||||
|
storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2
|
||||||
|
}, 'inbound'],
|
||||||
|
['POST', '/admin-api/inventory/stocks/5/adjust', {
|
||||||
|
storeId: '11', requestId: 'adjust-1', reason: 'manual correction',
|
||||||
|
availableDelta: -1, lossDelta: 1
|
||||||
|
}, 'adjust'],
|
||||||
|
['POST', '/admin-api/inventory/stocks/5/stocktake', {
|
||||||
|
storeId: '11', requestId: 'stocktake-1', reason: 'count',
|
||||||
|
expectedVersion: 1, availableQuantity: 8, lossQuantity: 1
|
||||||
|
}, 'stocktake'],
|
||||||
|
['POST', '/admin-api/inventory/stocks/5/loss', {
|
||||||
|
storeId: '11', requestId: 'loss-1', reason: 'damaged', quantity: 1
|
||||||
|
}, 'loss']
|
||||||
|
];
|
||||||
|
for (const [method, url, payload, calledMethod] of requests) {
|
||||||
|
const response = await app.inject({ method, url, headers, payload });
|
||||||
|
assert.equal(response.statusCode, 200, `${method} ${url}`);
|
||||||
|
assert.equal(response.json().code, 0);
|
||||||
|
const call = calls.findLast((item) => item.method === calledMethod);
|
||||||
|
assert.equal(call.args[1].skuId, '5');
|
||||||
|
assert.equal(call.args[1].storeId, '11');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [method, url, payload] of [
|
||||||
|
['PUT', '/admin-api/inventory/stocks/5/policy', {
|
||||||
|
storeId: '11', requestId: 'policy-no-version', reason: 'configure',
|
||||||
|
policyType: 'TRACKED', lowStockThreshold: 2
|
||||||
|
}],
|
||||||
|
['POST', '/admin-api/inventory/stocks/5/stocktake', {
|
||||||
|
storeId: '11', requestId: 'stocktake-no-version', reason: 'count',
|
||||||
|
availableQuantity: 8, lossQuantity: 1
|
||||||
|
}]
|
||||||
|
]) {
|
||||||
|
const response = await app.inject({ method, url, headers, payload });
|
||||||
|
assert.equal(response.statusCode, 400, `${method} ${url} requires expectedVersion`);
|
||||||
|
assert.equal(response.json().code, 'INVALID_INVENTORY_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
const insufficient = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||||
|
payload: { storeId: '11', requestId: 'insufficient', reason: 'conflict', quantity: 2 }
|
||||||
|
});
|
||||||
|
assert.equal(insufficient.statusCode, 409);
|
||||||
|
assert.equal(insufficient.json().code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||||
|
|
||||||
|
const outOfScope = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/inventory/stocks/5/loss', headers,
|
||||||
|
payload: { storeId: '12', requestId: 'loss-outside', reason: 'wrong store', quantity: 1 }
|
||||||
|
});
|
||||||
|
assert.equal(outOfScope.statusCode, 403);
|
||||||
|
assert.equal(outOfScope.json().code, 'INVENTORY_STORE_SCOPE_FORBIDDEN');
|
||||||
|
|
||||||
|
const internalRoute = await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/inventory/stocks/5/lock', headers,
|
||||||
|
payload: { storeId: '11', requestId: 'lock-1', reason: 'must stay internal', quantity: 1 }
|
||||||
|
});
|
||||||
|
assert.equal(internalRoute.statusCode, 404);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
console.log('PASS: M09-D1 inventory routes enforce auth, inventory.adjust, validation, error mapping and HTTP boundaries.');
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
InventoryError,
|
||||||
|
InventoryService
|
||||||
|
} from '../dist/inventory/inventory-service.js';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
stocks: [
|
||||||
|
stock('101', '5', 10, 0, 1, 2, 'TRACKED'),
|
||||||
|
stock('102', '2', 4, 0, 0, 1, 'TRACKED'),
|
||||||
|
stock('103', '9', 0, 0, 0, 0, 'UNLIMITED')
|
||||||
|
],
|
||||||
|
skus: new Map([
|
||||||
|
['2', 'TRACKED'], ['4', 'TRACKED'], ['5', 'TRACKED'], ['9', 'UNLIMITED']
|
||||||
|
]),
|
||||||
|
inactiveSkus: new Set(),
|
||||||
|
archivedSkus: new Set(),
|
||||||
|
requests: [],
|
||||||
|
ledgers: [],
|
||||||
|
audits: [],
|
||||||
|
lockOrder: [],
|
||||||
|
nextStockId: 104,
|
||||||
|
nextLedgerId: 1001,
|
||||||
|
commits: 0,
|
||||||
|
rollbacks: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
function stock(id, skuId, availableQuantity, lockedQuantity, lossQuantity,
|
||||||
|
lowStockThreshold, policyType) {
|
||||||
|
return {
|
||||||
|
id, tenantId: '7', storeId: '11', skuId, policyType,
|
||||||
|
availableQuantity, lockedQuantity, lossQuantity, lowStockThreshold, version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMutableState() {
|
||||||
|
return structuredClone({
|
||||||
|
stocks: state.stocks,
|
||||||
|
requests: state.requests,
|
||||||
|
ledgers: state.ledgers,
|
||||||
|
audits: state.audits,
|
||||||
|
nextStockId: state.nextStockId,
|
||||||
|
nextLedgerId: state.nextLedgerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let transactionSnapshot = null;
|
||||||
|
const pool = {
|
||||||
|
async getConnection() { return connection; },
|
||||||
|
async execute(sql, params = []) { return execute(sql, params); }
|
||||||
|
};
|
||||||
|
const connection = {
|
||||||
|
async beginTransaction() { transactionSnapshot = cloneMutableState(); },
|
||||||
|
async commit() { state.commits += 1; transactionSnapshot = null; },
|
||||||
|
async rollback() {
|
||||||
|
state.rollbacks += 1;
|
||||||
|
if (transactionSnapshot) {
|
||||||
|
state.stocks = transactionSnapshot.stocks;
|
||||||
|
state.requests = transactionSnapshot.requests;
|
||||||
|
state.ledgers = transactionSnapshot.ledgers;
|
||||||
|
state.audits = transactionSnapshot.audits;
|
||||||
|
state.nextStockId = transactionSnapshot.nextStockId;
|
||||||
|
state.nextLedgerId = transactionSnapshot.nextLedgerId;
|
||||||
|
}
|
||||||
|
transactionSnapshot = null;
|
||||||
|
},
|
||||||
|
release() {},
|
||||||
|
async execute(sql, params = []) { return execute(sql, params); }
|
||||||
|
};
|
||||||
|
|
||||||
|
function execute(sql, params) {
|
||||||
|
if (/\b(UPDATE|DELETE)\s+qipai_product_inventory_ledger/i.test(sql)) {
|
||||||
|
throw new Error('immutable ledger was mutated');
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory_ledger')) {
|
||||||
|
const matches = state.ledgers.filter((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]));
|
||||||
|
return [[{ total: matches.length }], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM qipai_product_inventory_ledger')
|
||||||
|
&& sql.includes('ORDER BY id DESC')) {
|
||||||
|
const rows = state.ledgers.filter((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]))
|
||||||
|
.sort((left, right) => Number(right.id) - Number(left.id));
|
||||||
|
return [rows, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory i')) {
|
||||||
|
const matches = state.stocks.filter((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.storeId === String(params[1]));
|
||||||
|
return [[{ total: matches.length }], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM qipai_product_inventory i') && sql.includes('ORDER BY p.sort_order')) {
|
||||||
|
const rows = state.stocks.filter((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.storeId === String(params[1])).map((item) => ({
|
||||||
|
...item,
|
||||||
|
skuCode: `SKU-${item.skuId}`,
|
||||||
|
skuName: `规格 ${item.skuId}`,
|
||||||
|
productId: `8${item.skuId}`,
|
||||||
|
productCode: `P-${item.skuId}`,
|
||||||
|
productName: `商品 ${item.skuId}`,
|
||||||
|
salePriceCents: 1200
|
||||||
|
}));
|
||||||
|
return [rows, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT IGNORE INTO qipai_product_inventory')) {
|
||||||
|
const tenantId = String(params[0]);
|
||||||
|
const storeId = String(params[3]);
|
||||||
|
const skuId = String(params[5]);
|
||||||
|
const existing = findStock(tenantId, storeId, skuId);
|
||||||
|
if (existing || tenantId !== '7' || storeId !== '11' || !state.skus.has(skuId)) {
|
||||||
|
return [{ affectedRows: 0 }, []];
|
||||||
|
}
|
||||||
|
state.stocks.push({
|
||||||
|
id: String(state.nextStockId++), tenantId, storeId, skuId,
|
||||||
|
policyType: state.skus.get(skuId), availableQuantity: 0, lockedQuantity: 0,
|
||||||
|
lossQuantity: 0, lowStockThreshold: 0, version: 1
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO qipai_product_inventory_requests')) {
|
||||||
|
const tenantId = String(params[0]);
|
||||||
|
const requestId = String(params[2]);
|
||||||
|
if (state.requests.some((item) => item.tenantId === tenantId
|
||||||
|
&& item.requestId === requestId)) {
|
||||||
|
throw Object.assign(new Error('duplicate inventory request'), { code: 'ER_DUP_ENTRY' });
|
||||||
|
}
|
||||||
|
state.requests.push({
|
||||||
|
tenantId, storeId: String(params[1]), requestId,
|
||||||
|
operation: params[3], businessType: String(params[4]), businessId: String(params[5]),
|
||||||
|
fingerprint: String(params[6]), itemCount: Number(params[7])
|
||||||
|
});
|
||||||
|
return [{ insertId: state.requests.length, affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM qipai_product_inventory_requests') && sql.includes('FOR UPDATE')) {
|
||||||
|
const row = state.requests.find((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.requestId === String(params[1]));
|
||||||
|
return [row ? [{ ...row }] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT product_id AS productId')
|
||||||
|
&& sql.includes('FROM qipai_product_skus')) {
|
||||||
|
const skuId = String(params[1]);
|
||||||
|
return [state.skus.has(skuId) ? [{ productId: `8${skuId}` }] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id FROM qipai_products') && sql.includes('FOR SHARE')) {
|
||||||
|
return [[{ id: String(params[1]) }], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id FROM qipai_product_skus') && sql.includes('FOR SHARE')) {
|
||||||
|
const skuId = String(params[2]);
|
||||||
|
const requiresActive = sql.includes("status = 'ACTIVE'");
|
||||||
|
return [state.skus.has(skuId) && !state.archivedSkus.has(skuId)
|
||||||
|
&& (!requiresActive || !state.inactiveSkus.has(skuId))
|
||||||
|
? [{ id: skuId }] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id FROM qipai_product_inventory')) {
|
||||||
|
const found = findStock(String(params[0]), String(params[1]), String(params[2]));
|
||||||
|
return [found ? [{ id: found.id }] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM qipai_product_inventory') && sql.includes('FOR UPDATE')) {
|
||||||
|
const skuId = String(params[2]);
|
||||||
|
state.lockOrder.push(skuId);
|
||||||
|
const found = findStock(String(params[0]), String(params[1]), skuId);
|
||||||
|
return [found ? [{ ...found }] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM qipai_product_inventory_ledger')
|
||||||
|
&& sql.includes('request_id = ?')) {
|
||||||
|
const row = state.ledgers.find((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
|
||||||
|
return [row ? [{ ...row }] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
|
||||||
|
const inventoryIds = new Set(params.slice(4).map(String));
|
||||||
|
const balances = new Map();
|
||||||
|
for (const item of state.ledgers) {
|
||||||
|
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|
||||||
|
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|
||||||
|
|| !inventoryIds.has(item.inventoryId)) continue;
|
||||||
|
const metadata = JSON.parse(item.metadata);
|
||||||
|
const reservationDelta = metadata.inventoryReservationDelta ?? Number(item.lockedDelta);
|
||||||
|
balances.set(item.inventoryId,
|
||||||
|
(balances.get(item.inventoryId) ?? 0) + Number(reservationDelta));
|
||||||
|
}
|
||||||
|
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
|
||||||
|
inventoryId, reservedQuantity
|
||||||
|
})), []];
|
||||||
|
}
|
||||||
|
if (sql.includes('AS reservedQuantity')
|
||||||
|
&& sql.includes('WHERE tenant_id = ? AND inventory_id = ?')) {
|
||||||
|
const reservedQuantity = state.ledgers
|
||||||
|
.filter((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.inventoryId === String(params[1]))
|
||||||
|
.reduce((total, item) => {
|
||||||
|
const metadata = JSON.parse(item.metadata);
|
||||||
|
return total + Number(metadata.inventoryReservationDelta ?? item.lockedDelta);
|
||||||
|
}, 0);
|
||||||
|
return [[{ reservedQuantity }], []];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('UPDATE qipai_product_inventory')) {
|
||||||
|
const found = state.stocks.find((item) => item.tenantId === String(params[7])
|
||||||
|
&& item.id === String(params[8]) && item.version === Number(params[9]));
|
||||||
|
if (!found) return [{ affectedRows: 0 }, []];
|
||||||
|
Object.assign(found, {
|
||||||
|
policyType: params[0], availableQuantity: Number(params[1]),
|
||||||
|
lockedQuantity: Number(params[2]), lossQuantity: Number(params[3]),
|
||||||
|
lowStockThreshold: Number(params[4]), version: Number(params[5])
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO qipai_product_inventory_ledger')) {
|
||||||
|
const duplicate = state.ledgers.some((item) => item.tenantId === String(params[0])
|
||||||
|
&& item.inventoryId === String(params[1]) && item.requestId === String(params[4]));
|
||||||
|
if (duplicate) throw new Error('duplicate inventory ledger request');
|
||||||
|
const row = {
|
||||||
|
id: String(state.nextLedgerId++),
|
||||||
|
tenantId: String(params[0]), inventoryId: String(params[1]),
|
||||||
|
storeId: String(params[2]), skuId: String(params[3]), requestId: String(params[4]),
|
||||||
|
businessType: String(params[5]), businessId: String(params[6]), operation: params[7],
|
||||||
|
availableDelta: Number(params[8]), lockedDelta: Number(params[9]),
|
||||||
|
lossDelta: Number(params[10]), availableAfter: Number(params[11]),
|
||||||
|
lockedAfter: Number(params[12]), lossAfter: Number(params[13]),
|
||||||
|
versionAfter: Number(params[14]), operatorId: params[15] == null ? null : String(params[15]),
|
||||||
|
traceId: String(params[16]), reason: String(params[17]), metadata: String(params[18]),
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
state.ledgers.push(row);
|
||||||
|
return [{ insertId: row.id, affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO qipai_audit_logs')) {
|
||||||
|
state.audits.push({ action: params[3], resourceId: String(params[4]), metadata: params[8] });
|
||||||
|
return [{ insertId: state.audits.length, affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected SQL: ${sql}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStock(tenantId, storeId, skuId) {
|
||||||
|
return state.stocks.find((item) => item.tenantId === tenantId
|
||||||
|
&& item.storeId === storeId && item.skuId === skuId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
tenantId: '7', userId: '21',
|
||||||
|
access: { roles: ['STORE_ADMIN'], capabilities: ['inventory.read', 'inventory.adjust'], storeIds: ['11'] },
|
||||||
|
traceId: 'inventory-test', ip: '127.0.0.1', userAgent: 'inventory-test-agent'
|
||||||
|
};
|
||||||
|
const service = new InventoryService(pool);
|
||||||
|
|
||||||
|
const listed = await service.listStocks(manager, { storeId: '11', page: 1, pageSize: 20 });
|
||||||
|
assert.equal(listed.total, 3);
|
||||||
|
assert.equal(listed.items[0].tenantId, '7');
|
||||||
|
assert.equal(listed.items[0].lossQuantity, 1);
|
||||||
|
|
||||||
|
const inboundInput = {
|
||||||
|
storeId: '11', skuId: '5', requestId: 'admin-inbound-1', reason: '首批入库',
|
||||||
|
expectedVersion: 1, quantity: 5
|
||||||
|
};
|
||||||
|
const inbound = await service.inbound(manager, inboundInput);
|
||||||
|
assert.equal(inbound.availableQuantity, 15);
|
||||||
|
assert.equal(inbound.version, 2);
|
||||||
|
const auditCountAfterInbound = state.audits.length;
|
||||||
|
const replay = await service.inbound(manager, inboundInput);
|
||||||
|
assert.equal(replay.idempotent, true);
|
||||||
|
assert.equal(replay.availableQuantity, 15);
|
||||||
|
assert.equal(state.audits.length, auditCountAfterInbound);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.inbound(manager, { ...inboundInput, quantity: 6 }),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||||
|
);
|
||||||
|
|
||||||
|
const adjusted = await service.adjust(manager, {
|
||||||
|
storeId: '11', skuId: '5', requestId: 'admin-adjust-1', reason: '人工纠偏',
|
||||||
|
expectedVersion: 2, availableDelta: -2, lossDelta: 1
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[adjusted.availableQuantity, adjusted.lossQuantity, adjusted.version],
|
||||||
|
[13, 2, 3]
|
||||||
|
);
|
||||||
|
const counted = await service.stocktake(manager, {
|
||||||
|
storeId: '11', skuId: '5', requestId: 'admin-stocktake-1', reason: '闭店盘点',
|
||||||
|
expectedVersion: 3, availableQuantity: 12, lossQuantity: 3
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[counted.availableQuantity, counted.lossQuantity, counted.version],
|
||||||
|
[12, 3, 4]
|
||||||
|
);
|
||||||
|
const lost = await service.recordLoss(manager, {
|
||||||
|
storeId: '11', skuId: '5', requestId: 'admin-loss-1', reason: '包装破损',
|
||||||
|
expectedVersion: 4, quantity: 2
|
||||||
|
});
|
||||||
|
assert.deepEqual([lost.availableQuantity, lost.lossQuantity, lost.version], [10, 5, 5]);
|
||||||
|
|
||||||
|
const configured = await service.configurePolicy(manager, {
|
||||||
|
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
|
||||||
|
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
|
||||||
|
});
|
||||||
|
assert.equal(configured.policyType, 'TRACKED');
|
||||||
|
assert.equal(configured.lowStockThreshold, 3);
|
||||||
|
assert.ok(findStock('7', '11', '4'));
|
||||||
|
const reconfigured = await service.configurePolicy(manager, {
|
||||||
|
storeId: '11', skuId: '4', requestId: 'admin-policy-2', reason: 'raise warning threshold',
|
||||||
|
policyType: 'TRACKED', lowStockThreshold: 7, expectedVersion: 2
|
||||||
|
});
|
||||||
|
assert.equal(reconfigured.lowStockThreshold, 7);
|
||||||
|
const configuredReplay = await service.configurePolicy(manager, {
|
||||||
|
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
|
||||||
|
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
|
||||||
|
});
|
||||||
|
assert.equal(configuredReplay.idempotent, true);
|
||||||
|
assert.equal(configuredReplay.lowStockThreshold, 3);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.configurePolicy(manager, {
|
||||||
|
storeId: '11', skuId: '5', requestId: 'admin-policy-unlimited', reason: '错误切换',
|
||||||
|
policyType: 'UNLIMITED', lowStockThreshold: 0, expectedVersion: 5
|
||||||
|
}),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_POLICY_STOCK_NOT_ZERO'
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopedOut = { ...manager, access: { ...manager.access, storeIds: ['12'] } };
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
|
||||||
|
);
|
||||||
|
|
||||||
|
state.lockOrder.length = 0;
|
||||||
|
const batchBase = {
|
||||||
|
tenantId: '7', storeId: '11', requestId: 'order-lock-1',
|
||||||
|
businessType: 'PRODUCT_ORDER', businessId: 'order-500', traceId: 'order-lock-trace',
|
||||||
|
operatorId: '21', reason: '创建商品订单', metadata: { source: 'ORDER' },
|
||||||
|
items: [{ skuId: '5', quantity: 3 }, { skuId: '2', quantity: 1 }, { skuId: '2', quantity: 1 }]
|
||||||
|
};
|
||||||
|
const locked = await service.lockMany(batchBase);
|
||||||
|
assert.equal(locked.idempotent, false);
|
||||||
|
assert.deepEqual(state.lockOrder.slice(-2), ['2', '5']);
|
||||||
|
assert.deepEqual(
|
||||||
|
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
|
||||||
|
[2, 2]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
|
||||||
|
[7, 3]
|
||||||
|
);
|
||||||
|
const ledgerCountAfterLock = state.ledgers.length;
|
||||||
|
const lockedReplay = await service.lockMany(batchBase);
|
||||||
|
assert.equal(lockedReplay.idempotent, true);
|
||||||
|
assert.equal(state.ledgers.length, ledgerCountAfterLock);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.lockMany({ ...batchBase, items: [{ skuId: '5', quantity: 4 }, { skuId: '2', quantity: 2 }] }),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.lockMany({ ...batchBase, items: [{ skuId: '9', quantity: 1 }] }),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.releaseMany({
|
||||||
|
...batchBase, requestId: 'foreign-order-release', businessId: 'order-foreign',
|
||||||
|
items: [{ skuId: '5', quantity: 1 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof InventoryError
|
||||||
|
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.releaseMany({
|
||||||
|
...batchBase, requestId: 'order-release-1', reason: '部分取消',
|
||||||
|
items: [{ skuId: '5', quantity: 1 }, { skuId: '2', quantity: 1 }]
|
||||||
|
});
|
||||||
|
await service.deductMany({
|
||||||
|
...batchBase, requestId: 'order-deduct-1', reason: '支付确认扣减',
|
||||||
|
items: [{ skuId: '5', quantity: 2 }, { skuId: '2', quantity: 1 }]
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
|
||||||
|
[3, 0]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
|
||||||
|
[8, 0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforeFailedBatch = structuredClone(state.stocks);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.lockMany({
|
||||||
|
...batchBase, requestId: 'order-lock-too-many', businessId: 'order-501',
|
||||||
|
items: [{ skuId: '5', quantity: 999 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
|
||||||
|
);
|
||||||
|
assert.deepEqual(state.stocks, beforeFailedBatch);
|
||||||
|
assert.ok(state.rollbacks > 0);
|
||||||
|
|
||||||
|
const unlimited = await service.lockMany({
|
||||||
|
...batchBase, requestId: 'order-unlimited-lock', businessId: 'order-502',
|
||||||
|
items: [{ skuId: '9', quantity: 999 }]
|
||||||
|
});
|
||||||
|
assert.equal(unlimited.items[0].availableQuantity, 0);
|
||||||
|
assert.equal(unlimited.items[0].lockedQuantity, 0);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.configurePolicy(manager, {
|
||||||
|
storeId: '11', skuId: '9', requestId: 'unlimited-switch-active', reason: '切换追踪',
|
||||||
|
expectedVersion: unlimited.items[0].version, policyType: 'TRACKED', lowStockThreshold: 0
|
||||||
|
}),
|
||||||
|
(error) => error instanceof InventoryError
|
||||||
|
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
|
||||||
|
);
|
||||||
|
const unlimitedDeduct = await service.deductMany({
|
||||||
|
...batchBase, requestId: 'order-unlimited-deduct', businessId: 'order-502',
|
||||||
|
items: [{ skuId: '9', quantity: 999 }]
|
||||||
|
});
|
||||||
|
const trackedAfterUnlimited = await service.configurePolicy(manager, {
|
||||||
|
storeId: '11', skuId: '9', requestId: 'unlimited-switch-settled', reason: '切换追踪',
|
||||||
|
expectedVersion: unlimitedDeduct.items[0].version,
|
||||||
|
policyType: 'TRACKED', lowStockThreshold: 0
|
||||||
|
});
|
||||||
|
assert.equal(trackedAfterUnlimited.policyType, 'TRACKED');
|
||||||
|
|
||||||
|
state.inactiveSkus.add('2');
|
||||||
|
const inactiveReplay = await service.lockMany(batchBase);
|
||||||
|
assert.equal(inactiveReplay.idempotent, true);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.lockMany({
|
||||||
|
...batchBase, requestId: 'inactive-sku-lock', businessId: 'order-503',
|
||||||
|
items: [{ skuId: '2', quantity: 1 }]
|
||||||
|
}),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_SELLABLE'
|
||||||
|
);
|
||||||
|
state.inactiveSkus.delete('2');
|
||||||
|
|
||||||
|
state.archivedSkus.add('5');
|
||||||
|
const archivedAdminReplay = await service.inbound(manager, inboundInput);
|
||||||
|
assert.equal(archivedAdminReplay.idempotent, true);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.inbound(manager, {
|
||||||
|
storeId: '11', skuId: '5', requestId: 'archived-sku-inbound',
|
||||||
|
reason: '归档规格不得重新入库', quantity: 1
|
||||||
|
}),
|
||||||
|
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
|
||||||
|
);
|
||||||
|
state.archivedSkus.delete('5');
|
||||||
|
|
||||||
|
const history = await service.listLedger(manager, {
|
||||||
|
storeId: '11', inventoryId: '101', page: 1, pageSize: 100
|
||||||
|
});
|
||||||
|
assert.ok(history.items.length >= 7);
|
||||||
|
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
|
||||||
|
assert.equal(state.audits.length, state.ledgers.length);
|
||||||
|
|
||||||
|
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
|
||||||
@@ -120,6 +120,15 @@ const cleaningSettlementIntegrityDownSql = read(
|
|||||||
const cleaningSettlementIntegrityVerifySql = read(
|
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.');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ProductCatalogError,
|
||||||
|
ProductCatalogRepository,
|
||||||
|
isProductStoreOpenAt,
|
||||||
|
validateBusinessHours
|
||||||
|
} from '../dist/products/product-catalog-repository.js';
|
||||||
|
|
||||||
|
const tenantActor = {
|
||||||
|
tenantId: '7',
|
||||||
|
userId: '21',
|
||||||
|
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||||
|
traceId: 'product-catalog-test',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'catalog-test'
|
||||||
|
};
|
||||||
|
const storeActor = {
|
||||||
|
...tenantActor,
|
||||||
|
userId: '22',
|
||||||
|
access: {
|
||||||
|
roles: ['STORE_ADMIN'],
|
||||||
|
capabilities: ['product.catalog.read', 'product.catalog.write'],
|
||||||
|
storeIds: ['11']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mondayOvernight = [{
|
||||||
|
weekday: 1,
|
||||||
|
slotNo: 1,
|
||||||
|
openMinute: 22 * 60,
|
||||||
|
closeMinute: 2 * 60,
|
||||||
|
crossesMidnight: true
|
||||||
|
}];
|
||||||
|
validateBusinessHours(mondayOvernight);
|
||||||
|
const openSettings = {
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
businessStatus: 'OPEN',
|
||||||
|
salesStatus: 'OPEN',
|
||||||
|
manualPausedAt: null,
|
||||||
|
manualPausedUntil: null,
|
||||||
|
hours: mondayOvernight
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
isProductStoreOpenAt(openSettings, new Date('2026-08-10T15:00:00.000Z')),
|
||||||
|
true,
|
||||||
|
'Monday 23:00 should be inside the Monday overnight window'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isProductStoreOpenAt(openSettings, new Date('2026-08-10T17:00:00.000Z')),
|
||||||
|
true,
|
||||||
|
'Tuesday 01:00 must be attributed to the previous-day overnight window'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isProductStoreOpenAt(openSettings, new Date('2026-08-10T19:00:00.000Z')),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(isProductStoreOpenAt({
|
||||||
|
...openSettings,
|
||||||
|
manualPausedAt: new Date('2026-08-10T14:00:00.000Z'),
|
||||||
|
manualPausedUntil: null
|
||||||
|
}, new Date('2026-08-10T15:00:00.000Z')), false, 'manual pause overrides hours');
|
||||||
|
assert.throws(
|
||||||
|
() => validateBusinessHours([
|
||||||
|
{ weekday: 1, slotNo: 1, openMinute: 540, closeMinute: 720, crossesMidnight: false },
|
||||||
|
{ weekday: 1, slotNo: 2, openMinute: 600, closeMinute: 780, crossesMidnight: false }
|
||||||
|
]),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
|
||||||
|
);
|
||||||
|
|
||||||
|
class ScriptedConnection {
|
||||||
|
constructor(steps) {
|
||||||
|
this.steps = [...steps];
|
||||||
|
this.committed = false;
|
||||||
|
this.rolledBack = false;
|
||||||
|
this.released = false;
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async beginTransaction() {}
|
||||||
|
async commit() { this.committed = true; }
|
||||||
|
async rollback() { this.rolledBack = true; }
|
||||||
|
release() { this.released = true; }
|
||||||
|
|
||||||
|
async execute(sql, params = []) {
|
||||||
|
this.calls.push({ sql, params });
|
||||||
|
const step = this.steps.shift();
|
||||||
|
assert.ok(step, `Unexpected SQL: ${sql}`);
|
||||||
|
assert.match(sql, step.match);
|
||||||
|
if (step.check) step.check(params, sql);
|
||||||
|
if (step.error) throw step.error;
|
||||||
|
return step.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryFor(connection) {
|
||||||
|
return new ProductCatalogRepository({
|
||||||
|
async getConnection() { return connection; },
|
||||||
|
async execute(sql, params) { return connection.execute(sql, params); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryForConnections(connections) {
|
||||||
|
const pending = [...connections];
|
||||||
|
return new ProductCatalogRepository({
|
||||||
|
async getConnection() {
|
||||||
|
const connection = pending.shift();
|
||||||
|
assert.ok(connection, 'Unexpected transaction');
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
async execute() { throw new Error('This scenario must use transactional connections'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryInput(parentId) {
|
||||||
|
return {
|
||||||
|
parentId,
|
||||||
|
categoryCode: 'DRINKS',
|
||||||
|
name: 'Cold drinks',
|
||||||
|
description: '',
|
||||||
|
imageUrl: '',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const createConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_product_categories/,
|
||||||
|
result: [{ insertId: 31, affectedRows: 1 }, []],
|
||||||
|
check(params) {
|
||||||
|
assert.equal(params[0], '7');
|
||||||
|
assert.equal(params[1], '11');
|
||||||
|
assert.equal(params[3], 'DRINKS');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) {
|
||||||
|
assert.equal(params[2], 'PRODUCT_CATEGORY_CREATED');
|
||||||
|
assert.equal(params[3], 'PRODUCT_CATEGORY');
|
||||||
|
assert.equal(params[4], '31');
|
||||||
|
assert.deepEqual(JSON.parse(params[8]), { storeId: '11', version: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const created = await repositoryFor(createConnection).createCategory(storeActor, '11', {
|
||||||
|
parentId: null,
|
||||||
|
categoryCode: 'DRINKS',
|
||||||
|
name: 'Drinks',
|
||||||
|
description: '',
|
||||||
|
imageUrl: '',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 1
|
||||||
|
});
|
||||||
|
assert.deepEqual(created, { categoryId: '31', version: 1 });
|
||||||
|
assert.equal(createConnection.committed, true);
|
||||||
|
assert.equal(createConnection.rolledBack, false);
|
||||||
|
assert.equal(createConnection.released, true);
|
||||||
|
|
||||||
|
const casConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{ match: /UPDATE qipai_product_categories/, result: [{ affectedRows: 0 }, []] },
|
||||||
|
{ match: /SELECT version FROM qipai_product_categories/, result: [[{ version: 3 }], []] }
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(casConnection).updateCategory(storeActor, '11', '31', 2, {
|
||||||
|
parentId: null,
|
||||||
|
categoryCode: 'DRINKS',
|
||||||
|
name: 'Cold drinks',
|
||||||
|
description: '',
|
||||||
|
imageUrl: '',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 1
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_CATEGORY_VERSION_CONFLICT'
|
||||||
|
);
|
||||||
|
assert.equal(casConnection.committed, false);
|
||||||
|
assert.equal(casConnection.rolledBack, true);
|
||||||
|
assert.equal(casConnection.released, true);
|
||||||
|
|
||||||
|
for (const scenario of [
|
||||||
|
{
|
||||||
|
name: 'two-node cycle',
|
||||||
|
ancestors: [{ id: '32', parentId: '31' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'multi-node cycle',
|
||||||
|
ancestors: [{ id: '32', parentId: '33' }, { id: '33', parentId: '31' }]
|
||||||
|
}
|
||||||
|
]) {
|
||||||
|
const cycleConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
...scenario.ancestors.map((ancestor, index) => ({
|
||||||
|
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[ancestor], []],
|
||||||
|
check(params, sql) {
|
||||||
|
assert.deepEqual(params, ['7', '11', index === 0 ? '32' : '33']);
|
||||||
|
assert.match(sql, /deleted_at IS NULL/);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(cycleConnection).updateCategory(
|
||||||
|
storeActor, '11', '31', 2, categoryInput('32')
|
||||||
|
),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE',
|
||||||
|
scenario.name
|
||||||
|
);
|
||||||
|
assert.equal(cycleConnection.rolledBack, true, scenario.name);
|
||||||
|
assert.equal(cycleConnection.steps.length, 0, scenario.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfParentConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] }
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(selfParentConnection).updateCategory(
|
||||||
|
storeActor, '11', '31', 2, categoryInput('31')
|
||||||
|
),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE'
|
||||||
|
);
|
||||||
|
assert.equal(selfParentConnection.rolledBack, true);
|
||||||
|
|
||||||
|
for (const invalidParent of ['cross-store parent', 'deleted parent']) {
|
||||||
|
const invalidParentConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[], []],
|
||||||
|
check(params, sql) {
|
||||||
|
assert.deepEqual(params, ['7', '11', '32']);
|
||||||
|
assert.match(sql, /store_id = \?/);
|
||||||
|
assert.match(sql, /deleted_at IS NULL/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(invalidParentConnection).updateCategory(
|
||||||
|
storeActor, '11', '31', 2, categoryInput('32')
|
||||||
|
),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_CATEGORY_PARENT_NOT_FOUND',
|
||||||
|
invalidParent
|
||||||
|
);
|
||||||
|
assert.equal(invalidParentConnection.rolledBack, true, invalidParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [bucket, balances] of [
|
||||||
|
['available', { availableQuantity: 1, lockedQuantity: 0, lossQuantity: 0 }],
|
||||||
|
['locked', { availableQuantity: 0, lockedQuantity: 1, lossQuantity: 0 }],
|
||||||
|
['loss', { availableQuantity: 0, lockedQuantity: 0, lossQuantity: 1 }]
|
||||||
|
]) {
|
||||||
|
const stockConnection = new ScriptedConnection([
|
||||||
|
{
|
||||||
|
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '41', version: 4 }], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '51', productId: '41', version: 2 }], []],
|
||||||
|
check(params, sql) {
|
||||||
|
assert.deepEqual(params, ['7', '41', '51']);
|
||||||
|
assert.match(sql, /product_id = \?/);
|
||||||
|
assert.match(sql, /deleted_at IS NULL/);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '71', storeId: '11', skuId: '51', ...balances }], []],
|
||||||
|
check(params, sql) {
|
||||||
|
assert.deepEqual(params, ['7', '51']);
|
||||||
|
assert.match(sql, /ORDER BY store_id, id[\s\S]*FOR UPDATE/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(stockConnection).archiveSku(tenantActor, '41', '51', 2),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_SKU_HAS_INVENTORY_STOCK',
|
||||||
|
`${bucket} stock must block SKU archival`
|
||||||
|
);
|
||||||
|
assert.equal(stockConnection.rolledBack, true, bucket);
|
||||||
|
assert.equal(stockConnection.steps.length, 0, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuVersionConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '51', productId: '41', version: 3 }], []]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(skuVersionConnection).archiveSku(tenantActor, '41', '51', 2),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
|
||||||
|
);
|
||||||
|
assert.equal(skuVersionConnection.rolledBack, true);
|
||||||
|
|
||||||
|
const skuOwnershipConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[], []],
|
||||||
|
check(params) { assert.deepEqual(params, ['7', '41', '51']); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(skuOwnershipConnection).archiveSku(tenantActor, '41', '51', 2),
|
||||||
|
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_SKU_NOT_FOUND'
|
||||||
|
);
|
||||||
|
|
||||||
|
const productStockConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '51', productId: '41', version: 2 }], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{
|
||||||
|
id: '71', storeId: '11', skuId: '51',
|
||||||
|
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 2
|
||||||
|
}], []],
|
||||||
|
check(params, sql) {
|
||||||
|
assert.deepEqual(params, ['7', '41']);
|
||||||
|
assert.match(sql, /ORDER BY s.id, i.store_id, i.id[\s\S]*FOR UPDATE/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(productStockConnection).archiveProduct(tenantActor, '41', 2),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_HAS_INVENTORY_STOCK'
|
||||||
|
);
|
||||||
|
assert.equal(productStockConnection.rolledBack, true);
|
||||||
|
|
||||||
|
const activeSkuConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
|
||||||
|
{
|
||||||
|
match: /deleted_at AS deletedAt[\s\S]*FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '51', productId: '41', version: 2, deletedAt: null }], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{
|
||||||
|
id: '71', storeId: '11', skuId: '51',
|
||||||
|
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
|
||||||
|
}], []]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repositoryFor(activeSkuConnection).archiveProduct(tenantActor, '41', 2),
|
||||||
|
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_HAS_ACTIVE_SKUS'
|
||||||
|
);
|
||||||
|
assert.equal(activeSkuConnection.rolledBack, true);
|
||||||
|
|
||||||
|
const skuArchiveConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '51', productId: '41', version: 2 }], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{
|
||||||
|
id: '71', storeId: '11', skuId: '51',
|
||||||
|
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
|
||||||
|
}], []]
|
||||||
|
},
|
||||||
|
{ match: /UPDATE qipai_product_skus/, result: [{ affectedRows: 1 }, []] },
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) {
|
||||||
|
assert.equal(params[2], 'PRODUCT_SKU_ARCHIVED');
|
||||||
|
assert.equal(params[4], '51');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const productArchiveAfterSkuConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{
|
||||||
|
id: '51', productId: '41', version: 3, deletedAt: new Date('2026-08-11T00:00:00Z')
|
||||||
|
}], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{
|
||||||
|
id: '71', storeId: '11', skuId: '51',
|
||||||
|
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
|
||||||
|
}], []]
|
||||||
|
},
|
||||||
|
{ match: /FROM qipai_product_store_listings/, result: [[{ total: 0 }], []] },
|
||||||
|
{
|
||||||
|
match: /UPDATE qipai_products/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params, sql) {
|
||||||
|
assert.deepEqual(params, ['21', '7', '41', 4]);
|
||||||
|
assert.match(sql, /deleted_at = UTC_TIMESTAMP/);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.equal(params[2], 'PRODUCT_ARCHIVED'); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const reusedProductConnection = new ScriptedConnection([
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_products/,
|
||||||
|
result: [{ insertId: 42, affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.equal(params[1], 'TEA'); }
|
||||||
|
},
|
||||||
|
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
|
||||||
|
]);
|
||||||
|
const reusedSkuConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '42', version: 1 }], []] },
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_product_skus/,
|
||||||
|
result: [{ insertId: 52, affectedRows: 1 }, []],
|
||||||
|
check(params) {
|
||||||
|
assert.equal(params[2], 'TEA-500');
|
||||||
|
assert.equal(params[5], '690000000001');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
|
||||||
|
]);
|
||||||
|
const archiveAndReuseRepository = repositoryForConnections([
|
||||||
|
skuArchiveConnection,
|
||||||
|
productArchiveAfterSkuConnection,
|
||||||
|
reusedProductConnection,
|
||||||
|
reusedSkuConnection
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
await archiveAndReuseRepository.archiveSku(tenantActor, '41', '51', 2),
|
||||||
|
{ skuId: '51', productId: '41', version: 3, archived: true }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await archiveAndReuseRepository.archiveProduct(tenantActor, '41', 4),
|
||||||
|
{ productId: '41', version: 5, archived: true }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await archiveAndReuseRepository.createProduct(tenantActor, {
|
||||||
|
productCode: 'TEA',
|
||||||
|
name: 'Tea replacement',
|
||||||
|
unitName: 'bottle',
|
||||||
|
description: '',
|
||||||
|
coverUrl: '',
|
||||||
|
images: [],
|
||||||
|
deliveryEnabled: true,
|
||||||
|
storageEnabled: false,
|
||||||
|
status: 'DRAFT',
|
||||||
|
sortOrder: 1
|
||||||
|
}),
|
||||||
|
{ productId: '42', version: 1 }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await archiveAndReuseRepository.createSku(tenantActor, '42', {
|
||||||
|
skuCode: 'TEA-500',
|
||||||
|
name: 'Tea 500ml replacement',
|
||||||
|
attributes: { size: '500ml' },
|
||||||
|
barcode: '690000000001',
|
||||||
|
imageUrl: '',
|
||||||
|
salePriceCents: 500,
|
||||||
|
marketPriceCents: 600,
|
||||||
|
costPriceCents: 200,
|
||||||
|
defaultInventoryPolicy: 'TRACKED',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
}),
|
||||||
|
{ skuId: '52', productId: '42', version: 1 }
|
||||||
|
);
|
||||||
|
assert.equal(skuArchiveConnection.committed, true);
|
||||||
|
assert.equal(productArchiveAfterSkuConnection.committed, true);
|
||||||
|
assert.equal(reusedProductConnection.committed, true);
|
||||||
|
assert.equal(reusedSkuConnection.committed, true);
|
||||||
|
|
||||||
|
const settingsConnection = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{ match: /FROM qipai_product_store_settings/, result: [[], []] },
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_product_store_settings/,
|
||||||
|
result: [{ insertId: 41, affectedRows: 1 }, []]
|
||||||
|
},
|
||||||
|
{ match: /UPDATE qipai_product_store_hours/, result: [{ affectedRows: 0 }, []] },
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_product_store_hours/,
|
||||||
|
result: [{ insertId: 51, affectedRows: 1 }, []],
|
||||||
|
check(params) {
|
||||||
|
assert.deepEqual(params.slice(3), [1, 1, 1320, 120, true]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) {
|
||||||
|
assert.equal(params[2], 'PRODUCT_STORE_SETTINGS_UPDATED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const pausedUntil = new Date('2026-08-12T00:00:00.000Z');
|
||||||
|
const settingsResult = await repositoryFor(settingsConnection).putStoreSettings(
|
||||||
|
storeActor,
|
||||||
|
'11',
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
salesStatus: 'OPEN',
|
||||||
|
manualPaused: true,
|
||||||
|
manualPausedUntil: pausedUntil,
|
||||||
|
manualPauseReason: 'inventory count',
|
||||||
|
hours: mondayOvernight
|
||||||
|
},
|
||||||
|
new Date('2026-08-11T00:00:00.000Z')
|
||||||
|
);
|
||||||
|
assert.deepEqual(settingsResult, { settingsId: '41', storeId: '11', version: 1 });
|
||||||
|
assert.equal(settingsConnection.committed, true);
|
||||||
|
|
||||||
|
const firstListingArchive = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{
|
||||||
|
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '61', version: 1 }], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /UPDATE qipai_product_store_listings/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '61', 1]); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.equal(params[4], '61'); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const rebuiltListing = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{
|
||||||
|
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{
|
||||||
|
id: '41', status: 'ACTIVE', deliveryEnabled: true, storageEnabled: true
|
||||||
|
}], []]
|
||||||
|
},
|
||||||
|
{ match: /FROM qipai_product_categories/, result: [[{ id: '31' }], []] },
|
||||||
|
{ match: /INSERT INTO qipai_product_store_listings/, result: [{ insertId: 62, affectedRows: 1 }, []] },
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.equal(params[4], '62'); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const secondListingArchive = new ScriptedConnection([
|
||||||
|
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||||
|
{
|
||||||
|
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
|
||||||
|
result: [[{ id: '62', version: 1 }], []]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /UPDATE qipai_product_store_listings/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '62', 1]); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: /INSERT INTO qipai_audit_logs/,
|
||||||
|
result: [{ affectedRows: 1 }, []],
|
||||||
|
check(params) { assert.equal(params[4], '62'); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const listingLifecycleRepository = repositoryForConnections([
|
||||||
|
firstListingArchive, rebuiltListing, secondListingArchive
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
|
||||||
|
{ listingId: '61', storeId: '11', productId: '41', version: 2, archived: true }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await listingLifecycleRepository.putListing(storeActor, '11', '41', 0, {
|
||||||
|
categoryId: '31',
|
||||||
|
status: 'INACTIVE',
|
||||||
|
fulfillmentMode: 'SELF_SERVICE',
|
||||||
|
sortOrder: 1
|
||||||
|
}),
|
||||||
|
{ listingId: '62', storeId: '11', productId: '41', version: 1 }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
|
||||||
|
{ listingId: '62', storeId: '11', productId: '41', version: 2, archived: true }
|
||||||
|
);
|
||||||
|
assert.equal(firstListingArchive.committed, true);
|
||||||
|
assert.equal(rebuiltListing.committed, true);
|
||||||
|
assert.equal(secondListingArchive.committed, true);
|
||||||
|
|
||||||
|
const skuReadRow = {
|
||||||
|
id: '51',
|
||||||
|
productId: '41',
|
||||||
|
skuCode: 'TEA-500',
|
||||||
|
name: 'Tea 500ml',
|
||||||
|
attributesJson: '{"size":"500ml"}',
|
||||||
|
barcode: '690000000001',
|
||||||
|
imageUrl: '',
|
||||||
|
salePriceCents: 500,
|
||||||
|
marketPriceCents: 600,
|
||||||
|
costPriceCents: 200,
|
||||||
|
defaultInventoryPolicy: 'TRACKED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
version: 2,
|
||||||
|
createdAt: new Date('2026-08-11T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-08-11T00:00:00Z')
|
||||||
|
};
|
||||||
|
function skuReadConnection() {
|
||||||
|
return new ScriptedConnection([
|
||||||
|
{ match: /SELECT id FROM qipai_products/, result: [[{ id: '41' }], []] },
|
||||||
|
{ match: /FROM qipai_product_skus/, result: [[skuReadRow], []] }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const storeAdminSkus = await repositoryFor(skuReadConnection()).listSkus(storeActor, '41');
|
||||||
|
assert.equal(storeAdminSkus.length, 1);
|
||||||
|
assert.equal(Object.hasOwn(storeAdminSkus[0], 'costPriceCents'), false,
|
||||||
|
'store administrators must not receive product cost');
|
||||||
|
const tenantManagerSkus = await repositoryFor(skuReadConnection()).listSkus(tenantActor, '41');
|
||||||
|
assert.equal(tenantManagerSkus[0].costPriceCents, 200,
|
||||||
|
'tenant managers retain product cost visibility');
|
||||||
|
|
||||||
|
const neverUsedPool = {
|
||||||
|
async execute() { throw new Error('scope checks must happen before SQL'); },
|
||||||
|
async getConnection() { throw new Error('scope checks must happen before SQL'); }
|
||||||
|
};
|
||||||
|
const guardedRepository = new ProductCatalogRepository(neverUsedPool);
|
||||||
|
await assert.rejects(
|
||||||
|
() => guardedRepository.listProducts({
|
||||||
|
...storeActor,
|
||||||
|
access: {
|
||||||
|
roles: ['STAFF'], capabilities: ['product.catalog.read'], storeIds: ['11']
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => guardedRepository.listSkus({
|
||||||
|
...storeActor,
|
||||||
|
access: {
|
||||||
|
roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: []
|
||||||
|
}
|
||||||
|
}, '41'),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => guardedRepository.listCategories({
|
||||||
|
...storeActor,
|
||||||
|
access: { ...storeActor.access, storeIds: ['12'] }
|
||||||
|
}, '11'),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => guardedRepository.createProduct(storeActor, {
|
||||||
|
productCode: 'TEA',
|
||||||
|
name: 'Tea',
|
||||||
|
unitName: 'bottle',
|
||||||
|
description: '',
|
||||||
|
coverUrl: '',
|
||||||
|
images: [],
|
||||||
|
deliveryEnabled: true,
|
||||||
|
storageEnabled: false,
|
||||||
|
status: 'DRAFT',
|
||||||
|
sortOrder: 1
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ProductCatalogError
|
||||||
|
&& error.code === 'PRODUCT_TENANT_WRITE_FORBIDDEN'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('PASS: M09-D1 product catalog covers global-read scope, cost redaction, category cycles, archive locks, listing lifecycle, hours, CAS and audit.');
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||||
|
import { ProductCatalogError } from '../dist/products/product-catalog-repository.js';
|
||||||
|
import { registerProductRoutes } from '../dist/routes/products.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-product-route-jwt-secret-32';
|
||||||
|
const token = signAccessToken({
|
||||||
|
sub: '21',
|
||||||
|
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||||
|
tid: '7',
|
||||||
|
aid: '9',
|
||||||
|
rv: 1
|
||||||
|
}, secret, 900);
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
let currentAccess = {
|
||||||
|
roles: ['TENANT_ADMIN'],
|
||||||
|
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
|
||||||
|
storeIds: []
|
||||||
|
};
|
||||||
|
const record = (method, result) => async (...args) => {
|
||||||
|
calls.push({ method, args });
|
||||||
|
return typeof result === 'function' ? result(...args) : result;
|
||||||
|
};
|
||||||
|
const repository = {
|
||||||
|
listCategories: record('listCategories', []),
|
||||||
|
createCategory: record('createCategory', { categoryId: '31', version: 1 }),
|
||||||
|
updateCategory: record('updateCategory', { categoryId: '31', version: 2 }),
|
||||||
|
archiveCategory: record('archiveCategory', { categoryId: '31', version: 3, archived: true }),
|
||||||
|
listProducts: record('listProducts', []),
|
||||||
|
createProduct: record('createProduct', { productId: '41', version: 1 }),
|
||||||
|
updateProduct: record('updateProduct', { productId: '41', version: 2 }),
|
||||||
|
archiveProduct: record('archiveProduct', { productId: '41', version: 3, archived: true }),
|
||||||
|
listSkus: record('listSkus', []),
|
||||||
|
createSku: record('createSku', { skuId: '51', productId: '41', version: 1 }),
|
||||||
|
updateSku: record('updateSku', { skuId: '51', productId: '41', version: 2 }),
|
||||||
|
archiveSku: record('archiveSku', { skuId: '51', productId: '41', version: 3, archived: true }),
|
||||||
|
listListings: record('listListings', []),
|
||||||
|
putListing: record('putListing', { listingId: '61', storeId: '11', productId: '41', version: 1 }),
|
||||||
|
archiveListing: record('archiveListing', {
|
||||||
|
listingId: '61', storeId: '11', productId: '41', version: 2, archived: true
|
||||||
|
}),
|
||||||
|
getStoreSettings: record('getStoreSettings', { storeId: '11', version: 0, hours: [] }),
|
||||||
|
putStoreSettings: record('putStoreSettings', { settingsId: '71', storeId: '11', version: 1 })
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
app.decorateRequest('traceId', '');
|
||||||
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
|
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||||
|
reply.header('x-trace-id', request.traceId);
|
||||||
|
});
|
||||||
|
await registerProductRoutes(app, {
|
||||||
|
repository,
|
||||||
|
jwtSecret: secret,
|
||||||
|
authRepository: {
|
||||||
|
async validateSession() {
|
||||||
|
return {
|
||||||
|
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||||
|
tenantId: '7',
|
||||||
|
platformAppId: '9',
|
||||||
|
expiresAt: new Date(Date.now() + 60000),
|
||||||
|
user: {
|
||||||
|
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
|
||||||
|
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accessControl: {
|
||||||
|
async getAccessProfile() { return currentAccess; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unauthorized = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products'
|
||||||
|
});
|
||||||
|
assert.equal(unauthorized.statusCode, 401);
|
||||||
|
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||||
|
|
||||||
|
currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
|
||||||
|
const forbidden = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(forbidden.statusCode, 403);
|
||||||
|
assert.equal(forbidden.json().code, 'PRODUCT_READ_FORBIDDEN');
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STAFF'],
|
||||||
|
capabilities: ['product.catalog.read'],
|
||||||
|
storeIds: ['11']
|
||||||
|
};
|
||||||
|
const globalCallsBeforeStaff = calls.length;
|
||||||
|
const staffGlobalForbidden = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(staffGlobalForbidden.statusCode, 403);
|
||||||
|
assert.equal(staffGlobalForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
|
||||||
|
assert.equal(calls.length, globalCallsBeforeStaff, 'forbidden global reads must not reach repository');
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
})).statusCode, 200, 'store-scoped listing reads remain available to scoped staff');
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STORE_ADMIN'],
|
||||||
|
capabilities: ['product.catalog.read'],
|
||||||
|
storeIds: []
|
||||||
|
};
|
||||||
|
const emptyStoreAdminForbidden = await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products/41/skus',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(emptyStoreAdminForbidden.statusCode, 403);
|
||||||
|
assert.equal(emptyStoreAdminForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN');
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['STORE_ADMIN'],
|
||||||
|
capabilities: ['product.catalog.read'],
|
||||||
|
storeIds: ['11']
|
||||||
|
};
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` }
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products/41/skus',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
})).statusCode, 200);
|
||||||
|
|
||||||
|
currentAccess = {
|
||||||
|
roles: ['TENANT_ADMIN'],
|
||||||
|
capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'],
|
||||||
|
storeIds: []
|
||||||
|
};
|
||||||
|
const authHeaders = { authorization: `Bearer ${token}`, 'x-trace-id': 'm09d1-product-route' };
|
||||||
|
|
||||||
|
const categoryPayload = {
|
||||||
|
parentId: null,
|
||||||
|
categoryCode: 'DRINKS',
|
||||||
|
name: 'Drinks',
|
||||||
|
description: '',
|
||||||
|
imageUrl: '',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 1
|
||||||
|
};
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/stores/11/product-categories?includeInactive=false',
|
||||||
|
headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/stores/11/product-categories',
|
||||||
|
headers: authHeaders, payload: categoryPayload
|
||||||
|
})).statusCode, 201);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/stores/11/product-categories/31',
|
||||||
|
headers: authHeaders, payload: { ...categoryPayload, expectedVersion: 1 }
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'DELETE', url: '/admin-api/stores/11/product-categories/31?expectedVersion=2',
|
||||||
|
headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
|
||||||
|
const productPayload = {
|
||||||
|
productCode: 'TEA',
|
||||||
|
name: 'Tea',
|
||||||
|
unitName: 'bottle',
|
||||||
|
description: '',
|
||||||
|
coverUrl: '',
|
||||||
|
images: [],
|
||||||
|
deliveryEnabled: true,
|
||||||
|
storageEnabled: true,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 1
|
||||||
|
};
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products?status=ACTIVE&search=Tea', headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/products', headers: authHeaders, payload: productPayload
|
||||||
|
})).statusCode, 201);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
|
||||||
|
payload: { ...productPayload, expectedVersion: 1 }
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'DELETE', url: '/admin-api/products/41?expectedVersion=2', headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
|
||||||
|
const skuPayload = {
|
||||||
|
skuCode: 'TEA-500',
|
||||||
|
name: 'Tea 500ml',
|
||||||
|
attributes: { size: '500ml' },
|
||||||
|
barcode: '690000000001',
|
||||||
|
imageUrl: '',
|
||||||
|
salePriceCents: 500,
|
||||||
|
marketPriceCents: 600,
|
||||||
|
costPriceCents: 200,
|
||||||
|
defaultInventoryPolicy: 'TRACKED',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
};
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/products/41/skus?includeInactive=true', headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'POST', url: '/admin-api/products/41/skus', headers: authHeaders, payload: skuPayload
|
||||||
|
})).statusCode, 201);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/products/41/skus/51', headers: authHeaders,
|
||||||
|
payload: { ...skuPayload, expectedVersion: 1 }
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'DELETE', url: '/admin-api/products/41/skus/51?expectedVersion=2',
|
||||||
|
headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true',
|
||||||
|
headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/stores/11/product-listings/41', headers: authHeaders,
|
||||||
|
payload: {
|
||||||
|
expectedVersion: 0,
|
||||||
|
categoryId: '31',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
fulfillmentMode: 'BOTH',
|
||||||
|
sortOrder: 1
|
||||||
|
}
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'DELETE', url: '/admin-api/stores/11/product-listings/41?expectedVersion=1',
|
||||||
|
headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'GET', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders
|
||||||
|
})).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
|
||||||
|
payload: {
|
||||||
|
expectedVersion: 0,
|
||||||
|
salesStatus: 'OPEN',
|
||||||
|
manualPaused: true,
|
||||||
|
manualPausedUntil: '2026-08-12T00:00:00.000Z',
|
||||||
|
manualPauseReason: 'inventory count',
|
||||||
|
hours: [{
|
||||||
|
weekday: 1, slotNo: 1, openMinute: 1320, closeMinute: 120, crossesMidnight: true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})).statusCode, 200);
|
||||||
|
|
||||||
|
const createdSkuCall = calls.find((call) => call.method === 'createSku');
|
||||||
|
assert.equal(createdSkuCall.args[0].tenantId, '7');
|
||||||
|
assert.equal(createdSkuCall.args[0].traceId, 'm09d1-product-route');
|
||||||
|
assert.equal(createdSkuCall.args[2].defaultInventoryPolicy, 'TRACKED');
|
||||||
|
const settingsCall = calls.find((call) => call.method === 'putStoreSettings');
|
||||||
|
assert.equal(settingsCall.args[2], 0);
|
||||||
|
assert.equal(settingsCall.args[3].hours[0].crossesMidnight, true);
|
||||||
|
|
||||||
|
repository.updateProduct = async () => {
|
||||||
|
throw new ProductCatalogError('PRODUCT_VERSION_CONFLICT');
|
||||||
|
};
|
||||||
|
const conflict = await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/products/41', headers: authHeaders,
|
||||||
|
payload: { ...productPayload, expectedVersion: 1 }
|
||||||
|
});
|
||||||
|
assert.equal(conflict.statusCode, 409);
|
||||||
|
assert.equal(conflict.json().code, 'PRODUCT_VERSION_CONFLICT');
|
||||||
|
|
||||||
|
const invalid = await app.inject({
|
||||||
|
method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders,
|
||||||
|
payload: {
|
||||||
|
expectedVersion: 0,
|
||||||
|
salesStatus: 'OPEN',
|
||||||
|
manualPaused: true,
|
||||||
|
manualPauseReason: '',
|
||||||
|
hours: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(invalid.statusCode, 400);
|
||||||
|
assert.equal(invalid.json().code, 'INVALID_PRODUCT_REQUEST');
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
console.log('PASS: M09-D1 product routes cover global/store read boundaries, CRUD, CAS, auth and business settings.');
|
||||||
@@ -29,5 +29,9 @@ assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
|
|||||||
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
|
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 语义。
|
||||||
@@ -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-22:M05-C 增加 `QIPAI_THIRD_PARTY_CREDENTIALS`、人工/Mock/API 模式、回调 HMAC 和券码哈希规则。
|
- 2026-06-22:M05-C 增加 `QIPAI_THIRD_PARTY_CREDENTIALS`、人工/Mock/API 模式、回调 HMAC 和券码哈希规则。
|
||||||
- 2026-06-22:M05-D 增加门店收款账户、`profitShareReceivers` 环境映射、分账授权状态和非生产 Mock 门禁。
|
- 2026-06-22:M05-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-10:M06-C-R1 增加门锁烟测 subtype 变量,确保实物 `CtrlDevice` 始终携带 14/15;未增加任何仓库内明文凭据。
|
- 2026-08-10:M06-C-R1 增加门锁烟测 subtype 变量,确保实物 `CtrlDevice` 始终携带 14/15;未增加任何仓库内明文凭据。
|
||||||
|
|||||||
+12
-11
@@ -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 业务 API;M09-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 业务 API;M09-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 B,M09-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-D1,M10 尚未进入。
|
- 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-D2,M10 尚未进入。
|
||||||
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。
|
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。
|
||||||
|
|
||||||
## 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-D1;interrupted 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`,下一阶段建立商品订单、订单项快照、支付衔接与库存占用生命周期。生产触发器前置属于部署强制条件,不是可绕过的外部阻塞。
|
||||||
@@ -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、门店上架、销售设置、跨日时段、库存、幂等请求和不可变流水。增量迁移可跳过历史 marker,interrupted 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 起业务资源继承门店范围。 |
|
||||||
|
|||||||
@@ -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_EXTERNAL;M08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNAL;M08-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.');
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}`?') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user