feat(M08-C): 补管理员订单处置与代下单
This commit is contained in:
@@ -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<CountRow[]>(
|
||||
|
||||
@@ -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<UserRow[]>(
|
||||
`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,
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}$/) });
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"pages/recharge/index",
|
||||
"pages/cleaner/tasks",
|
||||
"pages/manager/dashboard",
|
||||
"pages/manager/order-create",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"window": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -48,9 +48,24 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">近期订单</view>
|
||||
<view class="section-heading">
|
||||
<view class="section-title">近期订单</view>
|
||||
<button wx:if="{{canWrite}}" size="mini" type="primary" bindtap="createOrder">代下单</button>
|
||||
</view>
|
||||
<scroll-view class="order-filters" scroll-x enhanced show-scrollbar="false">
|
||||
<view class="order-filters-inner">
|
||||
<button
|
||||
wx:for="{{orderFilters}}"
|
||||
wx:key="value"
|
||||
size="mini"
|
||||
class="{{item.value === selectedOrderStatus ? 'order-filter active' : 'order-filter'}}"
|
||||
data-status="{{item.value}}"
|
||||
bindtap="selectOrderFilter"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
</scroll-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 wx:for="{{visibleOrders}}" wx:key="id" class="order-card">
|
||||
<view class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.roomName}} · {{item.roomNo}}</view>
|
||||
@@ -59,6 +74,10 @@
|
||||
<view class="status-pill">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="order-line"><text>{{item.timeText}}</text><strong>{{item.amountText}}</strong></view>
|
||||
<view wx:if="{{canWrite}}" class="card-actions">
|
||||
<button size="mini" loading="{{busyOrderId === item.id}}" data-order-id="{{item.id}}" data-status="{{item.status}}" bindtap="manageOrder">订单处置</button>
|
||||
<button size="mini" data-order-id="{{item.id}}" bindtap="addOrderNote">添加备注</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "管理员代下单",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container order-create">
|
||||
<view class="title">管理员代下单</view>
|
||||
<view class="subtitle">{{storeName}}</view>
|
||||
<view class="notice">订单金额由服务端按房间、时段和计价方式重新计算;仅可选择当前账号授权门店的房间。</view>
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
<view wx:if="{{loading}}" class="loading">正在加载房间...</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">会员用户 ID</text>
|
||||
<input type="number" value="{{userId}}" placeholder="输入会员用户 ID" bindinput="inputUserId" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">房间</text>
|
||||
<picker range="{{rooms}}" range-key="displayName" value="{{roomIndex}}" bindchange="selectRoom">
|
||||
<view class="picker-value">{{rooms.length ? rooms[roomIndex].displayName : '暂无可用房间'}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">计价方式</text>
|
||||
<picker range="{{pricingModes}}" range-key="label" value="{{pricingModeIndex}}" bindchange="selectPricingMode">
|
||||
<view class="picker-value">{{pricingModes[pricingModeIndex].label}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">开始时间</text>
|
||||
<view class="datetime-row">
|
||||
<picker mode="date" value="{{startDate}}" bindchange="changeStartDate"><view class="picker-value">{{startDate}}</view></picker>
|
||||
<picker mode="time" value="{{startTime}}" bindchange="changeStartTime"><view class="picker-value">{{startTime}}</view></picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">结束时间</text>
|
||||
<view class="datetime-row">
|
||||
<picker mode="date" value="{{endDate}}" bindchange="changeEndDate"><view class="picker-value">{{endDate}}</view></picker>
|
||||
<picker mode="time" value="{{endTime}}" bindchange="changeEndTime"><view class="picker-value">{{endTime}}</view></picker>
|
||||
</view>
|
||||
</view>
|
||||
<button type="primary" loading="{{submitting}}" disabled="{{loading || submitting || !rooms.length}}" bindtap="submit">确认代下单</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user