feat(M08-A): 接入顾客端续费换房分享
This commit is contained in:
@@ -83,6 +83,14 @@ export class OrderManagementRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async customerRenew(actor: OrderActor, orderId: string, input: {
|
||||
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
|
||||
}) {
|
||||
return this.renewForActor(actor, orderId, input, async (connection, order) => {
|
||||
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
|
||||
});
|
||||
}
|
||||
|
||||
async changeRoom(actor: OrderActor, orderId: string, input: {
|
||||
roomId: string; reason: string;
|
||||
}) {
|
||||
@@ -124,6 +132,17 @@ export class OrderManagementRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async customerChangeRoom(actor: OrderActor, orderId: string, input: {
|
||||
roomId: string; reason: string;
|
||||
}) {
|
||||
return this.changeRoomForActor(actor, orderId, input, async (connection, order, target) => {
|
||||
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
|
||||
if (target.storeId !== order.storeId) {
|
||||
throw new OrderManagementError('ORDER_ROOM_CHANGE_STORE_INVALID');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async adjustTime(actor: OrderActor, orderId: string, input: {
|
||||
startAt?: Date; endAt?: Date; reason: string;
|
||||
}) {
|
||||
@@ -220,6 +239,93 @@ export class OrderManagementRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private async renewForActor(
|
||||
actor: OrderActor, orderId: string, input: {
|
||||
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
|
||||
},
|
||||
authorize: (connection: PoolConnection, order: OrderRow) => Promise<void>
|
||||
) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
await authorize(connection, order);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
|
||||
throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID');
|
||||
}
|
||||
if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId
|
||||
);
|
||||
const unitPrice = await this.resolveUnitPrice(
|
||||
connection, actor.tenantId, order, input.pricingPolicy
|
||||
);
|
||||
const amountDeltaCents = Math.ceil(
|
||||
(input.endAt.getTime() - order.endAt.getTime()) / 3600000
|
||||
) * unitPrice;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET end_at = ?, total_amount_cents = total_amount_cents + ?,
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET ends_at = ?, expires_at = GREATEST(expires_at, ?)
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[input.endAt, input.endAt, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'RENEW', input.reason, {
|
||||
endAt: order.endAt
|
||||
}, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
private async changeRoomForActor(
|
||||
actor: OrderActor, orderId: string, input: { roomId: string; reason: string },
|
||||
authorize: (
|
||||
connection: PoolConnection, order: OrderRow, target: RoomRow
|
||||
) => Promise<void>
|
||||
) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
|
||||
throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID');
|
||||
}
|
||||
if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]);
|
||||
const target = await this.loadRoom(connection, actor.tenantId, input.roomId);
|
||||
await authorize(connection, order, target);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId
|
||||
);
|
||||
const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId);
|
||||
const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
|
||||
const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET store_id = ?, room_id = ?,
|
||||
total_amount_cents = GREATEST(0, total_amount_cents + ?),
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[target.storeId, target.id, amountDeltaCents, amountDeltaCents,
|
||||
actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations SET room_id = ?
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[target.id, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, {
|
||||
storeId: order.storeId, roomId: order.roomId
|
||||
}, { storeId: target.storeId, roomId: target.id }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) {
|
||||
const [rows] = await connection.execute<OrderRow[]>(
|
||||
`SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
@@ -27,7 +27,8 @@ const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict();
|
||||
|
||||
export interface OrderManagementRouteOptions {
|
||||
repository: Pick<OrderManagementRepository,
|
||||
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'>;
|
||||
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'
|
||||
| 'customerRenew' | 'customerChangeRoom'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -36,6 +37,34 @@ export interface OrderManagementRouteOptions {
|
||||
export async function registerOrderManagementRoutes(
|
||||
app: FastifyInstance, options: OrderManagementRouteOptions
|
||||
) {
|
||||
app.post('/app-api/orders/:orderId/renew', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = renewSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = customerActor(auth, request);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.customerRenew(actor, params.data.orderId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/orders/:orderId/change-room', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = roomSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = customerActor(auth, request);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.customerChangeRoom(actor, params.data.orderId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
@@ -82,6 +111,17 @@ export async function registerOrderManagementRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
function customerActor(
|
||||
auth: { tenantId: string; userId: string; access: AccessProfile },
|
||||
request: FastifyRequest
|
||||
): OrderActor {
|
||||
return {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'APP', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
authorization: string | undefined, options: OrderManagementRouteOptions
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user