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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 02:25:41 +04:00
1 parent 2772837e29
commit e7de4f5893
33 files changed
+4521 -45

No files matched your search

+42
View File
@@ -163,6 +163,48 @@ app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', asy
return sendChatFile(reply, m);
});
// ───── исполнители и заказы (этап 3) ─────
/** Заказы и исполнителей ведёт поддержка; суперадмину — всё */
const canDispatch = (staff: NonNullable<Awaited<ReturnType<typeof staffFromCookie>>>) => staff.admin.roles.includes('SUPERADMIN') || staff.admin.roles.includes('SUPPORT');
const DOC_TYPES = /^(image\/(jpeg|png|webp|heic)|application\/pdf)$/;
/** Документ для допуска исполнителя: паспорт, договор… (фото или PDF) */
app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
const provider = await db.provider.findUnique({ where: { id: req.params.providerId } });
if (!provider) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!DOC_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`providers/${provider.id}`, body, part.mimetype, fileName);
const title = (multipartField(part, 'title') ?? '').trim().slice(0, 120) || fileName;
const doc = await db.providerDoc.create({ data: { providerId: provider.id, title, fileKey, fileName, mime: part.mimetype, size: body.length, uploadedById: staff.admin.id } });
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.providers.addDoc', entity: 'providers', entityId: provider.id, data: { title } } });
return { id: doc.id };
});
app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const d = await db.providerDoc.findUnique({ where: { id: req.params.docId } });
if (!d) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, { fileKey: d.fileKey, tgFileId: null, tgBot: null, mime: d.mime, fileName: d.fileName });
});
/** Фотоотчёт по заказу; каждый просмотр — в журнал доступа */
app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const f = await db.orderFile.findUnique({ where: { id: req.params.fileId } });
if (!f || f.deletedAt) return reply.code(404).send({ error: 'NOT_FOUND' });
await db.fileAccessLog.create({ data: { fileId: f.id, actorType: 'ADMIN', actorId: staff.admin.id } });
return sendChatFile(reply, { fileKey: f.storageKey, tgFileId: null, tgBot: null, mime: f.mime, fileName: `order-photo.${f.mime.split('/')[1] ?? 'jpg'}` });
});
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
function clientFromInit(req: FastifyRequest) {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;