Backoffice stage 3: orders and providers, auto-assignment
- Auto-assignment: a new order is offered to the best free provider (same ranking as
the dispatcher hint); a decline or timeout passes it to the next one; nobody is
offered the same order twice; after N tries it waits for a dispatcher and the team
is notified once. The worker retries waiting orders every minute. Settings
assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
done, cancelled), periods and search; order card with progress, provider or ranked
candidates ("offer" / "pick automatically"), details, checklist and photos,
history in plain words, client, money (superadmin), payment, status change,
reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
quality, ratings, upcoming orders, schedule and districts, admission documents,
block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
and documents (ProviderDoc).
- Tests for auto-assignment.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2772837e29
commit
e7de4f5893
33 files changed
+4521
-45
No files matched your search
@@ -74,6 +74,10 @@ export interface CreateOrderInput {
|
||||
note?: string;
|
||||
paymentMethodId?: string;
|
||||
repeatOfId?: string;
|
||||
/** заказ оформила команда за клиента (новая админка) */
|
||||
createdBy?: { type: 'ADMIN'; id: string };
|
||||
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
|
||||
allowBusySlot?: boolean;
|
||||
}
|
||||
|
||||
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
|
||||
@@ -88,7 +92,8 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
|
||||
async (tx) => {
|
||||
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
|
||||
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
|
||||
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
|
||||
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
|
||||
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
|
||||
return tx.order.create({
|
||||
data: {
|
||||
@@ -115,7 +120,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
|
||||
checklist: {
|
||||
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
|
||||
},
|
||||
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
|
||||
history: { create: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -355,6 +360,42 @@ export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; cli
|
||||
for (const e of events) await ctx.notify.emit(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Перенос командой: в любой момент до начала работы. Если назначенный исполнитель занят в новое время —
|
||||
* заказ возвращается в «Новые» (и уйдёт в автораспределение). allowBusySlot — время вне свободных слотов.
|
||||
*/
|
||||
export async function rescheduleByAdmin(ctx: Ctx, input: { orderId: string; adminId: string; scheduledAt: Date; allowBusySlot?: boolean; comment?: string }) {
|
||||
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
|
||||
if (!order) throw new DomainError('NOT_FOUND');
|
||||
const flow = getFlow(order.service.flow);
|
||||
if (order.status !== flow.initial && order.status !== flow.assigned) throw new DomainError('TRANSITION_NOT_ALLOWED');
|
||||
if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
|
||||
const service = await getServiceByCode(ctx.db, order.service.code);
|
||||
const events: DomainEvent[] = [{ type: 'order.rescheduled', orderId: order.id }];
|
||||
await ctx.db.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
|
||||
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, order.durationMinutes, ctx.now(), order.id);
|
||||
let keepProvider = false;
|
||||
if (order.providerId) {
|
||||
const end = new Date(input.scheduledAt.getTime() + order.durationMinutes * 60_000);
|
||||
const [p] = (await loadAvailability(tx, service.providerRole, input.scheduledAt, end, { excludeOrderId: order.id })).filter((x) => x.id === order.providerId);
|
||||
keepProvider = !!p && isProviderFree(p, { start: input.scheduledAt, end });
|
||||
}
|
||||
// действующее предложение было на старое время — отзываем
|
||||
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
|
||||
await tx.order.update({ where: { id: order.id }, data: { scheduledAt: input.scheduledAt, reminded24hAt: null, reminded1hAt: null } });
|
||||
await tx.orderStatusHistory.create({
|
||||
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'rescheduled', actorType: 'ADMIN', actorId: input.adminId, comment: input.comment },
|
||||
});
|
||||
if (order.providerId && !keepProvider) {
|
||||
const r = await transitionOrder(ctx, { orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'rescheduled', data: { providerId: null, companyId: null } }, tx);
|
||||
events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }, r.event);
|
||||
}
|
||||
});
|
||||
for (const e of events) await ctx.notify.emit(e);
|
||||
return { keptProvider: events.length === 1 };
|
||||
}
|
||||
|
||||
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
|
||||
export async function repeatDraft(db: Db, orderId: string, clientId: string) {
|
||||
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
|
||||
|
||||
Reference in new issue
Block a user