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
@@ -0,0 +1,96 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { autoAssign, createOrder, giveConsent, respondToOffer, retryAutoAssign, startClient, unassignOrder } from '../src/index.ts';
|
||||
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
|
||||
|
||||
let fx: Awaited<ReturnType<typeof fixtures>>;
|
||||
beforeEach(async () => {
|
||||
fx = await fixtures();
|
||||
// при равном счёте компания идёт раньше по алфавиту — в большинстве тестов проверяем частных исполнителей
|
||||
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: false } });
|
||||
});
|
||||
afterAll(() => db.$disconnect());
|
||||
|
||||
async function order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number, lang = 'ru') {
|
||||
const s = await startClient(ctx, tgUser(tg, lang));
|
||||
await giveConsent(ctx, s.clientId);
|
||||
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
|
||||
}
|
||||
const setting = (key: string, value: unknown) => db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
|
||||
const history = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event).filter(Boolean);
|
||||
|
||||
describe('автораспределение заказов', () => {
|
||||
it('новый заказ сразу предлагается лучшему: язык клиента и район; система — автор предложения', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const o = await order(ctx, 801);
|
||||
const res = await autoAssign(ctx, o.id);
|
||||
expect(res).toMatchObject({ result: 'offered', target: 'Maria' });
|
||||
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
|
||||
expect(offer).toMatchObject({ providerId: fx.maria.id, status: 'OFFERED', createdById: null });
|
||||
expect(events).toContainEqual({ type: 'offer.created', offerId: offer.id });
|
||||
expect(await history(o.id)).toContain('autoOffered');
|
||||
// пока предложение действует — не трогаем
|
||||
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'skipped' });
|
||||
});
|
||||
|
||||
it('отказ — следующему; кому уже предлагали, повторно не предлагаем', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const o = await order(ctx, 802);
|
||||
await autoAssign(ctx, o.id);
|
||||
const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
|
||||
await respondToOffer(ctx, { offerId: first.id, providerId: fx.maria.id, accept: false });
|
||||
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
|
||||
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
|
||||
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
|
||||
// больше свободных частных нет — компания включается и получает заказ
|
||||
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
|
||||
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
|
||||
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
|
||||
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
|
||||
});
|
||||
|
||||
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const o = await order(ctx, 803);
|
||||
await autoAssign(ctx, o.id);
|
||||
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
|
||||
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
|
||||
const admin = await db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
|
||||
await unassignOrder(ctx, { orderId: o.id, adminId: admin.id });
|
||||
const again = await autoAssign(ctx, o.id);
|
||||
expect(again.result).toBe('offered');
|
||||
expect(again).not.toMatchObject({ target: 'Maria' });
|
||||
});
|
||||
|
||||
it('лимит попыток: дальше вручную, команде сообщаем один раз', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
await setting('assign.maxAutoOffers', 1);
|
||||
const o = await order(ctx, 804);
|
||||
await autoAssign(ctx, o.id);
|
||||
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
|
||||
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: false });
|
||||
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'gaveUp', first: true });
|
||||
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'gaveUp', first: false });
|
||||
expect((await history(o.id)).filter((e) => e === 'autoGaveUp')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('свободных нет — пометка один раз; повторная попытка воркера находит освободившегося', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const o = await order(ctx, 805);
|
||||
// все недоступны: исполнители отключены
|
||||
await db.provider.updateMany({ data: { active: false } });
|
||||
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'none', first: true });
|
||||
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'none', first: false });
|
||||
await db.provider.update({ where: { id: fx.ani.id }, data: { active: true } });
|
||||
const retried = await retryAutoAssign(ctx);
|
||||
expect(retried).toEqual([{ orderId: o.id, res: expect.objectContaining({ result: 'offered', target: 'Ani' }) }]);
|
||||
});
|
||||
|
||||
it('выключено в настройках — заказы ждут диспетчера', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
await setting('assign.auto', false);
|
||||
const o = await order(ctx, 806);
|
||||
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'off' });
|
||||
expect(await retryAutoAssign(ctx)).toEqual([]);
|
||||
expect(await db.orderOffer.count({ where: { orderId: o.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -302,7 +302,7 @@ describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
|
||||
expect(done.status).toBe('DONE');
|
||||
expect(done.completedAt).not.toBeNull();
|
||||
const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
|
||||
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
|
||||
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'offered', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
|
||||
});
|
||||
|
||||
it('чужой исполнитель не может двигать заказ', async () => {
|
||||
|
||||
Reference in new issue
Block a user