From 566e61b5d9a650054b9e66739cb0f43c354cba0e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 10:20:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-C):=20=E8=A1=A5=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=AE=A2=E5=8D=95=E5=A4=84=E7=BD=AE=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E4=B8=8B=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/orders/order-query-repository.ts | 5 + backend/src/orders/pricing-repository.ts | 39 ++++- backend/src/routes/order-management.ts | 36 ++-- backend/src/routes/order-query.ts | 3 +- backend/src/routes/order-state.ts | 37 ++-- backend/src/routes/pricing.ts | 73 ++++---- .../tests/mysql-migration-roundtrip.test.mjs | 53 ++++++ backend/tests/order-management.test.mjs | 18 +- backend/tests/order-query.test.mjs | 3 +- backend/tests/order-state.test.mjs | 15 ++ backend/tests/pricing.test.mjs | 38 ++++- miniapp/app.json | 1 + miniapp/pages/manager/dashboard.js | 158 +++++++++++++++--- miniapp/pages/manager/dashboard.wxml | 23 ++- miniapp/pages/manager/dashboard.wxss | 34 ++++ miniapp/pages/manager/order-create.js | 124 ++++++++++++++ miniapp/pages/manager/order-create.json | 4 + miniapp/pages/manager/order-create.wxml | 41 +++++ miniapp/pages/manager/order-create.wxss | 51 ++++++ scripts/check-miniapp-m08-c.mjs | 41 ++++- 20 files changed, 701 insertions(+), 96 deletions(-) create mode 100644 miniapp/pages/manager/order-create.js create mode 100644 miniapp/pages/manager/order-create.json create mode 100644 miniapp/pages/manager/order-create.wxml create mode 100644 miniapp/pages/manager/order-create.wxss diff --git a/backend/src/orders/order-query-repository.ts b/backend/src/orders/order-query-repository.ts index aeea1e1..d1a7202 100644 --- a/backend/src/orders/order-query-repository.ts +++ b/backend/src/orders/order-query-repository.ts @@ -41,6 +41,7 @@ export class OrderQueryRepository { page: number; pageSize: number; status?: OrderStatus; + storeId?: string; }) { const where = [ 'o.tenant_id = ?', @@ -52,6 +53,10 @@ export class OrderQueryRepository { where.push('o.status = ?'); params.push(input.status); } + if (input.storeId) { + where.push('o.store_id = ?'); + params.push(input.storeId); + } const whereSql = where.join(' AND '); const offset = (input.page - 1) * input.pageSize; const [counts] = await this.pool.execute( diff --git a/backend/src/orders/pricing-repository.ts b/backend/src/orders/pricing-repository.ts index 3a2b6f8..e171220 100644 --- a/backend/src/orders/pricing-repository.ts +++ b/backend/src/orders/pricing-repository.ts @@ -45,6 +45,7 @@ interface RoomPricingRow extends RowDataPacket { roomCategoryId: string | null; } interface CountRow extends RowDataPacket { total: number } +interface UserRow extends RowDataPacket { id: string } export class PricingError extends Error { constructor(public readonly code: string) { super(code); } @@ -69,8 +70,16 @@ export class PricingRepository { holdMinutes?: number; allowedStoreIds?: string[] | null; benefits?: OrderBenefitInput | null; + actor?: { + userId: string; + source: 'APP' | 'ADMIN'; + traceId: string; + ip: string; + userAgent: string; + }; }) { return this.transaction(async (connection) => { + await this.assertActiveTenantUser(connection, input.tenantId, input.userId); const room = await this.loadRoom(connection, input.tenantId, input.roomId, true); if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) { throw new PricingError('STORE_SCOPE_FORBIDDEN'); @@ -158,9 +167,24 @@ export class PricingRepository { (tenant_id, order_id, from_status, to_status, action, actor_type, actor_id, source, reason, trace_id, metadata) VALUES (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?, - 'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`, - [input.tenantId, orderId, input.userId, `order-created-${orderId}`] + ?, ?, ?, JSON_OBJECT('statusVersion', 1, 'targetUserId', ?))`, + [input.tenantId, orderId, input.actor?.userId ?? input.userId, + input.actor?.source ?? 'APP', + input.actor?.source === 'ADMIN' ? 'Order created on behalf' : 'Room hold created', + input.actor?.traceId ?? `order-created-${orderId}`, input.userId] ); + if (input.actor?.source === 'ADMIN') { + 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', ?, 'ORDER_CREATED_ON_BEHALF', 'ORDER', ?, ?, ?, ?, + JSON_OBJECT('targetUserId', ?, 'storeId', ?, 'roomId', ?))`, + [input.tenantId, input.actor.userId, orderId, input.actor.traceId, + input.actor.ip, input.actor.userAgent.slice(0, 255), input.userId, + String(room.storeId), input.roomId] + ); + } return { orderId, orderNo, @@ -173,6 +197,17 @@ export class PricingRepository { }); } + private async assertActiveTenantUser( + connection: PoolConnection, tenantId: string, userId: string + ) { + const [rows] = await connection.execute( + `SELECT id FROM qipai_users + WHERE tenant_id = ? AND id = ? AND status = 'ACTIVE' LIMIT 1`, + [tenantId, userId] + ); + if (!rows[0]) throw new PricingError('USER_NOT_FOUND'); + } + async releaseExpired( tenantId?: string, roomId?: string, diff --git a/backend/src/routes/order-management.ts b/backend/src/routes/order-management.ts index afaa073..e9178c6 100644 --- a/backend/src/routes/order-management.ts +++ b/backend/src/routes/order-management.ts @@ -91,23 +91,25 @@ export async function registerOrderManagementRoutes( ] as const; for (const [path, schema, execute] of adminActions) { - app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => { - const auth = await authenticate(request.headers.authorization, options); - const params = paramsSchema.safeParse(request.params); - const body = schema.safeParse(request.body); - if (!auth) return unauthorized(reply, request.traceId); - if (!params.success || !body.success) return invalid(reply, request.traceId); - const actor = { - tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const, - source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip, - userAgent: request.headers['user-agent'] ?? '', access: auth.access - }; - return handle(reply, request.traceId, async () => ({ - code: 0, - data: await execute(actor, params.data.orderId, body.data as never), - traceId: request.traceId - })); - }); + for (const prefix of ['/admin-api/orders', '/app-api/management/orders']) { + app.post(`${prefix}/:orderId/${path}`, async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = schema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success || !body.success) return invalid(reply, request.traceId); + const actor = { + tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const, + source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', access: auth.access + }; + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await execute(actor, params.data.orderId, body.data as never), + traceId: request.traceId + })); + }); + } } } diff --git a/backend/src/routes/order-query.ts b/backend/src/routes/order-query.ts index c5d2f0f..7b049fa 100644 --- a/backend/src/routes/order-query.ts +++ b/backend/src/routes/order-query.ts @@ -16,7 +16,8 @@ const orderStatuses = [ const listSchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(50).default(20), - status: z.enum(orderStatuses).optional() + status: z.enum(orderStatuses).optional(), + storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional() }); const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) }); diff --git a/backend/src/routes/order-state.ts b/backend/src/routes/order-state.ts index d30096a..98a6db2 100644 --- a/backend/src/routes/order-state.ts +++ b/backend/src/routes/order-state.ts @@ -62,22 +62,27 @@ export async function registerOrderStateRoutes( }); }); - app.post('/admin-api/orders/:orderId/actions', async (request, reply) => { - const auth = await authenticate(request.headers.authorization, options); - const params = paramsSchema.safeParse(request.params); - const body = transitionSchema.safeParse(request.body); - if (!auth) return unauthorized(reply, request.traceId); - if (!params.success || !body.success) return invalid(reply, request.traceId); - return handle(reply, request.traceId, async () => ({ - code: 0, - data: await options.repository.transition({ - tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', - source: 'ADMIN', traceId: request.traceId, ip: request.ip, - userAgent: request.headers['user-agent'] ?? '', access: auth.access - }, params.data.orderId, body.data.action, body.data.reason), - traceId: request.traceId - })); - }); + for (const path of [ + '/admin-api/orders/:orderId/actions', + '/app-api/management/orders/:orderId/actions' + ]) { + app.post(path, async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = transitionSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success || !body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.transition({ + tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', + source: 'ADMIN', traceId: request.traceId, ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', access: auth.access + }, params.data.orderId, body.data.action, body.data.reason), + traceId: request.traceId + })); + }); + } } async function authenticate( diff --git a/backend/src/routes/pricing.ts b/backend/src/routes/pricing.ts index dfd12ea..df070eb 100644 --- a/backend/src/routes/pricing.ts +++ b/backend/src/routes/pricing.ts @@ -80,35 +80,47 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici }; }); - app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => { - const auth = await authenticate(request.headers.authorization, options); - const body = adminReserveSchema.safeParse(request.body); - if (!auth) return unauthorized(reply, request.traceId); - if (!body.success) return invalid(reply, request.traceId); - const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); - const unrestricted = access.capabilities.includes('tenant.manage') - || access.roles.includes('PLATFORM_ADMIN'); - if (!unrestricted && !access.capabilities.includes('store.operation.write')) { - return reply.status(403).send({ - code: 'ORDER_MANAGEMENT_FORBIDDEN', - message: 'Order management permission is required.', + for (const path of [ + '/admin-api/orders/reserve-on-behalf', + '/app-api/management/orders/reserve-on-behalf' + ]) { + app.post(path, async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const body = adminReserveSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); + const unrestricted = access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN'); + if (!unrestricted && !access.capabilities.includes('store.operation.write')) { + return reply.status(403).send({ + code: 'ORDER_MANAGEMENT_FORBIDDEN', + message: 'Order management permission is required.', + traceId: request.traceId + }); + } + return handle(reply, request.traceId, async () => reply.status(201).send({ + code: 0, + data: await options.repository.reserve({ + tenantId: auth.tenantId, + userId: body.data.userId, + roomId: body.data.roomId, + startAt: body.data.startAt, + endAt: body.data.endAt, + pricingMode: body.data.pricingMode, + allowedStoreIds: unrestricted ? null : access.storeIds, + actor: { + userId: auth.userId, + source: 'ADMIN', + traceId: request.traceId, + ip: request.ip, + userAgent: String(request.headers['user-agent'] ?? '') + } + }), traceId: request.traceId - }); - } - return handle(reply, request.traceId, async () => reply.status(201).send({ - code: 0, - data: await options.repository.reserve({ - tenantId: auth.tenantId, - userId: body.data.userId, - roomId: body.data.roomId, - startAt: body.data.startAt, - endAt: body.data.endAt, - pricingMode: body.data.pricingMode, - allowedStoreIds: unrestricted ? null : access.storeIds - }), - traceId: request.traceId - })); - }); + })); + }); + } } async function authenticate( @@ -131,8 +143,9 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise< } catch (error) { if (!(error instanceof PricingError)) throw error; const conflict = error.code === 'TIME_SLOT_CONFLICT'; - const notFound = error.code === 'ROOM_NOT_FOUND'; - return reply.status(conflict ? 409 : notFound ? 404 : 400).send({ + const forbidden = error.code === 'STORE_SCOPE_FORBIDDEN'; + const notFound = error.code === 'ROOM_NOT_FOUND' || error.code === 'USER_NOT_FOUND'; + return reply.status(conflict ? 409 : forbidden ? 403 : notFound ? 404 : 400).send({ code: error.code, message: 'The requested price or time slot is not available.', traceId diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index f0107e8..87bf6c5 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -671,6 +671,57 @@ async function assertPricingAndReservations(pool, context) { startAt, endAt, pricingMode: 'FULL_DAY' }); assert.equal(replacement.quote.unitPriceCents, 9000); + + const [adminRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id + INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id + WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, + [context.tenantId] + ); + const adminId = String(adminRows[0].id); + const managedStartAt = new Date(startAt.getTime() + 24 * 3600000); + const managedEndAt = new Date(managedStartAt.getTime() + 2 * 3600000); + const managed = await repository.reserve({ + tenantId: context.tenantId, userId: customerId, roomId, + startAt: managedStartAt, endAt: managedEndAt, pricingMode: 'HOURLY', + actor: { + userId: adminId, source: 'ADMIN', traceId: 'm08c-reserve-on-behalf', + ip: '127.0.0.1', userAgent: 'M08-C live manager booking test' + } + }); + const [managedHistory] = await pool.query( + `SELECT actor_id AS actorId, source, CAST(metadata AS CHAR) AS metadata + FROM qipai_order_status_history WHERE tenant_id = ? AND order_id = ?`, + [context.tenantId, managed.orderId] + ); + assert.equal(String(managedHistory[0].actorId), adminId); + assert.equal(managedHistory[0].source, 'ADMIN'); + assert.equal(String(JSON.parse(managedHistory[0].metadata).targetUserId), customerId); + const [managedAudit] = await pool.query( + `SELECT actor_id AS actorId, action, CAST(metadata AS CHAR) AS metadata + FROM qipai_audit_logs + WHERE tenant_id = ? AND trace_id = 'm08c-reserve-on-behalf'`, + [context.tenantId] + ); + assert.equal(String(managedAudit[0].actorId), adminId); + assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF'); + assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId); + + const [foreignUser] = await pool.query( + `INSERT INTO qipai_users (tenant_id, user_type, status) + VALUES (?, 'CUSTOMER', 'ACTIVE')`, + [String(Number(context.tenantId) + 1)] + ); + await assert.rejects( + () => repository.reserve({ + tenantId: context.tenantId, userId: String(foreignUser.insertId), roomId, + startAt: new Date(managedStartAt.getTime() + 24 * 3600000), + endAt: new Date(managedEndAt.getTime() + 24 * 3600000), + pricingMode: 'HOURLY' + }), + (error) => error instanceof PricingError && error.code === 'USER_NOT_FOUND' + ); } async function assertOrderStateMachine(pool, context) { @@ -1947,6 +1998,8 @@ try { 'room price difference and manager time adjustment', 'configured cancellation fee quote', 'store-scoped on-behalf booking rejection', + 'manager on-behalf booking history and audit attribution', + 'cross-tenant on-behalf target user rejection', 'share token stored as SHA-256 only', 'default view and door permissions', 'renew permission denied by default', diff --git a/backend/tests/order-management.test.mjs b/backend/tests/order-management.test.mjs index 0ede0f1..2a49851 100644 --- a/backend/tests/order-management.test.mjs +++ b/backend/tests/order-management.test.mjs @@ -9,6 +9,7 @@ const token = signAccessToken({ }, secret, 900); let called; let customerCalled; +let noteCalled; const authRepository = { async validateSession() { return { @@ -44,7 +45,10 @@ const app = await buildApp({ return { orderId, adjustmentType: 'CHANGE_ROOM', amountDeltaCents: -500 }; }, async adjustTime() { throw new Error('not called'); }, - async note() { throw new Error('not called'); }, + async note(actor, orderId, note) { + noteCalled = { actor, orderId, note }; + return { orderId, note }; + }, async cancellationQuote() { return { allowed: true, feeCents: 0, refundableCents: 3000 }; } @@ -80,6 +84,18 @@ assert.equal(called.orderId, '31'); assert.equal(called.actor.traceId, 'm04c-renew-route'); assert.equal(called.body.pricingPolicy, 'LOCKED'); +const managementNote = await app.inject({ + method: 'POST', + url: '/app-api/management/orders/31/note', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08c-management-note' }, + payload: { note: 'front desk confirmed member arrival' } +}); +assert.equal(managementNote.statusCode, 200); +assert.equal(noteCalled.orderId, '31'); +assert.equal(noteCalled.actor.source, 'ADMIN'); +assert.equal(noteCalled.actor.traceId, 'm08c-management-note'); +assert.equal(noteCalled.note, 'front desk confirmed member arrival'); + const customerRenewed = await app.inject({ method: 'POST', url: '/app-api/orders/31/renew', diff --git a/backend/tests/order-query.test.mjs b/backend/tests/order-query.test.mjs index f1554f9..73aa3b8 100644 --- a/backend/tests/order-query.test.mjs +++ b/backend/tests/order-query.test.mjs @@ -77,7 +77,7 @@ const app = await buildApp({ const listed = await app.inject({ method: 'GET', - url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT', + url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT&storeId=18', headers: { authorization: `Bearer ${token}` } }); assert.equal(listed.statusCode, 200); @@ -87,6 +87,7 @@ assert.equal(listInput.userId, '21'); assert.equal(listInput.page, 2); assert.equal(listInput.pageSize, 5); assert.equal(listInput.status, 'PENDING_PAYMENT'); +assert.equal(listInput.storeId, '18'); const detail = await app.inject({ method: 'GET', diff --git a/backend/tests/order-state.test.mjs b/backend/tests/order-state.test.mjs index fed0336..9f256bb 100644 --- a/backend/tests/order-state.test.mjs +++ b/backend/tests/order-state.test.mjs @@ -61,6 +61,21 @@ assert.equal(transitionInput.action, 'CONFIRM_PAYMENT'); assert.equal(transitionInput.actor.source, 'ADMIN'); assert.equal(transitionInput.actor.traceId, 'm04b-route-transition'); +const managementTransition = await app.inject({ + method: 'POST', + url: '/app-api/management/orders/31/actions', + headers: { + authorization: `Bearer ${token}`, + 'x-trace-id': 'm08c-route-transition' + }, + payload: { action: 'START', reason: 'front desk started the room' } +}); +assert.equal(managementTransition.statusCode, 200); +assert.equal(transitionInput.orderId, '31'); +assert.equal(transitionInput.action, 'START'); +assert.equal(transitionInput.actor.source, 'ADMIN'); +assert.equal(transitionInput.actor.traceId, 'm08c-route-transition'); + const cancelled = await app.inject({ method: 'POST', url: '/app-api/orders/31/cancel', diff --git a/backend/tests/pricing.test.mjs b/backend/tests/pricing.test.mjs index 0bc0b12..ca51356 100644 --- a/backend/tests/pricing.test.mjs +++ b/backend/tests/pricing.test.mjs @@ -20,6 +20,7 @@ const token = signAccessToken({ rv: 1 }, secret, 900); let reserveInput; +let accessProfile = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] }; const app = await buildApp({ pricing: { jwtSecret: secret, @@ -45,7 +46,7 @@ const app = await buildApp({ }, accessControl: { async getAccessProfile() { - return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] }; + return accessProfile; } }, repository: { @@ -98,6 +99,41 @@ assert.deepEqual(reserveInput.benefits, { clientRequestId: 'benefit-route-0001' }); +const forbiddenOnBehalf = await app.inject({ + method: 'POST', + url: '/app-api/management/orders/reserve-on-behalf', + headers: { authorization: `Bearer ${token}` }, + payload: { + userId: '88', roomId: '11', + startAt: startAt.toISOString(), endAt: endAt.toISOString(), pricingMode: 'HOURLY' + } +}); +assert.equal(forbiddenOnBehalf.statusCode, 403); + +accessProfile = { + roles: ['STORE_ADMIN'], + capabilities: ['store.operation.write'], + storeIds: ['18'] +}; +const reservedOnBehalf = await app.inject({ + method: 'POST', + url: '/app-api/management/orders/reserve-on-behalf', + headers: { authorization: `Bearer ${token}` }, + payload: { + userId: '88', roomId: '11', + startAt: startAt.toISOString(), endAt: endAt.toISOString(), pricingMode: 'HOURLY' + } +}); +assert.equal(reservedOnBehalf.statusCode, 201); +assert.equal(reserveInput.userId, '88'); +assert.deepEqual(reserveInput.allowedStoreIds, ['18']); +assert.equal('benefits' in reserveInput, false); +assert.equal(reserveInput.actor.userId, '21'); +assert.equal(reserveInput.actor.source, 'ADMIN'); +assert.ok(reserveInput.actor.traceId); +assert.match(pricingSource, /tenant_id = \? AND id = \? AND status = 'ACTIVE'/); +assert.match(pricingSource, /ORDER_CREATED_ON_BEHALF/); + const unauthenticated = await app.inject({ method: 'POST', url: '/app-api/pricing/quote', diff --git a/miniapp/app.json b/miniapp/app.json index a226670..c40d992 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -10,6 +10,7 @@ "pages/recharge/index", "pages/cleaner/tasks", "pages/manager/dashboard", + "pages/manager/order-create", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/manager/dashboard.js b/miniapp/pages/manager/dashboard.js index 5d27527..67fc804 100644 --- a/miniapp/pages/manager/dashboard.js +++ b/miniapp/pages/manager/dashboard.js @@ -20,6 +20,17 @@ const orderStatusLabels = { REFUNDED: '已退款', CLOSED: '已关闭', } +const orderActionsByStatus = { + DRAFT: [{ action: 'SUBMIT', label: '提交订单' }, { action: 'CANCEL', label: '取消订单' }, { action: 'CLOSE', label: '关闭订单' }], + PENDING_PAYMENT: [{ action: 'CANCEL', label: '取消订单' }, { action: 'CLOSE', label: '关闭订单' }], + PAID: [{ action: 'RESERVE', label: '确认预留' }, { action: 'START', label: '开始使用' }, { action: 'CANCEL', label: '取消订单' }, { action: 'BEGIN_REFUND', label: '发起退款' }], + RESERVED: [{ action: 'START', label: '开始使用' }, { action: 'CANCEL', label: '取消订单' }, { action: 'BEGIN_REFUND', label: '发起退款' }], + IN_PROGRESS: [{ action: 'FINISH', label: '结束使用' }, { action: 'BEGIN_REFUND', label: '发起退款' }], + FINISHED: [{ action: 'BEGIN_REFUND', label: '发起退款' }, { action: 'CLOSE', label: '关闭订单' }], + CANCELLED: [{ action: 'BEGIN_REFUND', label: '发起退款' }, { action: 'CLOSE', label: '关闭订单' }], + REFUNDING: [{ action: 'COMPLETE_REFUND', label: '确认退款完成' }], + REFUNDED: [{ action: 'CLOSE', label: '关闭订单' }], +} Page({ data: { @@ -32,7 +43,17 @@ Page({ rooms: [], orders: [], visibleOrders: [], + orderFilters: [ + { value: '', label: '全部' }, + { value: 'PENDING_PAYMENT', label: '待支付' }, + { value: 'RESERVED', label: '待开始' }, + { value: 'IN_PROGRESS', label: '进行中' }, + { value: 'FINISHED', label: '已结束' }, + { value: 'REFUNDING', label: '退款中' }, + ], + selectedOrderStatus: '', busyRoomId: '', + busyOrderId: '', summary: { roomTotal: 0, available: 0, @@ -46,6 +67,10 @@ Page({ await this.loadDashboard() }, + async onShow() { + if (this.data.selectedStoreId) await this.loadStoreData(this.data.selectedStoreId) + }, + async onPullDownRefresh() { await this.loadDashboard(this.data.selectedStoreId) wx.stopPullDownRefresh() @@ -60,10 +85,7 @@ Page({ 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 storesResponse = await request('/management/stores') const stores = storesResponse.data || [] const selectedStoreId = stores.some((store) => store.id === preferredStoreId) ? preferredStoreId @@ -73,11 +95,10 @@ Page({ || 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) + await this.loadStoreData(selectedStoreId) } catch (error) { this.setData({ errorMessage: error.message || '门店运营数据加载失败' }) } finally { @@ -89,33 +110,54 @@ Page({ 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) + this.setData({ + selectedStoreId: storeId, + selectedStoreName: store?.name || '', + selectedOrderStatus: '', + }) + await this.loadStoreData(storeId) }, - async loadRooms(storeId) { + async loadStoreData(storeId) { if (!storeId) { - this.setData({ rooms: [], visibleOrders: [] }) + this.setData({ rooms: [], orders: [], visibleOrders: [] }) this.refreshSummary() return } try { - const response = await request(`/management/stores/${encodeURIComponent(storeId)}/rooms`) - const rooms = (response.data || []).map((item) => ({ + const encodedStoreId = encodeURIComponent(storeId) + const [roomsResponse, ordersResponse] = await Promise.all([ + request(`/management/stores/${encodedStoreId}/rooms`), + request(`/orders?page=1&pageSize=50&storeId=${encodedStoreId}`), + ]) + const rooms = (roomsResponse.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), - }) + const orders = (ordersResponse.data?.items || []).map((item) => this.presentOrder(item)) + this.setData({ rooms, orders }) + this.applyOrderFilter() this.refreshSummary() } catch (error) { - this.setData({ errorMessage: error.message || '房态加载失败' }) + this.setData({ errorMessage: error.message || '门店运营数据加载失败' }) } }, + selectOrderFilter(event) { + this.setData({ selectedOrderStatus: event.currentTarget.dataset.status || '' }) + this.applyOrderFilter() + }, + + applyOrderFilter() { + const status = this.data.selectedOrderStatus + this.setData({ + visibleOrders: status + ? this.data.orders.filter((item) => item.status === status) + : this.data.orders, + }) + }, + refreshSummary() { const rooms = this.data.rooms const activeStatuses = ['PAID', 'RESERVED', 'IN_PROGRESS'] @@ -125,7 +167,7 @@ Page({ 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, + activeOrders: this.data.orders.filter((item) => activeStatuses.includes(item.status)).length, }, }) }, @@ -158,7 +200,7 @@ Page({ method: 'PATCH', data: { storeId: this.data.selectedStoreId, ...status, reason }, }) - await this.loadRooms(this.data.selectedStoreId) + await this.loadStoreData(this.data.selectedStoreId) wx.showToast({ title: '房态已更新', icon: 'success' }) } catch (error) { this.setData({ errorMessage: error.message || '房态更新失败' }) @@ -167,10 +209,82 @@ Page({ } }, - openOrder(event) { + createOrder() { + if (!this.data.canWrite || !this.data.selectedStoreId) return + wx.navigateTo({ + url: `/pages/manager/order-create?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`, + }) + }, + + manageOrder(event) { + if (!this.data.canWrite) return const orderId = event.currentTarget.dataset.orderId - if (!orderId) return - wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` }) + const status = event.currentTarget.dataset.status + const actions = orderActionsByStatus[status] || [] + if (!actions.length) { + wx.showToast({ title: '当前状态无可用动作', icon: 'none' }) + return + } + wx.showActionSheet({ + itemList: actions.map((item) => item.label), + success: ({ tapIndex }) => { + const selected = actions[tapIndex] + wx.showModal({ + title: selected.label, + content: `确认对订单执行“${selected.label}”吗?`, + success: ({ confirm }) => { + if (confirm) this.executeOrderAction(orderId, selected) + }, + }) + }, + }) + }, + + addOrderNote(event) { + if (!this.data.canWrite) return + const orderId = event.currentTarget.dataset.orderId + wx.showModal({ + title: '添加订单备注', + editable: true, + placeholderText: '请输入备注内容', + success: ({ confirm, content }) => { + const note = String(content || '').trim() + if (confirm && note) this.saveOrderNote(orderId, note) + }, + }) + }, + + async executeOrderAction(orderId, selected) { + if (!orderId || this.data.busyOrderId) return + this.setData({ busyOrderId: orderId, errorMessage: '' }) + try { + await request(`/management/orders/${encodeURIComponent(orderId)}/actions`, { + method: 'POST', + data: { action: selected.action, reason: `管理员小程序:${selected.label}` }, + }) + await this.loadStoreData(this.data.selectedStoreId) + wx.showToast({ title: '订单已更新', icon: 'success' }) + } catch (error) { + this.setData({ errorMessage: error.message || '订单操作失败' }) + } finally { + this.setData({ busyOrderId: '' }) + } + }, + + async saveOrderNote(orderId, note) { + if (!orderId || this.data.busyOrderId) return + this.setData({ busyOrderId: orderId, errorMessage: '' }) + try { + await request(`/management/orders/${encodeURIComponent(orderId)}/note`, { + method: 'POST', + data: { note }, + }) + wx.showToast({ title: '备注已保存', icon: 'success' }) + } catch (error) { + this.setData({ errorMessage: error.message || '订单备注保存失败' }) + } finally { + this.setData({ busyOrderId: '' }) + } }, presentOrder(item) { diff --git a/miniapp/pages/manager/dashboard.wxml b/miniapp/pages/manager/dashboard.wxml index 9afc64d..3c093a1 100644 --- a/miniapp/pages/manager/dashboard.wxml +++ b/miniapp/pages/manager/dashboard.wxml @@ -48,9 +48,24 @@ - 近期订单 + + 近期订单 + + + + + + + 当前门店暂无订单 - + {{item.roomName}} · {{item.roomNo}} @@ -59,6 +74,10 @@ {{item.statusText}} {{item.timeText}}{{item.amountText}} + + + + diff --git a/miniapp/pages/manager/dashboard.wxss b/miniapp/pages/manager/dashboard.wxss index 0707f24..00e5778 100644 --- a/miniapp/pages/manager/dashboard.wxss +++ b/miniapp/pages/manager/dashboard.wxss @@ -81,6 +81,40 @@ margin: 28rpx 0 16rpx; } +.section-heading { + align-items: center; + display: flex; + justify-content: space-between; +} + +.section-heading .section-title { + margin-right: 20rpx; +} + +.section-heading button { + margin: 20rpx 0 8rpx; +} + +.order-filters { + margin-bottom: 16rpx; + white-space: nowrap; + width: 100%; +} + +.order-filters-inner { + display: inline-flex; + gap: 12rpx; +} + +.order-filter { + margin: 0; +} + +.order-filter.active { + background: #0f172a; + color: #fff; +} + .room-card, .order-card { background: #fff; diff --git a/miniapp/pages/manager/order-create.js b/miniapp/pages/manager/order-create.js new file mode 100644 index 0000000..39f9adf --- /dev/null +++ b/miniapp/pages/manager/order-create.js @@ -0,0 +1,124 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +Page({ + data: { + loading: false, + submitting: false, + errorMessage: '', + storeId: '', + storeName: '', + rooms: [], + roomIndex: 0, + userId: '', + startDate: '', + startTime: '', + endDate: '', + endTime: '', + pricingModes: [ + { value: 'HOURLY', label: '小时' }, + { value: 'OVERNIGHT', label: '过夜' }, + { value: 'FULL_DAY', label: '全天' }, + ], + pricingModeIndex: 0, + }, + + async onLoad(options) { + const start = new Date(Date.now() + 60 * 60 * 1000) + start.setMinutes(0, 0, 0) + const end = new Date(start.getTime() + 2 * 60 * 60 * 1000) + this.setData({ + storeId: options.storeId || '', + storeName: options.storeName ? decodeURIComponent(options.storeName) : '', + startDate: this.datePart(start), + startTime: this.timePart(start), + endDate: this.datePart(end), + endTime: this.timePart(end), + }) + await this.loadRooms() + }, + + async loadRooms() { + if (!this.data.storeId) { + this.setData({ errorMessage: '缺少授权门店信息' }) + return + } + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const response = await request(`/management/stores/${encodeURIComponent(this.data.storeId)}/rooms`) + const rooms = (response.data || []) + .filter((room) => room.configurationStatus === 'ENABLED') + .map((room) => ({ + ...room, + displayName: `${room.name} · ${room.roomNo} · ${cents(room.basePriceCents)}起`, + })) + this.setData({ rooms, roomIndex: 0 }) + } catch (error) { + this.setData({ errorMessage: error.message || '房间加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + inputUserId(event) { this.setData({ userId: String(event.detail.value || '').trim() }) }, + selectRoom(event) { this.setData({ roomIndex: Number(event.detail.value) }) }, + selectPricingMode(event) { this.setData({ pricingModeIndex: Number(event.detail.value) }) }, + changeStartDate(event) { this.setData({ startDate: event.detail.value }) }, + changeStartTime(event) { this.setData({ startTime: event.detail.value }) }, + changeEndDate(event) { this.setData({ endDate: event.detail.value }) }, + changeEndTime(event) { this.setData({ endTime: event.detail.value }) }, + + async submit() { + if (this.data.submitting) return + const room = this.data.rooms[this.data.roomIndex] + const pricingMode = this.data.pricingModes[this.data.pricingModeIndex]?.value + const userId = this.data.userId + if (!/^[1-9]\d{0,19}$/.test(userId)) { + this.setData({ errorMessage: '请输入有效的会员用户 ID' }) + return + } + if (!room) { + this.setData({ errorMessage: '请选择可用房间' }) + return + } + const startAt = new Date(`${this.data.startDate}T${this.data.startTime}:00`) + const endAt = new Date(`${this.data.endDate}T${this.data.endTime}:00`) + if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || endAt <= startAt) { + this.setData({ errorMessage: '结束时间必须晚于开始时间' }) + return + } + this.setData({ submitting: true, errorMessage: '' }) + try { + const response = await request('/management/orders/reserve-on-behalf', { + method: 'POST', + data: { + userId, + roomId: room.id, + startAt: startAt.toISOString(), + endAt: endAt.toISOString(), + pricingMode, + }, + }) + wx.showModal({ + title: '代下单成功', + content: `订单 ${response.data?.orderNo || response.data?.orderId || ''} 已创建,价格由服务端计算。`, + showCancel: false, + success: () => wx.navigateBack(), + }) + } catch (error) { + this.setData({ errorMessage: error.message || '代下单失败' }) + } finally { + this.setData({ submitting: false }) + } + }, + + datePart(value) { + const pad = (part) => String(part).padStart(2, '0') + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` + }, + + timePart(value) { + const pad = (part) => String(part).padStart(2, '0') + return `${pad(value.getHours())}:${pad(value.getMinutes())}` + }, +}) diff --git a/miniapp/pages/manager/order-create.json b/miniapp/pages/manager/order-create.json new file mode 100644 index 0000000..dc0a717 --- /dev/null +++ b/miniapp/pages/manager/order-create.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "管理员代下单", + "enablePullDownRefresh": false +} diff --git a/miniapp/pages/manager/order-create.wxml b/miniapp/pages/manager/order-create.wxml new file mode 100644 index 0000000..9b16581 --- /dev/null +++ b/miniapp/pages/manager/order-create.wxml @@ -0,0 +1,41 @@ + + + 管理员代下单 + {{storeName}} + 订单金额由服务端按房间、时段和计价方式重新计算;仅可选择当前账号授权门店的房间。 + {{errorMessage}} + 正在加载房间... + + + 会员用户 ID + + + + 房间 + + {{rooms.length ? rooms[roomIndex].displayName : '暂无可用房间'}} + + + + 计价方式 + + {{pricingModes[pricingModeIndex].label}} + + + + 开始时间 + + {{startDate}} + {{startTime}} + + + + 结束时间 + + {{endDate}} + {{endTime}} + + + + + diff --git a/miniapp/pages/manager/order-create.wxss b/miniapp/pages/manager/order-create.wxss new file mode 100644 index 0000000..8013c6a --- /dev/null +++ b/miniapp/pages/manager/order-create.wxss @@ -0,0 +1,51 @@ +.order-create { + padding-bottom: 48rpx; +} + +.subtitle { + color: #64748b; + margin: 8rpx 0 24rpx; +} + +.notice { + background: #eff6ff; + border: 1rpx solid #bfdbfe; + border-radius: 16rpx; + color: #1e40af; + font-size: 24rpx; + line-height: 1.6; + margin-bottom: 24rpx; + padding: 20rpx; +} + +.field { + margin-bottom: 24rpx; +} + +.label { + color: #334155; + display: block; + font-size: 25rpx; + margin-bottom: 10rpx; +} + +.field input, +.picker-value { + background: #fff; + border: 1rpx solid #cbd5e1; + border-radius: 14rpx; + min-height: 48rpx; + padding: 18rpx; +} + +.datetime-row { + display: grid; + gap: 14rpx; + grid-template-columns: 1fr 1fr; +} + +.loading { + color: #64748b; + padding: 20rpx 0; + text-align: center; +} diff --git a/scripts/check-miniapp-m08-c.mjs b/scripts/check-miniapp-m08-c.mjs index 6a4d9fe..9a2dc80 100644 --- a/scripts/check-miniapp-m08-c.mjs +++ b/scripts/check-miniapp-m08-c.mjs @@ -7,6 +7,7 @@ const read = (path) => readFileSync(join(root, path), 'utf8'); const appJson = JSON.parse(read('miniapp/app.json')); assert.ok(appJson.pages.includes('pages/manager/dashboard')); +assert.ok(appJson.pages.includes('pages/manager/order-create')); const index = read('miniapp/pages/index/index.js') + read('miniapp/pages/index/index.wxml'); for (const pattern of [ @@ -27,15 +28,22 @@ const dashboard = read('miniapp/pages/manager/dashboard.js') + read('miniapp/pages/manager/dashboard.wxss'); for (const pattern of [ '/management/stores', - '/management/stores/${encodeURIComponent(storeId)}/rooms', + '/management/stores/${encodedStoreId}/rooms', '/management/rooms/${encodeURIComponent(roomId)}/status', - '/orders?page=1&pageSize=50', + '/orders?page=1&pageSize=50&storeId=${encodedStoreId}', + '/management/orders/${encodeURIComponent(orderId)}/actions', + '/management/orders/${encodeURIComponent(orderId)}/note', "method: 'PATCH'", 'store.operation.write', 'tenant.manage', 'changeRoomStatus', 'toggleRoomConfiguration', 'activeOrders', + 'selectOrderFilter', + 'manageOrder', + 'addOrderNote', + '代下单', + '订单处置', '实时房态', '近期订单', '只读' @@ -43,6 +51,33 @@ for (const pattern of [ assert.match(dashboard, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } +const orderCreate = read('miniapp/pages/manager/order-create.js') + + read('miniapp/pages/manager/order-create.wxml'); +for (const pattern of [ + '/management/stores/${encodeURIComponent(this.data.storeId)}/rooms', + '/management/orders/reserve-on-behalf', + 'startAt.toISOString()', + 'endAt.toISOString()', + 'pricingMode', + '会员用户 ID', + '价格由服务端' +]) { + assert.match(orderCreate, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const orderRoutes = read('backend/src/routes/order-query.ts') + + read('backend/src/routes/order-state.ts') + + read('backend/src/routes/order-management.ts') + + read('backend/src/routes/pricing.ts'); +for (const pattern of [ + 'storeId: z.string()', + '/app-api/management/orders/:orderId/actions', + '/app-api/management/orders', + '/app-api/management/orders/reserve-on-behalf' +]) { + assert.match(orderRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + const routes = read('backend/src/routes/store-room-management.ts'); for (const pattern of [ '/app-api/management/stores', @@ -64,4 +99,4 @@ const migration = read('database/migrations/2026081001_m08c_staff_management_acc 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.'); +console.log('PASS: M08-C manager/staff miniapp exposes scoped rooms, order handling and on-behalf booking.');