From 8ed1a02967b15c527146411bafc5f492f3d93853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 16 Oct 2026 12:33:38 +0500 Subject: [PATCH] fix: prevent duplicate project resource assignments With two tabs open (or two users) the same profession or company could be assigned twice, because only the list loaded in the tab was checked. Before creating an assignment the server is now asked whether it already exists; if so its id is reused. Found in T-RES-03. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/MIGRATION-CHECKLIST.md | 1 + .../resources/resourcesRepository.test.ts | 29 +++++++++++++++---- .../pages/resources/resourcesRepository.ts | 23 ++++++++++++++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index 9ad8feb..1f4ad02 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -241,6 +241,7 @@ - Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP. - Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль. - Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`. +- 🔧 **Исправлено (найдено при проверке T-RES, 2026-10-16):** перед добавлением проверяется, нет ли уже такой привязки на сервере. Раньше при двух открытых вкладках (или двух пользователях) одна профессия / компания добавлялась дважды. Если привязка уже есть — новая запись не создаётся, показывается существующая. --- diff --git a/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts b/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts index 9402936..c1d5aeb 100644 --- a/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts +++ b/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts @@ -3,6 +3,7 @@ import { assignedItemsUrl, assignedItemUrl, deleteAssignedResource, + existingAssignmentUrl, fetchAllItems, fetchProjectResources, poolItemsUrl, @@ -105,13 +106,29 @@ describe('fetchProjectResources', () => { }); describe('addAssignedResource', () => { + /** Проверка на дубль: привязки ещё нет. */ + const notAssignedYet = { match: '$top=1', response: { body: { value: [] } } }; + + it('сначала ищет существующую привязку этого элемента к проекту', () => { + expect(existingAssignmentUrl(SITE, 'professions', '5', 12)).toEqual( + `${items('Профессии по проекту')}?$select=Id&$top=1&$filter=projectIdId eq 5 and professionIdId eq 12` + ); + }); + + it('привязка уже есть (добавили в другой вкладке) — новая не создаётся, возвращается её Id', async () => { + const client = createFakeHttpClient([{ match: '$top=1', response: { body: { value: [{ Id: 77 }] } } }]); + + expect(await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' })).toEqual(77); + expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false); + }); + it('профессия: POST в items с телом как в assigner, возвращает Id', async () => { - const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 101 } } }]); + const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 101 } } }]); const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' }); expect(id).toEqual(101); - expect(client.requests[0]).toEqual({ + expect(client.requests[1]).toEqual({ url: items('Профессии по проекту'), request: { method: 'POST', @@ -126,16 +143,16 @@ describe('addAssignedResource', () => { }); it('компания: поле companyIdId', async () => { - const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 7 } } }]); + const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 7 } } }]); await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' }); - expect(client.requests[0].url).toEqual(items('Компании по проекту')); - expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}'); + expect(client.requests[1].url).toEqual(items('Компании по проекту')); + expect(client.requests[1].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}'); }); it('ошибка сервера пробрасывается', async () => { - const client = createFakeHttpClient([{ match: 'items', response: { status: 500, body: 'boom' } }]); + const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 500, body: 'boom' } }]); await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow( '500' ); diff --git a/src/webparts/stroyBot/pages/resources/resourcesRepository.ts b/src/webparts/stroyBot/pages/resources/resourcesRepository.ts index 9b2a18d..3fa7c60 100644 --- a/src/webparts/stroyBot/pages/resources/resourcesRepository.ts +++ b/src/webparts/stroyBot/pages/resources/resourcesRepository.ts @@ -130,7 +130,20 @@ export async function fetchProjectResources( }; } -/** Создаёт привязку и возвращает id новой записи. */ +/** Уже существующая привязка этого элемента к проекту (`{поле}Id` — id из lookup). */ +export function existingAssignmentUrl(siteUrl: string, kind: ResourceKind, projectId: string, itemId: number): string { + const { assignedList, lookupField } = RESOURCE_LISTS[kind]; + return ( + `${listItemsUrl(siteUrl, assignedList)}?$select=Id&$top=1` + + `&$filter=${PROJECT_LOOKUP_ID_FIELD} eq ${Number(projectId)} and ${lookupField}Id eq ${itemId}` + ); +} + +/** + * Создаёт привязку и возвращает её id. Если такая привязка уже есть (её создали в другой вкладке + * или другой пользователь, пока у нас был открыт старый список), новая не создаётся — возвращается + * id существующей. В старом assigner такой проверки не было, и получались дубли. + */ export async function addAssignedResource( client: IHttpClient, siteUrl: string, @@ -138,6 +151,14 @@ export async function addAssignedResource( projectId: string, item: IResourceItem ): Promise { + const existing = await getJson<{ value: Array<{ Id: number }> }>( + client, + existingAssignmentUrl(siteUrl, kind, projectId, item.id) + ); + if (existing.value.length > 0) { + return existing.value[0].Id; + } + const body = { Title: item.title, [PROJECT_LOOKUP_ID_FIELD]: Number(projectId),