feat(M08-C): 建立管理员房态运营入口
This commit is contained in:
@@ -140,6 +140,8 @@ export class AuthRepository {
|
|||||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||||
INNER JOIN qipai_permissions p ON
|
INNER JOIN qipai_permissions p ON
|
||||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||||
|
OR (r.code = 'STAFF'
|
||||||
|
AND p.code IN ('profile.read', 'store.operation.read'))
|
||||||
OR (r.code = 'STORE_ADMIN'
|
OR (r.code = 'STORE_ADMIN'
|
||||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||||
'store.operation.read', 'store.operation.write',
|
'store.operation.read', 'store.operation.write',
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export class RbacRepository {
|
|||||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||||
INNER JOIN qipai_permissions p ON
|
INNER JOIN qipai_permissions p ON
|
||||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||||
|
OR (r.code = 'STAFF'
|
||||||
|
AND p.code IN ('profile.read', 'store.operation.read'))
|
||||||
OR (r.code = 'CLEANER'
|
OR (r.code = 'CLEANER'
|
||||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
|
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
|
||||||
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
||||||
'database/migrations/2026062728_m08b_cleaning_payouts.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'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/2026062626_m08b_cleaning_settlements.verify.sql',
|
||||||
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
|
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
|
||||||
'database/migrations/2026062728_m08b_cleaning_payouts.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: [
|
down: [
|
||||||
|
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||||
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
||||||
'database/migrations/2026062627_m08b_cleaning_collaboration.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, 1, 4, 7, 1,
|
||||||
1, 1, 7, 7, 1,
|
1, 1, 7, 7, 1,
|
||||||
5, 10, 7, 3, 1,
|
5, 10, 7, 3, 1,
|
||||||
|
5, 2,
|
||||||
2, 8, 4, 3, 1,
|
2, 8, 4, 3, 1,
|
||||||
2, 9, 5, 2, 1,
|
2, 9, 5, 2, 1,
|
||||||
1, 7, 5, 1,
|
1, 7, 5, 1,
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export class PricingRepository {
|
|||||||
r.deposit_cents AS depositCents,
|
r.deposit_cents AS depositCents,
|
||||||
r.minimum_minutes AS minimumMinutes,
|
r.minimum_minutes AS minimumMinutes,
|
||||||
r.max_advance_days AS maxAdvanceDays,
|
r.max_advance_days AS maxAdvanceDays,
|
||||||
r.room_category_id AS roomCategoryId
|
r.category_id AS roomCategoryId
|
||||||
FROM qipai_rooms r
|
FROM qipai_rooms r
|
||||||
INNER JOIN qipai_stores s
|
INNER JOIN qipai_stores s
|
||||||
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
|
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(),
|
endsAt: z.coerce.date(),
|
||||||
reason: z.string().trim().max(255).default('')
|
reason: z.string().trim().max(255).default('')
|
||||||
}).refine((value) => value.endsAt > value.startsAt);
|
}).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 {
|
export interface StoreRoomRouteOptions {
|
||||||
repository: Pick<StoreRoomRepository,
|
repository: Pick<StoreRoomRepository,
|
||||||
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
|
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
|
||||||
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
|
| 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>;
|
||||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
@@ -80,6 +90,11 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
|||||||
if (!actor) return;
|
if (!actor) return;
|
||||||
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
|
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) => {
|
app.post('/admin-api/stores', async (request, reply) => {
|
||||||
const actor = await requireOperator(request, reply, options);
|
const actor = await requireOperator(request, reply, options);
|
||||||
const body = storeSchema.safeParse(request.body);
|
const body = storeSchema.safeParse(request.body);
|
||||||
@@ -119,6 +134,15 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
|||||||
traceId: request.traceId
|
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) => {
|
app.post('/admin-api/rooms', async (request, reply) => {
|
||||||
const actor = await requireOperator(request, reply, options);
|
const actor = await requireOperator(request, reply, options);
|
||||||
const body = roomSchema.safeParse(request.body);
|
const body = roomSchema.safeParse(request.body);
|
||||||
@@ -140,6 +164,21 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
|||||||
traceId: request.traceId
|
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) => {
|
app.delete('/admin-api/rooms/:id', async (request, reply) => {
|
||||||
const actor = await requireOperator(request, reply, options);
|
const actor = await requireOperator(request, reply, options);
|
||||||
const params = idSchema.safeParse(request.params);
|
const params = idSchema.safeParse(request.params);
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export interface RoomInput {
|
|||||||
sortOrder: number;
|
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 IdRow extends RowDataPacket { id: string }
|
||||||
interface CountRow extends RowDataPacket { total: number }
|
interface CountRow extends RowDataPacket { total: number }
|
||||||
interface StoreRow extends RowDataPacket {
|
interface StoreRow extends RowDataPacket {
|
||||||
@@ -152,7 +159,7 @@ export class StoreRoomRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listRooms(actor: ManagementActor, storeId: string) {
|
async listRooms(actor: ManagementActor, storeId: string) {
|
||||||
this.assertStoreScope(actor, storeId);
|
this.assertStoreScope(actor, storeId, false);
|
||||||
const [rows] = await this.pool.execute<RoomRow[]>(
|
const [rows] = await this.pool.execute<RoomRow[]>(
|
||||||
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
|
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
|
||||||
r.name, r.room_no AS roomNo, r.capacity,
|
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) {
|
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
|
||||||
this.assertStoreScope(actor, storeId);
|
this.assertStoreScope(actor, storeId);
|
||||||
return this.transaction(async (connection) => {
|
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')
|
if (actor.access.capabilities.includes('tenant.manage')
|
||||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
|| 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)) {
|
|| !actor.access.storeIds.includes(storeId)) {
|
||||||
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
|
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
|
||||||
}
|
}
|
||||||
@@ -343,15 +378,16 @@ export class StoreRoomRepository {
|
|||||||
|
|
||||||
private async audit(
|
private async audit(
|
||||||
connection: PoolConnection, actor: ManagementActor,
|
connection: PoolConnection, actor: ManagementActor,
|
||||||
action: string, resourceType: string, resourceId: string
|
action: string, resourceType: string, resourceId: string,
|
||||||
|
metadata: Record<string, unknown> = {}
|
||||||
) {
|
) {
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
`INSERT INTO qipai_audit_logs
|
`INSERT INTO qipai_audit_logs
|
||||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
trace_id, ip, user_agent, metadata)
|
trace_id, ip, user_agent, metadata)
|
||||||
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[actor.tenantId, actor.userId, action, resourceType, resourceId,
|
[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 cleaningTransferStateUpSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql');
|
||||||
const cleaningTransferStateDownSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.down.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 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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'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(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
|
||||||
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
||||||
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
|
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']) {
|
for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
|
||||||
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
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, /'payout_state'/);
|
||||||
assert.match(cleaningTransferStateVerifySql, /'2026062729'/);
|
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, /2026062626_m08b_cleaning_settlements\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
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 calls = [];
|
||||||
const fakePool = {
|
const fakePool = {
|
||||||
async query(sql) {
|
async query(sql) {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
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 { buildApp } from '../dist/app.js';
|
||||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||||
|
|
||||||
const secret = 'test-only-pricing-jwt-secret-with-32-characters';
|
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({
|
const token = signAccessToken({
|
||||||
sub: '21',
|
sub: '21',
|
||||||
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||||
|
|||||||
@@ -27,5 +27,7 @@ assert.equal(await repository.grantStore({
|
|||||||
}), true);
|
}), true);
|
||||||
assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
|
assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
|
||||||
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
|
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
|
||||||
|
await repository.ensureCustomerRole('7', '21');
|
||||||
|
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.');
|
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.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(
|
await assert.rejects(
|
||||||
() => repository.listRooms({ ...storeActor, access: { ...storeActor.access, storeIds: ['12'] } }, '11'),
|
() => repository.listRooms({ ...storeActor, access: { ...storeActor.access, storeIds: ['12'] } }, '11'),
|
||||||
(error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN'
|
(error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN'
|
||||||
@@ -34,6 +38,8 @@ const token = signAccessToken({
|
|||||||
tid: '7', aid: '9', rv: 1
|
tid: '7', aid: '9', rv: 1
|
||||||
}, secret, 900);
|
}, secret, 900);
|
||||||
let roomInput;
|
let roomInput;
|
||||||
|
let roomStatusInput;
|
||||||
|
let currentAccess = tenantActor.access;
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
storeRoom: {
|
storeRoom: {
|
||||||
jwtSecret: secret,
|
jwtSecret: secret,
|
||||||
@@ -49,7 +55,7 @@ const app = await buildApp({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
accessControl: { async getAccessProfile() { return tenantActor.access; } },
|
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||||
repository: {
|
repository: {
|
||||||
async listStores() { return []; },
|
async listStores() { return []; },
|
||||||
async createStore() { return { storeId: '11' }; },
|
async createStore() { return { storeId: '11' }; },
|
||||||
@@ -58,6 +64,14 @@ const app = await buildApp({
|
|||||||
async listRooms() { return []; },
|
async listRooms() { return []; },
|
||||||
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
|
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
|
||||||
async updateRoom() { 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 archiveRoom() { return { roomId: '31', archived: true }; },
|
||||||
async addDisabledPeriod() { return { disabledPeriodId: '41' }; }
|
async addDisabledPeriod() { return { disabledPeriodId: '41' }; }
|
||||||
}
|
}
|
||||||
@@ -78,6 +92,30 @@ const created = await app.inject({
|
|||||||
assert.equal(created.statusCode, 201);
|
assert.equal(created.statusCode, 201);
|
||||||
assert.equal(created.json().data.roomId, '31');
|
assert.equal(created.json().data.roomId, '31');
|
||||||
assert.equal(roomInput.weekdayPriceCents, 2800);
|
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({
|
const invalid = await app.inject({
|
||||||
method: 'POST', url: '/admin-api/rooms/31/disabled-periods',
|
method: 'POST', url: '/admin-api/rooms/31/disabled-periods',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
SELECT COUNT(*) AS expected_columns
|
SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
AND table_name = 'qipai_recharge_orders'
|
AND table_name = 'qipai_recharge_orders'
|
||||||
@@ -10,7 +10,7 @@ WHERE table_schema = DATABASE()
|
|||||||
'raw_notify'
|
'raw_notify'
|
||||||
);
|
);
|
||||||
|
|
||||||
SELECT COUNT(*) AS expected_indexes
|
SELECT index_name
|
||||||
FROM information_schema.statistics
|
FROM information_schema.statistics
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
AND table_name = 'qipai_recharge_orders'
|
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/benefits/index",
|
||||||
"pages/recharge/index",
|
"pages/recharge/index",
|
||||||
"pages/cleaner/tasks",
|
"pages/cleaner/tasks",
|
||||||
|
"pages/manager/dashboard",
|
||||||
"pages/logs/logs"
|
"pages/logs/logs"
|
||||||
],
|
],
|
||||||
"window": {
|
"window": {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Page({
|
|||||||
loading: false,
|
loading: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
stores: [],
|
stores: [],
|
||||||
|
showManagerEntry: false,
|
||||||
|
showCleanerEntry: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad(options) {
|
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) {
|
onCityInput(event) {
|
||||||
this.setData({ city: event.detail.value })
|
this.setData({ city: event.detail.value })
|
||||||
},
|
},
|
||||||
@@ -73,6 +96,14 @@ Page({
|
|||||||
wx.navigateTo({ url: '/pages/profile/index' })
|
wx.navigateTo({ url: '/pages/profile/index' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openManager() {
|
||||||
|
wx.navigateTo({ url: '/pages/manager/dashboard' })
|
||||||
|
},
|
||||||
|
|
||||||
|
openCleaner() {
|
||||||
|
wx.navigateTo({ url: '/pages/cleaner/tasks' })
|
||||||
|
},
|
||||||
|
|
||||||
async resolveScene(code, sourceType) {
|
async resolveScene(code, sourceType) {
|
||||||
this.setData({ loading: true, errorMessage: '' })
|
this.setData({ loading: true, errorMessage: '' })
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<view class="quick-actions">
|
<view class="quick-actions">
|
||||||
<button bindtap="openOrders">我的订单</button>
|
<button bindtap="openOrders">我的订单</button>
|
||||||
<button bindtap="openProfile">个人中心</button>
|
<button bindtap="openProfile">个人中心</button>
|
||||||
|
<button wx:if="{{showManagerEntry}}" type="primary" bindtap="openManager">门店管理</button>
|
||||||
|
<button wx:if="{{showCleanerEntry}}" bindtap="openCleaner">保洁任务</button>
|
||||||
</view>
|
</view>
|
||||||
<button loading="{{locating}}" bindtap="locateNearby">定位附近门店</button>
|
<button loading="{{locating}}" bindtap="locateNearby">定位附近门店</button>
|
||||||
<view class="city-search">
|
<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-readme-config.ps1
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
||||||
node scripts/check-admin-m08-b.mjs
|
node scripts/check-admin-m08-b.mjs
|
||||||
|
node scripts/check-miniapp-m08-c.mjs
|
||||||
|
|
||||||
if (Test-Path "admin/package.json") {
|
if (Test-Path "admin/package.json") {
|
||||||
Push-Location admin
|
Push-Location admin
|
||||||
|
|||||||
Reference in New Issue
Block a user