483 lines
21 KiB
TypeScript
483 lines
21 KiB
TypeScript
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
|
import type { MySqlPool } from '../db/mysql.js';
|
|
import type { OrderActor } from './order-state-repository.js';
|
|
|
|
type PricingPolicy = 'CURRENT' | 'LOCKED';
|
|
type AdjustableStatus = 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS';
|
|
|
|
interface OrderRow extends RowDataPacket {
|
|
id: string;
|
|
storeId: string;
|
|
roomId: string;
|
|
status: AdjustableStatus;
|
|
startAt: Date;
|
|
endAt: Date;
|
|
totalAmountCents: number;
|
|
adjustmentAmountCents: number;
|
|
cancellationCutoffMinutes: number;
|
|
cancellationFeeBps: number;
|
|
}
|
|
interface RoomRow extends RowDataPacket {
|
|
id: string;
|
|
storeId: string;
|
|
basePriceCents: number;
|
|
weekdayPriceCents: number;
|
|
holidayPriceCents: number;
|
|
timezone: string;
|
|
operationalStatus: string;
|
|
configurationStatus: string;
|
|
}
|
|
interface SnapshotRow extends RowDataPacket { unitPriceCents: number }
|
|
interface DuplicateRow extends RowDataPacket {
|
|
id: string;
|
|
adjustmentType: string;
|
|
amountDeltaCents: number;
|
|
}
|
|
|
|
export class OrderManagementError extends Error {
|
|
constructor(public readonly code: string) { super(code); }
|
|
}
|
|
|
|
export class OrderManagementRepository {
|
|
constructor(private readonly pool: MySqlPool) {}
|
|
|
|
async renew(actor: OrderActor, orderId: string, input: {
|
|
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
|
|
}) {
|
|
return this.transaction(async (connection) => {
|
|
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
|
this.assertManager(actor.access, order.storeId);
|
|
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);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
return this.transaction(async (connection) => {
|
|
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
|
this.assertManager(actor.access, order.storeId);
|
|
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);
|
|
this.assertManager(actor.access, target.storeId);
|
|
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);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
return this.transaction(async (connection) => {
|
|
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
|
this.assertManager(actor.access, order.storeId);
|
|
const duplicate = await this.duplicate(connection, actor, orderId);
|
|
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
|
const startAt = input.startAt ?? order.startAt;
|
|
const endAt = input.endAt ?? order.endAt;
|
|
if (endAt <= startAt) throw new OrderManagementError('ORDER_TIME_WINDOW_INVALID');
|
|
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
|
|
await this.assertAvailable(
|
|
connection, actor.tenantId, order.roomId, startAt, endAt, orderId
|
|
);
|
|
const oldHours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
|
|
const newHours = Math.ceil((endAt.getTime() - startAt.getTime()) / 3600000);
|
|
const unitPrice = await this.resolveUnitPrice(connection, actor.tenantId, order, 'LOCKED');
|
|
const amountDeltaCents = (newHours - oldHours) * unitPrice;
|
|
await connection.execute(
|
|
`UPDATE qipai_orders SET start_at = ?, end_at = ?,
|
|
total_amount_cents = GREATEST(0, total_amount_cents + ?),
|
|
adjustment_amount_cents = adjustment_amount_cents + ?
|
|
WHERE tenant_id = ? AND id = ?`,
|
|
[startAt, endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
|
|
);
|
|
await connection.execute(
|
|
`UPDATE qipai_room_reservations SET starts_at = ?, ends_at = ?,
|
|
expires_at = GREATEST(expires_at, ?)
|
|
WHERE tenant_id = ? AND order_id = ?`,
|
|
[startAt, endAt, endAt, actor.tenantId, orderId]
|
|
);
|
|
return this.record(connection, actor, order, 'ADJUST_TIME', input.reason, {
|
|
startAt: order.startAt, endAt: order.endAt
|
|
}, { startAt, endAt }, amountDeltaCents);
|
|
});
|
|
}
|
|
|
|
async note(actor: OrderActor, orderId: string, note: string) {
|
|
return this.transaction(async (connection) => {
|
|
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
|
this.assertManager(actor.access, order.storeId);
|
|
const duplicate = await this.duplicate(connection, actor, orderId);
|
|
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
|
await connection.execute(
|
|
`UPDATE qipai_orders SET operator_note = ? WHERE tenant_id = ? AND id = ?`,
|
|
[note.slice(0, 512), actor.tenantId, orderId]
|
|
);
|
|
return this.record(connection, actor, order, 'NOTE', note, {}, { note }, 0);
|
|
});
|
|
}
|
|
|
|
async cancellationQuote(tenantId: string, userId: string, orderId: string) {
|
|
return this.transaction(async (connection) => {
|
|
const order = await this.loadOrder(connection, tenantId, orderId);
|
|
await this.assertOwner(connection, tenantId, orderId, userId);
|
|
const minutesBeforeStart = Math.floor((order.startAt.getTime() - Date.now()) / 60000);
|
|
const feeCents = minutesBeforeStart >= order.cancellationCutoffMinutes
|
|
? 0 : Math.ceil(order.totalAmountCents * order.cancellationFeeBps / 10000);
|
|
return {
|
|
orderId,
|
|
allowed: order.status !== 'IN_PROGRESS',
|
|
cutoffMinutes: order.cancellationCutoffMinutes,
|
|
feeCents,
|
|
refundableCents: Math.max(0, order.totalAmountCents - feeCents)
|
|
};
|
|
});
|
|
}
|
|
|
|
private async record(
|
|
connection: PoolConnection, actor: OrderActor, order: OrderRow,
|
|
type: string, reason: string, before: object, after: object, amountDeltaCents: number
|
|
) {
|
|
const [result] = await connection.execute<ResultSetHeader>(
|
|
`INSERT INTO qipai_order_adjustments
|
|
(tenant_id, order_id, adjustment_type, actor_id, source, trace_id,
|
|
reason, before_values, after_values, amount_delta_cents)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[actor.tenantId, order.id, type, actor.userId, actor.source, actor.traceId,
|
|
reason.slice(0, 512), JSON.stringify(before), JSON.stringify(after), amountDeltaCents]
|
|
);
|
|
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_MANUALLY_ADJUSTED', 'ORDER', ?, ?, ?, ?,
|
|
JSON_OBJECT('adjustmentType', ?, 'amountDeltaCents', ?))`,
|
|
[actor.tenantId, actor.userId, order.id, actor.traceId, actor.ip,
|
|
actor.userAgent.slice(0, 255), type, amountDeltaCents]
|
|
);
|
|
return {
|
|
orderId: order.id, adjustmentId: String(result.insertId),
|
|
adjustmentType: type, amountDeltaCents, idempotent: false
|
|
};
|
|
}
|
|
|
|
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,
|
|
o.start_at AS startAt, o.end_at AS endAt,
|
|
o.total_amount_cents AS totalAmountCents,
|
|
o.adjustment_amount_cents AS adjustmentAmountCents,
|
|
s.cancellation_cutoff_minutes AS cancellationCutoffMinutes,
|
|
s.cancellation_fee_bps AS cancellationFeeBps
|
|
FROM qipai_orders o
|
|
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
|
|
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL FOR UPDATE`,
|
|
[tenantId, orderId]
|
|
);
|
|
if (!rows[0]) throw new OrderManagementError('ORDER_NOT_FOUND');
|
|
return rows[0];
|
|
}
|
|
|
|
private async loadRoom(connection: PoolConnection, tenantId: string, roomId: string) {
|
|
const [rows] = await connection.execute<RoomRow[]>(
|
|
`SELECT r.id, r.store_id AS storeId, r.base_price_cents AS basePriceCents,
|
|
r.weekday_price_cents AS weekdayPriceCents,
|
|
r.holiday_price_cents AS holidayPriceCents,
|
|
r.operational_status AS operationalStatus,
|
|
r.configuration_status AS configurationStatus, s.timezone
|
|
FROM qipai_rooms r
|
|
INNER JOIN qipai_stores s
|
|
ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL
|
|
WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL`,
|
|
[tenantId, roomId]
|
|
);
|
|
const room = rows[0];
|
|
if (!room) throw new OrderManagementError('ROOM_NOT_FOUND');
|
|
if (room.operationalStatus !== 'AVAILABLE' || room.configurationStatus !== 'ENABLED') {
|
|
throw new OrderManagementError('ROOM_NOT_AVAILABLE');
|
|
}
|
|
return room;
|
|
}
|
|
|
|
private async lockRooms(connection: PoolConnection, tenantId: string, roomIds: string[]) {
|
|
const sorted = [...new Set(roomIds)].sort((a, b) => Number(a) - Number(b));
|
|
for (const roomId of sorted) {
|
|
await connection.execute(
|
|
`SELECT id FROM qipai_rooms WHERE tenant_id = ? AND id = ? FOR UPDATE`,
|
|
[tenantId, roomId]
|
|
);
|
|
}
|
|
}
|
|
|
|
private async assertAvailable(
|
|
connection: PoolConnection, tenantId: string, roomId: string,
|
|
startAt: Date, endAt: Date, excludingOrderId: string
|
|
) {
|
|
const [disabled] = await connection.execute<RowDataPacket[]>(
|
|
`SELECT id FROM qipai_room_disabled_periods
|
|
WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ? FOR UPDATE`,
|
|
[tenantId, roomId, endAt, startAt]
|
|
);
|
|
if (disabled[0]) throw new OrderManagementError('ROOM_DISABLED_PERIOD');
|
|
const [rows] = await connection.execute<RowDataPacket[]>(
|
|
`SELECT id FROM qipai_room_reservations
|
|
WHERE tenant_id = ? AND room_id = ? AND order_id <> ?
|
|
AND status IN ('HELD', 'CONSUMED')
|
|
AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3))
|
|
AND starts_at < ? AND ends_at > ? FOR UPDATE`,
|
|
[tenantId, roomId, excludingOrderId, endAt, startAt]
|
|
);
|
|
if (rows[0]) throw new OrderManagementError('TIME_SLOT_CONFLICT');
|
|
}
|
|
|
|
private async resolveUnitPrice(
|
|
connection: PoolConnection, tenantId: string, order: OrderRow, policy: PricingPolicy
|
|
) {
|
|
if (policy === 'LOCKED') {
|
|
const [rows] = await connection.execute<SnapshotRow[]>(
|
|
`SELECT unit_price_cents AS unitPriceCents
|
|
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
|
|
[tenantId, order.id]
|
|
);
|
|
if (!rows[0]) throw new OrderManagementError('ORDER_PRICE_SNAPSHOT_MISSING');
|
|
return Number(rows[0].unitPriceCents);
|
|
}
|
|
const room = await this.loadRoom(connection, tenantId, order.roomId);
|
|
const localDate = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: room.timezone, year: 'numeric', month: '2-digit', day: '2-digit'
|
|
}).format(order.endAt);
|
|
const [holidayRows] = await connection.execute<RowDataPacket[]>(
|
|
`SELECT 1 FROM qipai_holiday_calendar
|
|
WHERE tenant_id = ? AND holiday_date = ? LIMIT 1`,
|
|
[tenantId, localDate]
|
|
);
|
|
if (holidayRows[0] && room.holidayPriceCents > 0) return Number(room.holidayPriceCents);
|
|
const weekdayName = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: room.timezone, weekday: 'short'
|
|
}).format(order.endAt);
|
|
if (['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].includes(weekdayName)
|
|
&& room.weekdayPriceCents > 0) {
|
|
return Number(room.weekdayPriceCents);
|
|
}
|
|
return Number(room.basePriceCents);
|
|
}
|
|
|
|
private async duplicate(connection: PoolConnection, actor: OrderActor, orderId: string) {
|
|
const [rows] = await connection.execute<DuplicateRow[]>(
|
|
`SELECT id, adjustment_type AS adjustmentType,
|
|
amount_delta_cents AS amountDeltaCents
|
|
FROM qipai_order_adjustments
|
|
WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`,
|
|
[actor.tenantId, orderId, actor.traceId]
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
private duplicateResult(orderId: string, duplicate: DuplicateRow) {
|
|
return {
|
|
orderId, adjustmentId: String(duplicate.id),
|
|
adjustmentType: duplicate.adjustmentType,
|
|
amountDeltaCents: Number(duplicate.amountDeltaCents), idempotent: true
|
|
};
|
|
}
|
|
|
|
private assertManager(access: AccessProfile | undefined, storeId: string) {
|
|
if (!access || !(access.capabilities.includes('tenant.manage')
|
|
|| access.roles.includes('PLATFORM_ADMIN')
|
|
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)))) {
|
|
throw new OrderManagementError('ORDER_MANAGEMENT_FORBIDDEN');
|
|
}
|
|
}
|
|
|
|
private async assertOwner(
|
|
connection: PoolConnection, tenantId: string, orderId: string, userId: string
|
|
) {
|
|
const [rows] = await connection.execute<RowDataPacket[]>(
|
|
`SELECT 1 FROM qipai_order_user_access
|
|
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
|
|
[tenantId, orderId, userId]
|
|
);
|
|
if (!rows[0]) throw new OrderManagementError('ORDER_ACCESS_FORBIDDEN');
|
|
}
|
|
|
|
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
|
const connection = await this.pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
const result = await work(connection);
|
|
await connection.commit();
|
|
return result;
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
}
|