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),