feat(M08-C): 建立管理员房态运营入口
This commit is contained in:
@@ -140,6 +140,8 @@ export class AuthRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
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'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
|
||||
@@ -36,6 +36,8 @@ export class RbacRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
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'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
|
||||
@@ -49,7 +49,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql'
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
|
||||
'database/migrations/2026081001_m08c_staff_management_access.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -80,9 +81,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql'
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
|
||||
'database/migrations/2026081001_m08c_staff_management_access.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
|
||||
@@ -251,6 +254,7 @@ export async function executeMigrationPlan(
|
||||
1, 1, 1, 4, 7, 1,
|
||||
1, 1, 7, 7, 1,
|
||||
5, 10, 7, 3, 1,
|
||||
5, 2,
|
||||
2, 8, 4, 3, 1,
|
||||
2, 9, 5, 2, 1,
|
||||
1, 7, 5, 1,
|
||||
|
||||
@@ -304,7 +304,7 @@ export class PricingRepository {
|
||||
r.deposit_cents AS depositCents,
|
||||
r.minimum_minutes AS minimumMinutes,
|
||||
r.max_advance_days AS maxAdvanceDays,
|
||||
r.room_category_id AS roomCategoryId
|
||||
r.category_id AS roomCategoryId
|
||||
FROM qipai_rooms r
|
||||
INNER JOIN qipai_stores s
|
||||
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
|
||||
|
||||
@@ -64,11 +64,21 @@ const disabledPeriodSchema = z.object({
|
||||
endsAt: z.coerce.date(),
|
||||
reason: z.string().trim().max(255).default('')
|
||||
}).refine((value) => value.endsAt > value.startsAt);
|
||||
const roomStatusSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
configurationStatus: z.enum(['ENABLED', 'DISABLED']).optional(),
|
||||
operationalStatus: z.enum([
|
||||
'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED'
|
||||
]).optional(),
|
||||
reason: z.string().trim().min(1).max(255)
|
||||
}).strict().refine((value) => value.configurationStatus || value.operationalStatus, {
|
||||
message: 'at least one room status is required'
|
||||
});
|
||||
|
||||
export interface StoreRoomRouteOptions {
|
||||
repository: Pick<StoreRoomRepository,
|
||||
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
|
||||
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
|
||||
| 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -80,6 +90,11 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
||||
if (!actor) return;
|
||||
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
|
||||
});
|
||||
app.get('/app-api/management/stores', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
if (!actor) return;
|
||||
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
|
||||
});
|
||||
app.post('/admin-api/stores', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const body = storeSchema.safeParse(request.body);
|
||||
@@ -119,6 +134,15 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/app-api/management/stores/:storeId/rooms', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = storeIdSchema.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0, data: await options.repository.listRooms(actor, params.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/rooms', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const body = roomSchema.safeParse(request.body);
|
||||
@@ -140,6 +164,21 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/rooms/:id/status', '/app-api/management/rooms/:id/status']) {
|
||||
app.patch(path, async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = roomStatusSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateRoomStatus(actor, params.data.id, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
app.delete('/admin-api/rooms/:id', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
|
||||
@@ -47,6 +47,13 @@ export interface RoomInput {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface RoomStatusInput {
|
||||
storeId: string;
|
||||
configurationStatus?: 'ENABLED' | 'DISABLED';
|
||||
operationalStatus?: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface StoreRow extends RowDataPacket {
|
||||
@@ -152,7 +159,7 @@ export class StoreRoomRepository {
|
||||
}
|
||||
|
||||
async listRooms(actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
this.assertStoreScope(actor, storeId, false);
|
||||
const [rows] = await this.pool.execute<RoomRow[]>(
|
||||
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
|
||||
r.name, r.room_no AS roomNo, r.capacity,
|
||||
@@ -226,6 +233,33 @@ export class StoreRoomRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoomStatus(actor: ManagementActor, roomId: string, input: RoomStatusInput) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockRoom(connection, actor, roomId, input.storeId);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_rooms
|
||||
SET configuration_status = COALESCE(?, configuration_status),
|
||||
operational_status = COALESCE(?, operational_status),
|
||||
status = CASE
|
||||
WHEN COALESCE(?, configuration_status) = 'DISABLED' THEN 'DISABLED'
|
||||
ELSE COALESCE(?, operational_status)
|
||||
END
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
|
||||
[input.configurationStatus ?? null, input.operationalStatus ?? null,
|
||||
input.configurationStatus ?? null, input.operationalStatus ?? null,
|
||||
actor.tenantId, input.storeId, roomId]
|
||||
);
|
||||
await this.audit(connection, actor, 'ROOM_STATUS_UPDATED', 'ROOM', roomId, {
|
||||
storeId: input.storeId,
|
||||
configurationStatus: input.configurationStatus ?? null,
|
||||
operationalStatus: input.operationalStatus ?? null,
|
||||
reason: input.reason
|
||||
});
|
||||
return { roomId, ...input };
|
||||
});
|
||||
}
|
||||
|
||||
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -265,10 +299,11 @@ export class StoreRoomRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string, write = true) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
if (!actor.access.capabilities.includes('store.operation.write')
|
||||
const capability = write ? 'store.operation.write' : 'store.operation.read';
|
||||
if (!actor.access.capabilities.includes(capability)
|
||||
|| !actor.access.storeIds.includes(storeId)) {
|
||||
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
@@ -343,15 +378,16 @@ export class StoreRoomRepository {
|
||||
|
||||
private async audit(
|
||||
connection: PoolConnection, actor: ManagementActor,
|
||||
action: string, resourceType: string, resourceId: string
|
||||
action: string, resourceType: string, resourceId: string,
|
||||
metadata: Record<string, unknown> = {}
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
||||
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, actor.userId, action, resourceType, resourceId,
|
||||
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
|
||||
actor.traceId, actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ const cleaningPayoutVerifySql = read('database/migrations/2026062728_m08b_cleani
|
||||
const cleaningTransferStateUpSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql');
|
||||
const cleaningTransferStateDownSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql');
|
||||
const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql');
|
||||
const staffManagementUpSql = read('database/migrations/2026081001_m08c_staff_management_access.up.sql');
|
||||
const staffManagementDownSql = read('database/migrations/2026081001_m08c_staff_management_access.down.sql');
|
||||
const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff_management_access.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -392,6 +395,9 @@ assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callbac
|
||||
assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
|
||||
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
||||
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
|
||||
assert.match(rechargeWechatVerifySql, /SELECT column_name/);
|
||||
assert.match(rechargeWechatVerifySql, /SELECT index_name/);
|
||||
assert.doesNotMatch(rechargeWechatVerifySql, /COUNT\(\*\) AS expected_/);
|
||||
|
||||
for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
|
||||
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
@@ -450,4 +456,11 @@ assert.match(cleaningTransferStateDownSql, /DROP COLUMN payout_package_info/);
|
||||
assert.match(cleaningTransferStateVerifySql, /'payout_state'/);
|
||||
assert.match(cleaningTransferStateVerifySql, /'2026062729'/);
|
||||
|
||||
console.log('PASS: M01-B through M08-B migration contracts are present.');
|
||||
assert.match(staffManagementUpSql, /r\.code = 'STAFF'/);
|
||||
assert.match(staffManagementUpSql, /'profile\.read', 'store\.operation\.read'/);
|
||||
assert.match(staffManagementUpSql, /'2026081001'/);
|
||||
assert.match(staffManagementDownSql, /DELETE rp FROM qipai_role_permissions/);
|
||||
assert.match(staffManagementVerifySql, /fully_granted_staff_roles/);
|
||||
assert.match(staffManagementVerifySql, /HAVING COUNT\(DISTINCT p\.code\) = 2/);
|
||||
|
||||
console.log('PASS: M01-B through M08-C migration contracts are present.');
|
||||
|
||||
@@ -39,10 +39,15 @@ assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
|
||||
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
|
||||
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql$/);
|
||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
||||
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
const verifyPlan = await loadMigrationPlan('verify');
|
||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||
|
||||
const calls = [];
|
||||
const fakePool = {
|
||||
async query(sql) {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
const secret = 'test-only-pricing-jwt-secret-with-32-characters';
|
||||
const pricingSource = readFileSync(
|
||||
join(dirname(dirname(fileURLToPath(import.meta.url))), 'src/orders/pricing-repository.ts'),
|
||||
'utf8'
|
||||
);
|
||||
assert.match(pricingSource, /r\.category_id AS roomCategoryId/);
|
||||
assert.doesNotMatch(pricingSource, /r\.room_category_id AS roomCategoryId/);
|
||||
const token = signAccessToken({
|
||||
sub: '21',
|
||||
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
|
||||
@@ -27,5 +27,7 @@ assert.equal(await repository.grantStore({
|
||||
}), true);
|
||||
assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
|
||||
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
|
||||
await repository.ensureCustomerRole('7', '21');
|
||||
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'") && sql.includes('store.operation.read')));
|
||||
|
||||
console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');
|
||||
|
||||
@@ -23,6 +23,10 @@ const repository = new StoreRoomRepository({
|
||||
}
|
||||
});
|
||||
assert.equal((await repository.listStores(storeActor))[0].id, '11');
|
||||
assert.deepEqual(await repository.listRooms({
|
||||
...storeActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] }
|
||||
}, '11'), []);
|
||||
await assert.rejects(
|
||||
() => repository.listRooms({ ...storeActor, access: { ...storeActor.access, storeIds: ['12'] } }, '11'),
|
||||
(error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN'
|
||||
@@ -34,6 +38,8 @@ const token = signAccessToken({
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let roomInput;
|
||||
let roomStatusInput;
|
||||
let currentAccess = tenantActor.access;
|
||||
const app = await buildApp({
|
||||
storeRoom: {
|
||||
jwtSecret: secret,
|
||||
@@ -49,7 +55,7 @@ const app = await buildApp({
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return tenantActor.access; } },
|
||||
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||
repository: {
|
||||
async listStores() { return []; },
|
||||
async createStore() { return { storeId: '11' }; },
|
||||
@@ -58,6 +64,14 @@ const app = await buildApp({
|
||||
async listRooms() { return []; },
|
||||
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
|
||||
async updateRoom() { return { roomId: '31' }; },
|
||||
async updateRoomStatus(actor, _roomId, input) {
|
||||
if (!actor.access.capabilities.includes('store.operation.write')
|
||||
&& !actor.access.capabilities.includes('tenant.manage')) {
|
||||
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
roomStatusInput = input;
|
||||
return { roomId: '31', ...input };
|
||||
},
|
||||
async archiveRoom() { return { roomId: '31', archived: true }; },
|
||||
async addDisabledPeriod() { return { disabledPeriodId: '41' }; }
|
||||
}
|
||||
@@ -78,6 +92,30 @@ const created = await app.inject({
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.roomId, '31');
|
||||
assert.equal(roomInput.weekdayPriceCents, 2800);
|
||||
const managementStores = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/stores',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(managementStores.statusCode, 200);
|
||||
const statusUpdated = await app.inject({
|
||||
method: 'PATCH', url: '/app-api/management/rooms/31/status',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', operationalStatus: 'MAINTENANCE', reason: '现场维护' }
|
||||
});
|
||||
assert.equal(statusUpdated.statusCode, 200);
|
||||
assert.equal(roomStatusInput.operationalStatus, 'MAINTENANCE');
|
||||
currentAccess = { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] };
|
||||
const staffRooms = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/stores/11/rooms',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(staffRooms.statusCode, 200);
|
||||
const staffWrite = await app.inject({
|
||||
method: 'PATCH', url: '/app-api/management/rooms/31/status',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', operationalStatus: 'AVAILABLE', reason: '越权尝试' }
|
||||
});
|
||||
assert.equal(staffWrite.statusCode, 403);
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: '/admin-api/rooms/31/disabled-periods',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
SELECT COUNT(*) AS expected_columns
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_recharge_orders'
|
||||
@@ -10,7 +10,7 @@ WHERE table_schema = DATABASE()
|
||||
'raw_notify'
|
||||
);
|
||||
|
||||
SELECT COUNT(*) AS expected_indexes
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_recharge_orders'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
DELETE rp FROM qipai_role_permissions rp
|
||||
INNER JOIN qipai_roles r
|
||||
ON r.tenant_id = rp.tenant_id AND r.id = rp.role_id
|
||||
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||
WHERE r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read');
|
||||
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026081001';
|
||||
@@ -0,0 +1,11 @@
|
||||
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 p.code IN ('profile.read', 'store.operation.read')
|
||||
WHERE r.code = 'STAFF'
|
||||
AND r.status = 'ACTIVE'
|
||||
AND r.deleted_at IS NULL;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026081001', 'm08c_staff_management_access');
|
||||
@@ -0,0 +1,19 @@
|
||||
SELECT COUNT(*) AS staff_role_count
|
||||
FROM qipai_roles r
|
||||
WHERE r.code = 'STAFF' AND r.status = 'ACTIVE' AND r.deleted_at IS NULL
|
||||
HAVING COUNT(*) = (
|
||||
SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT granted_role.tenant_id, granted_role.id
|
||||
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 = 'STAFF'
|
||||
AND granted_role.status = 'ACTIVE'
|
||||
AND granted_role.deleted_at IS NULL
|
||||
AND p.code IN ('profile.read', 'store.operation.read')
|
||||
GROUP BY granted_role.tenant_id, granted_role.id
|
||||
HAVING COUNT(DISTINCT p.code) = 2
|
||||
) fully_granted_staff_roles
|
||||
);
|
||||
@@ -9,6 +9,7 @@
|
||||
"pages/benefits/index",
|
||||
"pages/recharge/index",
|
||||
"pages/cleaner/tasks",
|
||||
"pages/manager/dashboard",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"window": {
|
||||
|
||||
@@ -7,6 +7,8 @@ Page({
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
stores: [],
|
||||
showManagerEntry: false,
|
||||
showCleanerEntry: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -15,6 +17,27 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadRoleEntrances()
|
||||
},
|
||||
|
||||
async loadRoleEntrances() {
|
||||
if (!wx.getStorageSync('qipai_access_token')) {
|
||||
this.setData({ showManagerEntry: false, showCleanerEntry: false })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await request('/auth/me')
|
||||
const roles = response.data?.access?.roles || []
|
||||
this.setData({
|
||||
showManagerEntry: roles.some((role) => ['STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)),
|
||||
showCleanerEntry: roles.includes('CLEANER'),
|
||||
})
|
||||
} catch (_) {
|
||||
this.setData({ showManagerEntry: false, showCleanerEntry: false })
|
||||
}
|
||||
},
|
||||
|
||||
onCityInput(event) {
|
||||
this.setData({ city: event.detail.value })
|
||||
},
|
||||
@@ -73,6 +96,14 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/profile/index' })
|
||||
},
|
||||
|
||||
openManager() {
|
||||
wx.navigateTo({ url: '/pages/manager/dashboard' })
|
||||
},
|
||||
|
||||
openCleaner() {
|
||||
wx.navigateTo({ url: '/pages/cleaner/tasks' })
|
||||
},
|
||||
|
||||
async resolveScene(code, sourceType) {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<view class="quick-actions">
|
||||
<button bindtap="openOrders">我的订单</button>
|
||||
<button bindtap="openProfile">个人中心</button>
|
||||
<button wx:if="{{showManagerEntry}}" type="primary" bindtap="openManager">门店管理</button>
|
||||
<button wx:if="{{showCleanerEntry}}" bindtap="openCleaner">保洁任务</button>
|
||||
</view>
|
||||
<button loading="{{locating}}" bindtap="locateNearby">定位附近门店</button>
|
||||
<view class="city-search">
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
const { request, ensureLogin, cents } = require('../../utils/api.js')
|
||||
|
||||
const managerRoles = ['STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN']
|
||||
const roomStatusLabels = {
|
||||
AVAILABLE: '空闲',
|
||||
MAINTENANCE: '维护中',
|
||||
RESERVED: '已预订',
|
||||
IN_USE: '使用中',
|
||||
CLEANING_REQUIRED: '待清洁',
|
||||
}
|
||||
const orderStatusLabels = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_PAYMENT: '待支付',
|
||||
PAID: '已支付',
|
||||
RESERVED: '待开始',
|
||||
IN_PROGRESS: '进行中',
|
||||
FINISHED: '已结束',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
CLOSED: '已关闭',
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
canWrite: false,
|
||||
stores: [],
|
||||
selectedStoreId: '',
|
||||
selectedStoreName: '',
|
||||
rooms: [],
|
||||
orders: [],
|
||||
visibleOrders: [],
|
||||
busyRoomId: '',
|
||||
summary: {
|
||||
roomTotal: 0,
|
||||
available: 0,
|
||||
inUse: 0,
|
||||
attention: 0,
|
||||
activeOrders: 0,
|
||||
},
|
||||
},
|
||||
|
||||
async onLoad() {
|
||||
await this.loadDashboard()
|
||||
},
|
||||
|
||||
async onPullDownRefresh() {
|
||||
await this.loadDashboard(this.data.selectedStoreId)
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
|
||||
async loadDashboard(preferredStoreId = '') {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const me = await request('/auth/me')
|
||||
const access = me.data?.access || { roles: [], capabilities: [], storeIds: [] }
|
||||
if (!access.roles.some((role) => managerRoles.includes(role))) {
|
||||
throw new Error('当前账号没有门店运营权限')
|
||||
}
|
||||
const [storesResponse, ordersResponse] = await Promise.all([
|
||||
request('/management/stores'),
|
||||
request('/orders?page=1&pageSize=50'),
|
||||
])
|
||||
const stores = storesResponse.data || []
|
||||
const selectedStoreId = stores.some((store) => store.id === preferredStoreId)
|
||||
? preferredStoreId
|
||||
: (stores[0]?.id || '')
|
||||
this.setData({
|
||||
canWrite: access.capabilities.includes('store.operation.write')
|
||||
|| access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN'),
|
||||
stores,
|
||||
orders: (ordersResponse.data?.items || []).map((item) => this.presentOrder(item)),
|
||||
selectedStoreId,
|
||||
selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '',
|
||||
})
|
||||
await this.loadRooms(selectedStoreId)
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '门店运营数据加载失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
async selectStore(event) {
|
||||
const storeId = event.currentTarget.dataset.storeId
|
||||
if (!storeId || storeId === this.data.selectedStoreId) return
|
||||
const store = this.data.stores.find((item) => item.id === storeId)
|
||||
this.setData({ selectedStoreId: storeId, selectedStoreName: store?.name || '' })
|
||||
await this.loadRooms(storeId)
|
||||
},
|
||||
|
||||
async loadRooms(storeId) {
|
||||
if (!storeId) {
|
||||
this.setData({ rooms: [], visibleOrders: [] })
|
||||
this.refreshSummary()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await request(`/management/stores/${encodeURIComponent(storeId)}/rooms`)
|
||||
const rooms = (response.data || []).map((item) => ({
|
||||
...item,
|
||||
statusText: roomStatusLabels[item.operationalStatus] || item.operationalStatus,
|
||||
priceText: cents(item.basePriceCents),
|
||||
}))
|
||||
this.setData({
|
||||
rooms,
|
||||
visibleOrders: this.data.orders.filter((item) => item.storeId === storeId),
|
||||
})
|
||||
this.refreshSummary()
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '房态加载失败' })
|
||||
}
|
||||
},
|
||||
|
||||
refreshSummary() {
|
||||
const rooms = this.data.rooms
|
||||
const activeStatuses = ['PAID', 'RESERVED', 'IN_PROGRESS']
|
||||
this.setData({
|
||||
summary: {
|
||||
roomTotal: rooms.length,
|
||||
available: rooms.filter((item) => item.configurationStatus === 'ENABLED' && item.operationalStatus === 'AVAILABLE').length,
|
||||
inUse: rooms.filter((item) => ['RESERVED', 'IN_USE'].includes(item.operationalStatus)).length,
|
||||
attention: rooms.filter((item) => item.configurationStatus === 'DISABLED' || ['MAINTENANCE', 'CLEANING_REQUIRED'].includes(item.operationalStatus)).length,
|
||||
activeOrders: this.data.visibleOrders.filter((item) => activeStatuses.includes(item.status)).length,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
changeRoomStatus(event) {
|
||||
if (!this.data.canWrite) return
|
||||
const roomId = event.currentTarget.dataset.roomId
|
||||
wx.showActionSheet({
|
||||
itemList: ['设为空闲', '设为维护中', '设为待清洁'],
|
||||
success: ({ tapIndex }) => {
|
||||
const statuses = ['AVAILABLE', 'MAINTENANCE', 'CLEANING_REQUIRED']
|
||||
this.updateRoomStatus(roomId, { operationalStatus: statuses[tapIndex] }, '管理员小程序调整房态')
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
toggleRoomConfiguration(event) {
|
||||
if (!this.data.canWrite) return
|
||||
const roomId = event.currentTarget.dataset.roomId
|
||||
const current = event.currentTarget.dataset.status
|
||||
const configurationStatus = current === 'ENABLED' ? 'DISABLED' : 'ENABLED'
|
||||
this.updateRoomStatus(roomId, { configurationStatus }, configurationStatus === 'ENABLED' ? '管理员小程序启用房间' : '管理员小程序停用房间')
|
||||
},
|
||||
|
||||
async updateRoomStatus(roomId, status, reason) {
|
||||
if (!roomId || this.data.busyRoomId) return
|
||||
this.setData({ busyRoomId: roomId, errorMessage: '' })
|
||||
try {
|
||||
await request(`/management/rooms/${encodeURIComponent(roomId)}/status`, {
|
||||
method: 'PATCH',
|
||||
data: { storeId: this.data.selectedStoreId, ...status, reason },
|
||||
})
|
||||
await this.loadRooms(this.data.selectedStoreId)
|
||||
wx.showToast({ title: '房态已更新', icon: 'success' })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '房态更新失败' })
|
||||
} finally {
|
||||
this.setData({ busyRoomId: '' })
|
||||
}
|
||||
},
|
||||
|
||||
openOrder(event) {
|
||||
const orderId = event.currentTarget.dataset.orderId
|
||||
if (!orderId) return
|
||||
wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` })
|
||||
},
|
||||
|
||||
presentOrder(item) {
|
||||
return {
|
||||
...item,
|
||||
statusText: orderStatusLabels[item.status] || item.status,
|
||||
amountText: cents(item.paidAmountCents || item.totalAmountCents),
|
||||
timeText: `${this.formatTime(item.startAt)} - ${this.formatTime(item.endAt)}`,
|
||||
}
|
||||
},
|
||||
|
||||
formatTime(value) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value || '')
|
||||
const pad = (part) => String(part).padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "门店运营",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container manager-dashboard">
|
||||
<view class="manager-header">
|
||||
<view>
|
||||
<view class="title">门店运营</view>
|
||||
<view class="subtitle">{{selectedStoreName || '请选择授权门店'}}</view>
|
||||
</view>
|
||||
<view class="permission-tag">{{canWrite ? '可操作' : '只读'}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
<view wx:if="{{loading}}" class="loading">正在加载运营数据...</view>
|
||||
|
||||
<scroll-view wx:if="{{stores.length}}" class="store-tabs" scroll-x enhanced show-scrollbar="false">
|
||||
<view class="store-tabs-inner">
|
||||
<button
|
||||
wx:for="{{stores}}"
|
||||
wx:key="id"
|
||||
size="mini"
|
||||
class="{{item.id === selectedStoreId ? 'store-tab active' : 'store-tab'}}"
|
||||
data-store-id="{{item.id}}"
|
||||
bindtap="selectStore"
|
||||
>{{item.name}}</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="summary-grid">
|
||||
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
|
||||
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
|
||||
<view class="summary-card primary"><strong>{{summary.inUse}}</strong><text>占用</text></view>
|
||||
<view class="summary-card warning"><strong>{{summary.attention}}</strong><text>待处理</text></view>
|
||||
<view class="summary-card"><strong>{{summary.activeOrders}}</strong><text>活跃订单</text></view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">实时房态</view>
|
||||
<view wx:if="{{!loading && rooms.length === 0}}" class="empty">当前门店暂无房间</view>
|
||||
<view wx:for="{{rooms}}" wx:key="id" class="room-card {{item.configurationStatus === 'DISABLED' ? 'disabled' : ''}}">
|
||||
<view class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.name}} · {{item.roomNo}}</view>
|
||||
<view class="card-meta">{{item.categoryName}} / {{item.capacity}} 人 / {{item.priceText}} 起</view>
|
||||
</view>
|
||||
<view class="status-pill status-{{item.operationalStatus}}">{{item.configurationStatus === 'DISABLED' ? '已停用' : item.statusText}}</view>
|
||||
</view>
|
||||
<view wx:if="{{canWrite}}" class="card-actions">
|
||||
<button size="mini" loading="{{busyRoomId === item.id}}" data-room-id="{{item.id}}" bindtap="changeRoomStatus">调整房态</button>
|
||||
<button size="mini" data-room-id="{{item.id}}" data-status="{{item.configurationStatus}}" bindtap="toggleRoomConfiguration">{{item.configurationStatus === 'ENABLED' ? '停用' : '启用'}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">近期订单</view>
|
||||
<view wx:if="{{!loading && visibleOrders.length === 0}}" class="empty">当前门店暂无订单</view>
|
||||
<view wx:for="{{visibleOrders}}" wx:key="id" class="order-card" data-order-id="{{item.id}}" bindtap="openOrder">
|
||||
<view class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.roomName}} · {{item.roomNo}}</view>
|
||||
<view class="card-meta">{{item.orderNo}}</view>
|
||||
</view>
|
||||
<view class="status-pill">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="order-line"><text>{{item.timeText}}</text><strong>{{item.amountText}}</strong></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,134 @@
|
||||
.manager-dashboard {
|
||||
padding-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.manager-header,
|
||||
.card-main,
|
||||
.order-line {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.card-meta {
|
||||
color: #6b7280;
|
||||
font-size: 24rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.permission-tag,
|
||||
.status-pill {
|
||||
background: #eef2ff;
|
||||
border-radius: 999rpx;
|
||||
color: #3730a3;
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.store-tabs {
|
||||
margin: 24rpx 0;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-tabs-inner {
|
||||
display: inline-flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.store-tab {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-tab.active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18rpx;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.summary-card text {
|
||||
color: #64748b;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.summary-card.success strong { color: #15803d; }
|
||||
.summary-card.primary strong { color: #2563eb; }
|
||||
.summary-card.warning strong { color: #c2410c; }
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin: 28rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.room-card,
|
||||
.order-card {
|
||||
background: #fff;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 18rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.room-card.disabled {
|
||||
opacity: .64;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-AVAILABLE { background: #dcfce7; color: #166534; }
|
||||
.status-MAINTENANCE { background: #ffedd5; color: #9a3412; }
|
||||
.status-RESERVED,
|
||||
.status-IN_USE { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-CLEANING_REQUIRED { background: #fef3c7; color: #92400e; }
|
||||
|
||||
.order-line {
|
||||
color: #64748b;
|
||||
font-size: 23rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.order-line strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: #64748b;
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
const appJson = JSON.parse(read('miniapp/app.json'));
|
||||
assert.ok(appJson.pages.includes('pages/manager/dashboard'));
|
||||
|
||||
const index = read('miniapp/pages/index/index.js') + read('miniapp/pages/index/index.wxml');
|
||||
for (const pattern of [
|
||||
'/auth/me',
|
||||
'showManagerEntry',
|
||||
'STORE_ADMIN',
|
||||
'TENANT_ADMIN',
|
||||
'PLATFORM_ADMIN',
|
||||
'openManager',
|
||||
'pages/manager/dashboard',
|
||||
'门店管理'
|
||||
]) {
|
||||
assert.match(index, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const dashboard = read('miniapp/pages/manager/dashboard.js')
|
||||
+ read('miniapp/pages/manager/dashboard.wxml')
|
||||
+ read('miniapp/pages/manager/dashboard.wxss');
|
||||
for (const pattern of [
|
||||
'/management/stores',
|
||||
'/management/stores/${encodeURIComponent(storeId)}/rooms',
|
||||
'/management/rooms/${encodeURIComponent(roomId)}/status',
|
||||
'/orders?page=1&pageSize=50',
|
||||
"method: 'PATCH'",
|
||||
'store.operation.write',
|
||||
'tenant.manage',
|
||||
'changeRoomStatus',
|
||||
'toggleRoomConfiguration',
|
||||
'activeOrders',
|
||||
'实时房态',
|
||||
'近期订单',
|
||||
'只读'
|
||||
]) {
|
||||
assert.match(dashboard, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const routes = read('backend/src/routes/store-room-management.ts');
|
||||
for (const pattern of [
|
||||
'/app-api/management/stores',
|
||||
'/app-api/management/stores/:storeId/rooms',
|
||||
'/app-api/management/rooms/:id/status',
|
||||
'roomStatusSchema',
|
||||
'updateRoomStatus'
|
||||
]) {
|
||||
assert.match(routes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const repository = read('backend/src/stores/store-room-repository.ts');
|
||||
assert.match(repository, /ROOM_STATUS_UPDATED/);
|
||||
assert.match(repository, /store\.operation\.read/);
|
||||
assert.match(repository, /store\.operation\.write/);
|
||||
assert.match(repository, /JSON\.stringify\(metadata\)/);
|
||||
|
||||
const migration = read('database/migrations/2026081001_m08c_staff_management_access.up.sql');
|
||||
assert.match(migration, /r\.code = 'STAFF'/);
|
||||
assert.match(migration, /store\.operation\.read/);
|
||||
|
||||
console.log('PASS: M08-C manager/staff miniapp exposes scoped stores, room status and order overview.');
|
||||
@@ -10,6 +10,7 @@ $ErrorActionPreference = "Stop"
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-readme-config.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
||||
node scripts/check-admin-m08-b.mjs
|
||||
node scripts/check-miniapp-m08-c.mjs
|
||||
|
||||
if (Test-Path "admin/package.json") {
|
||||
Push-Location admin
|
||||
|
||||
Reference in New Issue
Block a user