feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -62,6 +62,8 @@ import {
|
||||
type SystemOperationsRouteOptions
|
||||
} from './routes/system-operations.js';
|
||||
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
|
||||
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
|
||||
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -90,6 +92,8 @@ export interface BuildAppOptions {
|
||||
franchise?: FranchiseRouteOptions;
|
||||
systemOperations?: SystemOperationsRouteOptions;
|
||||
adminAuth?: AdminAuthRouteOptions;
|
||||
products?: ProductRouteOptions;
|
||||
inventory?: InventoryRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -206,6 +210,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.adminAuth) {
|
||||
await registerAdminAuthRoutes(app, options.adminAuth);
|
||||
}
|
||||
if (options.products) {
|
||||
await registerProductRoutes(app, options.products);
|
||||
}
|
||||
if (options.inventory) {
|
||||
await registerInventoryRoutes(app, options.inventory);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -141,14 +141,19 @@ export class AuthRepository {
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read'))
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -37,7 +37,8 @@ export class RbacRepository {
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read'))
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
@@ -45,11 +46,15 @@ export class RbacRepository {
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface MigrationPlan {
|
||||
file: string;
|
||||
checksum: string;
|
||||
statements: readonly string[];
|
||||
migrations?: readonly MigrationFilePlan[];
|
||||
}
|
||||
|
||||
export interface MigrationFilePlan {
|
||||
version: string;
|
||||
name: string;
|
||||
file: string;
|
||||
checksum: string;
|
||||
statements: readonly string[];
|
||||
}
|
||||
|
||||
export interface MigrationExecutionResult extends MigrationPlan {
|
||||
@@ -18,6 +27,12 @@ export interface MigrationExecutionResult extends MigrationPlan {
|
||||
affectedRows: number;
|
||||
}
|
||||
|
||||
type MigrationQueryExecutor = Pick<MySqlPool, 'query'>;
|
||||
type MigrationPool = MigrationQueryExecutor & Partial<Pick<MySqlPool, 'getConnection'>>;
|
||||
|
||||
const migrationLockExpression =
|
||||
"CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
up: [
|
||||
@@ -55,7 +70,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -92,9 +108,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||
@@ -223,17 +241,30 @@ export async function loadMigrationPlan(direction: MigrationDirection): Promise<
|
||||
relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
|
||||
);
|
||||
const sql = sqlParts.join('\n');
|
||||
const migrations = relativeFiles.map((file, index) => {
|
||||
const match = /(?:^|\/)(\d+)_([a-z0-9_]+)\.(?:up|verify|down)\.sql$/iu.exec(file);
|
||||
if (!match) throw new Error(`Invalid migration file name: ${file}`);
|
||||
const migrationSql = sqlParts[index];
|
||||
return {
|
||||
version: match[1],
|
||||
name: match[2],
|
||||
file,
|
||||
checksum: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
statements: splitSqlStatements(migrationSql)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
direction,
|
||||
file: relativeFiles.join(','),
|
||||
checksum: createHash('sha256').update(sql).digest('hex'),
|
||||
statements: splitSqlStatements(sql)
|
||||
statements: splitSqlStatements(sql),
|
||||
migrations
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeMigrationPlan(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
pool: MigrationPool,
|
||||
plan: MigrationPlan,
|
||||
dryRun = false
|
||||
): Promise<MigrationExecutionResult> {
|
||||
@@ -241,9 +272,64 @@ export async function executeMigrationPlan(
|
||||
return { ...plan, executed: false, affectedRows: 0 };
|
||||
}
|
||||
|
||||
if (pool.getConnection) {
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
let executionFailed = false;
|
||||
try {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT GET_LOCK(${migrationLockExpression}, 30) AS acquired`
|
||||
);
|
||||
const acquired = Array.isArray(rows)
|
||||
? Number((rows[0] as Record<string, unknown> | undefined)?.acquired ?? 0)
|
||||
: 0;
|
||||
if (acquired !== 1) {
|
||||
throw new Error('MIGRATION_LOCK_TIMEOUT: another migration process is still running.');
|
||||
}
|
||||
lockAcquired = true;
|
||||
return await executeMigrationPlanUnlocked(connection, plan);
|
||||
} catch (error) {
|
||||
executionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
let releaseError: unknown;
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT RELEASE_LOCK(${migrationLockExpression}) AS released`
|
||||
);
|
||||
const released = Array.isArray(rows)
|
||||
? Number((rows[0] as Record<string, unknown> | undefined)?.released ?? 0)
|
||||
: 0;
|
||||
if (released !== 1) {
|
||||
releaseError = new Error('MIGRATION_LOCK_RELEASE_FAILED: migration lock was lost.');
|
||||
}
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
}
|
||||
}
|
||||
connection.release();
|
||||
if (!executionFailed && releaseError) throw releaseError;
|
||||
}
|
||||
}
|
||||
|
||||
return executeMigrationPlanUnlocked(pool, plan);
|
||||
}
|
||||
|
||||
async function executeMigrationPlanUnlocked(
|
||||
pool: MigrationQueryExecutor,
|
||||
plan: MigrationPlan
|
||||
): Promise<MigrationExecutionResult> {
|
||||
|
||||
if (plan.direction === 'up' && plan.migrations?.length) {
|
||||
return executeVersionedUpPlan(pool, plan);
|
||||
}
|
||||
|
||||
if (plan.direction === 'up') await assertTriggerConfiguration(pool, plan.statements);
|
||||
|
||||
let affectedRows = 0;
|
||||
for (const [index, statement] of plan.statements.entries()) {
|
||||
const [result] = await pool.query(statement);
|
||||
const result = await queryMigrationStatement(pool, statement);
|
||||
if (plan.direction === 'verify') {
|
||||
const minimumRows = [
|
||||
10, 26, 1,
|
||||
@@ -279,7 +365,8 @@ export async function executeMigrationPlan(
|
||||
1, 2, 3, 1,
|
||||
1, 2, 3, 1,
|
||||
3, 7, 3, 1,
|
||||
2, 8, 4, 1
|
||||
2, 8, 4, 1,
|
||||
9, 63, 18, 28, 15, 2, 4, 1, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
@@ -297,3 +384,235 @@ export async function executeMigrationPlan(
|
||||
|
||||
return { ...plan, executed: true, affectedRows };
|
||||
}
|
||||
|
||||
interface AppliedMigrationRow extends Record<string, unknown> {
|
||||
version?: unknown;
|
||||
name?: unknown;
|
||||
checksum?: unknown;
|
||||
}
|
||||
|
||||
async function executeVersionedUpPlan(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
plan: MigrationPlan
|
||||
): Promise<MigrationExecutionResult> {
|
||||
const migrations = plan.migrations ?? [];
|
||||
const appliedRows = await readAppliedMigrations(pool);
|
||||
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
|
||||
for (const migration of migrations) {
|
||||
const row = applied.get(migration.version);
|
||||
if (!row) continue;
|
||||
if (String(row.name) !== migration.name) {
|
||||
throw new Error(
|
||||
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
|
||||
);
|
||||
}
|
||||
const storedChecksum = String(row.checksum ?? '');
|
||||
if (storedChecksum && storedChecksum !== migration.checksum) {
|
||||
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.version));
|
||||
await assertTriggerConfiguration(
|
||||
pool,
|
||||
pending.flatMap((migration) => [...migration.statements])
|
||||
);
|
||||
let affectedRows = 0;
|
||||
for (const migration of pending) {
|
||||
for (const statement of migration.statements) {
|
||||
const result = await queryMigrationStatement(pool, statement);
|
||||
if (result && typeof result === 'object' && 'affectedRows' in result) {
|
||||
const value = Reflect.get(result, 'affectedRows');
|
||||
if (typeof value === 'number') affectedRows += value;
|
||||
}
|
||||
}
|
||||
const [markerRows] = await pool.query(
|
||||
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
|
||||
[migration.version]
|
||||
);
|
||||
const marker = Array.isArray(markerRows)
|
||||
? markerRows[0] as Record<string, unknown> | undefined
|
||||
: undefined;
|
||||
if (!marker || String(marker.name) !== migration.name) {
|
||||
throw new Error(
|
||||
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
|
||||
);
|
||||
}
|
||||
await storeMigrationChecksum(pool, migration);
|
||||
}
|
||||
|
||||
for (const migration of migrations) {
|
||||
await storeMigrationChecksum(pool, migration);
|
||||
}
|
||||
return { ...plan, executed: true, affectedRows };
|
||||
}
|
||||
|
||||
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
|
||||
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object'
|
||||
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeMigrationChecksum(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
migration: MigrationFilePlan
|
||||
) {
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
`UPDATE qipai_schema_migrations
|
||||
SET checksum = ?
|
||||
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
|
||||
[migration.checksum, migration.version, migration.checksum]
|
||||
);
|
||||
if (result && typeof result === 'object'
|
||||
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
|
||||
[migration.version]
|
||||
);
|
||||
const stored = Array.isArray(rows)
|
||||
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
|
||||
: '';
|
||||
if (stored && stored !== migration.checksum) {
|
||||
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object'
|
||||
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTriggerConfiguration(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statements: readonly string[]
|
||||
) {
|
||||
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT @@GLOBAL.log_bin AS logBin,
|
||||
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
|
||||
);
|
||||
const setting = Array.isArray(rows)
|
||||
? rows[0] as Record<string, unknown> | undefined
|
||||
: undefined;
|
||||
if (Number(setting?.logBin ?? 0) === 1
|
||||
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
|
||||
throw new Error(
|
||||
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
|
||||
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryMigrationStatement(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string
|
||||
) {
|
||||
try {
|
||||
const [result] = await pool.query(statement);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|
||||
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|
||||
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function isMissingM09D1DownObject(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string,
|
||||
error: unknown
|
||||
) {
|
||||
if (!error || typeof error !== 'object'
|
||||
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
|
||||
|
||||
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
|
||||
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
|
||||
: null;
|
||||
if (indexTarget) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = '${indexTarget.table}'
|
||||
AND index_name = '${indexTarget.index}'
|
||||
LIMIT 1`
|
||||
);
|
||||
return Array.isArray(rows) && rows.length === 0;
|
||||
}
|
||||
|
||||
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_schema_migrations'
|
||||
AND column_name = 'checksum'
|
||||
LIMIT 1`
|
||||
);
|
||||
return Array.isArray(rows) && rows.length === 0;
|
||||
}
|
||||
|
||||
async function isMatchingM09D1ChecksumColumn(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string,
|
||||
error: unknown
|
||||
) {
|
||||
if (!error || typeof error !== 'object'
|
||||
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|
||||
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
|
||||
is_nullable AS isNullable, column_default AS columnDefault,
|
||||
collation_name AS collationName
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_schema_migrations'
|
||||
AND column_name = 'checksum'`
|
||||
);
|
||||
const column = Array.isArray(rows)
|
||||
? rows[0] as Record<string, unknown> | undefined
|
||||
: undefined;
|
||||
return column?.dataType === 'char'
|
||||
&& Number(column.maxLength) === 64
|
||||
&& column.isNullable === 'NO'
|
||||
&& String(column.columnDefault ?? '') === ''
|
||||
&& column.collationName === 'ascii_bin';
|
||||
}
|
||||
|
||||
async function isMatchingM09D1ScopeIndex(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string,
|
||||
error: unknown
|
||||
) {
|
||||
if (!error || typeof error !== 'object'
|
||||
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
|
||||
return false;
|
||||
}
|
||||
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
|
||||
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
|
||||
: null;
|
||||
if (!target) return false;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT non_unique AS nonUnique,
|
||||
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = '${target.table}'
|
||||
AND index_name = '${target.index}'
|
||||
GROUP BY non_unique`
|
||||
);
|
||||
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
|
||||
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,8 @@ function adminAccess(access: AccessProfile) {
|
||||
const storeRead = tenant || access.capabilities.includes('store.operation.read');
|
||||
const menus = [
|
||||
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
|
||||
...(tenant || access.capabilities.includes('product.catalog.read')
|
||||
|| access.capabilities.includes('inventory.read') ? ['products'] : []),
|
||||
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
|
||||
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
|
||||
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
|
||||
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
|
||||
const version = z.number().int().min(1).max(4_294_967_295);
|
||||
const createVersion = z.number().int().min(0).max(4_294_967_295);
|
||||
const quantity = z.number().int().min(1).max(1_000_000_000);
|
||||
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
|
||||
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
|
||||
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
|
||||
const reason = z.string().trim().min(1).max(512);
|
||||
const stockParams = z.object({ skuId: id }).strict();
|
||||
const ledgerParams = z.object({ inventoryId: id }).strict();
|
||||
const stockListQuery = z.object({
|
||||
storeId: id,
|
||||
page,
|
||||
pageSize,
|
||||
skuId: id.optional(),
|
||||
productId: id.optional(),
|
||||
search: z.string().trim().max(64).optional()
|
||||
}).strict();
|
||||
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
|
||||
const mutationBase = {
|
||||
storeId: id,
|
||||
requestId,
|
||||
reason,
|
||||
expectedVersion: version.optional()
|
||||
};
|
||||
const policyBody = z.object({
|
||||
...mutationBase,
|
||||
expectedVersion: createVersion,
|
||||
policyType: z.enum(['TRACKED', 'UNLIMITED']),
|
||||
lowStockThreshold: nonNegativeQuantity
|
||||
}).strict();
|
||||
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
|
||||
const adjustmentBody = z.object({
|
||||
...mutationBase,
|
||||
availableDelta: delta,
|
||||
lossDelta: delta.default(0)
|
||||
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
|
||||
const stocktakeBody = z.object({
|
||||
...mutationBase,
|
||||
expectedVersion: version,
|
||||
availableQuantity: nonNegativeQuantity,
|
||||
lossQuantity: nonNegativeQuantity
|
||||
}).strict();
|
||||
const lossBody = z.object({ ...mutationBase, quantity }).strict();
|
||||
|
||||
export interface InventoryRouteOptions {
|
||||
service: Pick<InventoryService,
|
||||
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
|
||||
| 'stocktake' | 'recordLoss'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerInventoryRoutes(
|
||||
app: FastifyInstance,
|
||||
options: InventoryRouteOptions
|
||||
) {
|
||||
app.get('/admin-api/inventory/stocks', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, false);
|
||||
const query = stockListQuery.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listStocks(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, false);
|
||||
const params = ledgerParams.safeParse(request.params);
|
||||
const query = ledgerQuery.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listLedger(actor, {
|
||||
inventoryId: params.data.inventoryId,
|
||||
...query.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = policyBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.configurePolicy(actor, {
|
||||
skuId: params.data.skuId,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = inboundBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = adjustmentBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = stocktakeBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
|
||||
const actor = await requireInventoryActor(request, reply, options, true);
|
||||
const params = stockParams.safeParse(request.params);
|
||||
const body = lossBody.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return inventoryResponse(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireInventoryActor(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: InventoryRouteOptions,
|
||||
write: boolean
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID',
|
||||
message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId,
|
||||
auth.session.user.id
|
||||
);
|
||||
const manager = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const allowed = manager || (write
|
||||
? access.capabilities.includes('inventory.adjust')
|
||||
: access.capabilities.includes('inventory.read')
|
||||
|| access.capabilities.includes('inventory.adjust'));
|
||||
if (!allowed) {
|
||||
reply.status(403).send({
|
||||
code: 'INVENTORY_OPERATION_FORBIDDEN',
|
||||
message: 'Inventory permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function inventoryResponse(
|
||||
reply: FastifyReply,
|
||||
traceId: string,
|
||||
work: () => Promise<unknown>
|
||||
) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof InventoryError)) throw error;
|
||||
const status = inventoryErrorStatus(error.code);
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The inventory operation is not allowed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function inventoryErrorStatus(code: string) {
|
||||
if (code.endsWith('_FORBIDDEN')) return 403;
|
||||
if (code.endsWith('_NOT_FOUND')) return 404;
|
||||
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|
||||
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
|
||||
return 400;
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_INVENTORY_REQUEST',
|
||||
message: 'The inventory request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import {
|
||||
ProductCatalogError,
|
||||
type ProductCatalogRepository
|
||||
} from '../products/product-catalog-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const version = z.coerce.number().int().min(0).max(0xffffffff);
|
||||
const code = z.string().trim().min(1).max(64)
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
||||
const storeParamsSchema = z.object({ storeId: id }).strict();
|
||||
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
|
||||
const productParamsSchema = z.object({ productId: id }).strict();
|
||||
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
|
||||
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
|
||||
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
|
||||
const includeInactiveQuerySchema = z.object({
|
||||
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
|
||||
}).strict();
|
||||
|
||||
const categoryFields = {
|
||||
parentId: id.nullable().optional(),
|
||||
categoryCode: code,
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: z.string().trim().max(1024).default(''),
|
||||
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
};
|
||||
const createCategorySchema = z.object(categoryFields).strict();
|
||||
const updateCategorySchema = z.object({
|
||||
...categoryFields,
|
||||
expectedVersion: version.min(1)
|
||||
}).strict();
|
||||
|
||||
const productFields = {
|
||||
productCode: code,
|
||||
name: z.string().trim().min(1).max(128),
|
||||
unitName: z.string().trim().min(1).max(32),
|
||||
description: z.string().trim().max(10000).default(''),
|
||||
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||
images: z.array(z.string().url().max(512)).max(20).default([]),
|
||||
deliveryEnabled: z.boolean().default(true),
|
||||
storageEnabled: z.boolean().default(false),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
};
|
||||
const createProductSchema = z.object(productFields).strict();
|
||||
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
|
||||
const productListQuerySchema = z.object({
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
|
||||
search: z.string().trim().min(1).max(128).optional()
|
||||
}).strict();
|
||||
|
||||
const attributesSchema = z.record(
|
||||
z.string().trim().min(1).max(64),
|
||||
z.string().trim().max(128)
|
||||
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
|
||||
const skuFields = {
|
||||
skuCode: code,
|
||||
name: z.string().trim().min(1).max(128),
|
||||
attributes: attributesSchema.default({}),
|
||||
barcode: z.string().trim().max(64).default(''),
|
||||
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||
salePriceCents: z.number().int().min(0).max(100000000),
|
||||
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
|
||||
costPriceCents: z.number().int().min(0).max(100000000).default(0),
|
||||
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
|
||||
};
|
||||
const createSkuSchema = z.object(skuFields).strict();
|
||||
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
|
||||
|
||||
const listingSchema = z.object({
|
||||
expectedVersion: version,
|
||||
categoryId: id,
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
|
||||
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
|
||||
salesStartAt: z.coerce.date().nullable().optional(),
|
||||
salesEndAt: z.coerce.date().nullable().optional(),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|
||||
|| value.salesEndAt > value.salesStartAt, {
|
||||
message: 'salesEndAt must be later than salesStartAt'
|
||||
});
|
||||
|
||||
const hourSchema = z.object({
|
||||
weekday: z.number().int().min(1).max(7),
|
||||
slotNo: z.number().int().min(1).max(8),
|
||||
openMinute: z.number().int().min(0).max(1439),
|
||||
closeMinute: z.number().int().min(0).max(1439),
|
||||
crossesMidnight: z.boolean()
|
||||
}).strict();
|
||||
const settingsSchema = z.object({
|
||||
expectedVersion: version,
|
||||
salesStatus: z.enum(['OPEN', 'CLOSED']),
|
||||
manualPaused: z.boolean(),
|
||||
manualPausedUntil: z.coerce.date().nullable().optional(),
|
||||
manualPauseReason: z.string().trim().max(512).default(''),
|
||||
hours: z.array(hourSchema).max(56).default([])
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.manualPaused && value.manualPauseReason.length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['manualPauseReason'],
|
||||
message: 'manualPauseReason is required while manually paused'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export interface ProductRouteOptions {
|
||||
repository: Pick<ProductCatalogRepository,
|
||||
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
|
||||
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
|
||||
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
|
||||
| 'listListings' | 'putListing' | 'archiveListing'
|
||||
| 'getStoreSettings' | 'putStoreSettings'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
|
||||
for (const path of [
|
||||
'/admin-api/stores/:storeId/product-categories',
|
||||
'/app-api/management/stores/:storeId/product-categories'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listCategories(
|
||||
actor, params.data.storeId, query.data.includeInactive
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const body = createCategorySchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = categoryParamsSchema.safeParse(request.params);
|
||||
const body = updateCategorySchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateCategory(
|
||||
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
|
||||
async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = categoryParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveCategory(
|
||||
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/products', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
|
||||
const query = productListQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listProducts(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/products', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const body = createProductSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.createProduct(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/products/:productId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const body = updateProductSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateProduct(
|
||||
actor, params.data.productId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/products/:productId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveProduct(
|
||||
actor, params.data.productId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listSkus(
|
||||
actor, params.data.productId, query.data.includeInactive
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const body = createSkuSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.createSku(actor, params.data.productId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = skuParamsSchema.safeParse(request.params);
|
||||
const body = updateSkuSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateSku(
|
||||
actor, params.data.productId, params.data.skuId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = skuParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveSku(
|
||||
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
for (const path of [
|
||||
'/admin-api/stores/:storeId/product-listings',
|
||||
'/app-api/management/stores/:storeId/product-listings'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listListings(
|
||||
actor, params.data.storeId, query.data.includeInactive
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = listingParamsSchema.safeParse(request.params);
|
||||
const body = listingSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.putListing(
|
||||
actor, params.data.storeId, params.data.productId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
|
||||
async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = listingParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveListing(
|
||||
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
for (const path of [
|
||||
'/admin-api/stores/:storeId/product-sales-settings',
|
||||
'/app-api/management/stores/:storeId/product-sales-settings'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getStoreSettings(actor, params.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const body = settingsSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.putStoreSettings(
|
||||
actor, params.data.storeId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: ProductRouteOptions,
|
||||
mode: 'read' | 'write',
|
||||
scope: 'store' | 'global-catalog' = 'store'
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
|
||||
const allowed = access.capabilities.includes(capability)
|
||||
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|
||||
|| access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!allowed) {
|
||||
reply.status(403).send({
|
||||
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
|
||||
message: 'Product catalog permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (scope === 'global-catalog') {
|
||||
const tenantManager = access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
|
||||
&& access.storeIds.length > 0
|
||||
&& (access.capabilities.includes('product.catalog.read')
|
||||
|| access.capabilities.includes('product.catalog.write'));
|
||||
if (!tenantManager && !authorizedStoreAdmin) {
|
||||
reply.status(403).send({
|
||||
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
|
||||
message: 'Tenant management or an authorized store administrator is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProductCatalogError)) throw error;
|
||||
const code = error.code;
|
||||
const status = code.endsWith('_FORBIDDEN') ? 403
|
||||
: code.endsWith('_NOT_FOUND') ? 404
|
||||
: code.includes('INVALID') ? 400
|
||||
: 409;
|
||||
return reply.status(status).send({
|
||||
code,
|
||||
message: 'The product catalog request cannot be completed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -44,6 +44,8 @@ import { BusinessStatisticsRepository } from './operations/business-statistics-r
|
||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
||||
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
||||
import { InventoryService } from './inventory/inventory-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -54,6 +56,8 @@ const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const walletLedgerService = new WalletLedgerService(pool);
|
||||
const marketingBenefits = new MarketingBenefitService(pool);
|
||||
const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
||||
const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||
const inventoryService = new InventoryService(pool);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
@@ -241,6 +245,18 @@ const app = await buildApp({
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
|
||||
sessionTtlSeconds: config.auth.sessionTtlSeconds
|
||||
},
|
||||
products: {
|
||||
repository: productCatalogRepository,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
inventory: {
|
||||
service: inventoryService,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
Reference in New Issue
Block a user