From 023c0913f1ce48fecfcacec1c23cd5526cb44dde Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 27 Oct 2026 12:35:53 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=83=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20API=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B8=20OCR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сверено с api-tnsu-portal (DictionaryController, DocumentController): - статьи ДДС — CashFlowItems?id=<группа> (было groupId, сервер отвечал 400); - OCR — Document/extractPDFText, текст в file_content пишется до ответа, поэтому колонка читается сразу, без опроса; - договоры и банковские счета — постраничные, поиск на сервере (search), иначе находились только первые 50 записей; фильтра по компании у договоров нет; - счета по проекту с откатом на фильтр только по компании: совпадение Id проекта из /Project с bank_accounts.project_id не гарантировано; - Id-фильтры передаются только в формате GUID, иначе API отвечает 400; - подписи записей по реальным полям (account_number, bank, valuta, counterparty_name); разбор вынесен в oneCRecords.ts с тестами; - код позиции потребности — lineNumber; - пустой ответ Gemini (200 с error) считается ошибкой. Co-Authored-By: Claude Opus 5.5 --- .../forms/PaymentRequest/README.md | 13 ++- .../sections/PaymentSection.tsx | 36 +++--- .../services/invoice.service.ts | 26 ++--- .../services/needBasis.service.ts | 4 +- .../requestPortal/shared/ai/gemini.service.ts | 7 +- .../dictionary/dictionaryList.service.ts | 109 +++++++++++------- .../requestPortal/shared/dictionary/index.ts | 9 +- .../shared/dictionary/oneCRecords.test.ts | 29 +++++ .../shared/dictionary/oneCRecords.ts | 60 ++++++++++ .../shared/dictionary/ui/DictionaryPicker.tsx | 80 ++++++++----- .../shared/dictionary/ui/ItemPicker.tsx | 8 +- 11 files changed, 271 insertions(+), 110 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dictionary/oneCRecords.test.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/oneCRecords.ts diff --git a/src/webparts/requestPortal/forms/PaymentRequest/README.md b/src/webparts/requestPortal/forms/PaymentRequest/README.md index ba2d981..9ab39e8 100644 --- a/src/webparts/requestPortal/forms/PaymentRequest/README.md +++ b/src/webparts/requestPortal/forms/PaymentRequest/README.md @@ -17,14 +17,17 @@ - **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта. - **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует. - **Ранее поданные заявки на оплату** — читаются из списка данных этой же формы по `BasisKey` (`ct:` / `need:`), без черновиков, отклонённых и возвращённых. Только текущая партиция списка. Снимок пишется в `PreviousPaymentsJson`. -- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDF` (OCR кладёт текст в `file_content`), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную. +- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную. - **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`. -- Справочники 1С (банковские счета, договоры, группы и статьи ДДС) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым). +- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки). +- Справочники 1С (`api-tnsu-portal`, `DictionaryController`) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым): + - **Банковский счёт** — `GET /Dictionary/BankAccounts?search=&companyId=&projectId=` (постранично, поиск на сервере по названию, коду, номеру счёта, банку). Сначала по компании и проекту; если по проекту пусто — повтор только по компании с пояснением под полем (совпадение Id проекта из `/Project` с `bank_accounts.project_id` не гарантировано). + - **Договор** — `GET /Dictionary/Contracts?search=` (только действующие; фильтра по компании нет, поиск по названию, коду и контрагенту). + - **Группа статьи ДДС** — `GET /Dictionary/CashFlowItemGroups` (целиком); **статья ДДС** — `GET /Dictionary/CashFlowItems?id=<группа>`. + - Фильтры-Id передаются, только если это GUID: иначе API отвечает 400. ## Что не проверено на живом API -- Форма ответов и параметры фильтра `Dictionary/BankAccounts` (`companyId`, `projectId`), `Dictionary/Contracts` (`companyId`), `Dictionary/CashFlowItemGroups`, `Dictionary/CashFlowItems` (`groupId` — адрес и параметр статьи ДДС в ТЗ не указаны, взяты по аналогии). Поля записи читаются по списку вероятных имён (`dictionaryList.service.ts`). -- Тело запроса `Document/extractPDF` — взято по аналогии с `extractPDFText` из СТ (`siteUrl`, `libraryName`, `itemId`). -- «Потребности» взяты из `/Requests/{дни}`, а не из `/Dictionary/Positions`: в портале это справочник должностей. +- Совпадают ли Id компании из `/Dictionary/Companies` и проекта из `/Project` с `company_id` / `project_id` в `bank_accounts` (на это — откат на фильтр по компании). - Доступ пользователей портала на чтение списков сайта СТ. ## Секции формы diff --git a/src/webparts/requestPortal/forms/PaymentRequest/sections/PaymentSection.tsx b/src/webparts/requestPortal/forms/PaymentRequest/sections/PaymentSection.tsx index b537256..1081b09 100644 --- a/src/webparts/requestPortal/forms/PaymentRequest/sections/PaymentSection.tsx +++ b/src/webparts/requestPortal/forms/PaymentRequest/sections/PaymentSection.tsx @@ -1,13 +1,20 @@ import * as React from 'react'; import styles from './PaymentSections.module.scss'; import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui'; -import { DictionaryPicker } from '../../../shared/dictionary'; +import { + DictionaryPicker, + getCashFlowGroups, + getCashFlowItems, + searchBankAccounts, + searchContracts +} from '../../../shared/dictionary'; import { recipientMatchesInvoice } from '../amounts'; import { ISectionProps } from '../model'; /** * Реквизиты платежа. Банковский счёт, договор и статьи ДДС — из справочников 1С - * (счета и договоры фильтруются по выбранной компании и проекту). Получатель — победитель + * (счета фильтруются по компании и проекту, договоры ищутся по названию, коду и + * контрагенту — фильтра по компании у API договоров нет). Получатель — победитель * СТ; если в самом счёте указан другой контрагент, поле подсвечивается. */ export const PaymentSection: React.FC = ({ data, set, patch, context }) => { @@ -15,16 +22,16 @@ export const PaymentSection: React.FC = ({ data, set, patch, cont { name: data.RecipientName, bin: data.RecipientBin }, { name: data.InvoiceSupplierName, bin: data.InvoiceSupplierBin } ); - const accountParams = { companyId: data.CompanyId, projectId: data.ProjectId }; + const accountFilter = { companyId: data.CompanyId, projectId: data.ProjectId }; return ( searchBankAccounts(context, accountFilter, search)} + loadKey={`${data.CompanyId}|${data.ProjectId}`} + serverSearch value={data.BankAccountName} placeholder="Счёт компании в 1С…" onChange={(label, id) => patch({ BankAccountName: label, BankAccountId: id })} @@ -33,11 +40,11 @@ export const PaymentSection: React.FC = ({ data, set, patch, cont searchContracts(context, search)} + loadKey="contracts" + serverSearch value={data.ContractName} - placeholder="Договор из 1С…" + placeholder="Название, код договора или контрагент…" onChange={(label, id) => patch({ ContractName: label, ContractId: id })} /> @@ -67,8 +74,8 @@ export const PaymentSection: React.FC = ({ data, set, patch, cont getCashFlowGroups(context)} + loadKey="cash-flow-groups" value={data.CashFlowGroupName} onChange={(label, id) => patch({ CashFlowGroupName: label, CashFlowGroupId: id, CashFlowItemName: '', CashFlowItemId: '' })} /> @@ -76,9 +83,8 @@ export const PaymentSection: React.FC = ({ data, set, patch, cont getCashFlowItems(context, data.CashFlowGroupId)} + loadKey={data.CashFlowGroupId} value={data.CashFlowItemName} onChange={(label, id) => patch({ CashFlowItemName: label, CashFlowItemId: id })} /> diff --git a/src/webparts/requestPortal/forms/PaymentRequest/services/invoice.service.ts b/src/webparts/requestPortal/forms/PaymentRequest/services/invoice.service.ts index 6c0220b..857d08f 100644 --- a/src/webparts/requestPortal/forms/PaymentRequest/services/invoice.service.ts +++ b/src/webparts/requestPortal/forms/PaymentRequest/services/invoice.service.ts @@ -12,9 +12,6 @@ import { fileStamp, storedFileName } from '../../../shared/chat/chatFileNames'; /** Колонка библиотеки, куда OCR кладёт распознанный текст счёта. */ const FILE_CONTENT_FIELD = 'file_content'; -/** Распознавание идёт на стороне сервиса — ждём появления текста, но не бесконечно. */ -const OCR_POLL_ATTEMPTS = 8; -const OCR_POLL_INTERVAL_MS = 2500; export const MAX_INVOICE_SIZE_BYTES = 15 * 1024 * 1024; @@ -53,10 +50,6 @@ export async function storeInvoice(file: File): Promise { return { itemId: item.Id, storedName, serverRelativeUrl: added.ServerRelativeUrl }; } -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - async function readFileContent(itemId: number): Promise { const item = (await getSP() .web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE) @@ -66,14 +59,14 @@ async function readFileContent(itemId: number): Promise { } /** - * Отправить сохранённый счёт на OCR и вернуть распознанный текст из колонки - * file_content. Сервис отвечает до того, как текст записан в колонку, поэтому - * колонку опрашиваем несколько раз. + * Отправить сохранённый счёт на OCR (POST /Document/extractPDFText) и вернуть + * распознанный текст из колонки file_content. Сервис отвечает только после того, + * как записал текст в колонку, поэтому читаем её сразу, без опроса. */ export async function recognizeInvoice(context: WebPartContext, itemId: number): Promise { const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); const response: HttpClientResponse = await client.post( - `${DICTIONARY_API_BASE_URL}/Document/extractPDF`, + `${DICTIONARY_API_BASE_URL}/Document/extractPDFText`, AadHttpClient.configurations.v1, { headers: { 'Content-Type': 'application/json' }, @@ -85,13 +78,12 @@ export async function recognizeInvoice(context: WebPartContext, itemId: number): } ); if (!response.ok) { - throw new Error(`Не удалось распознать счёт (Document/extractPDF): ${response.status} ${response.statusText}`); + throw new Error(`Не удалось распознать счёт (Document/extractPDFText): ${response.status} ${response.statusText}`); } - for (let attempt = 0; attempt < OCR_POLL_ATTEMPTS; attempt++) { - const text = await readFileContent(itemId); - if (text) return text; - await wait(OCR_POLL_INTERVAL_MS); + const text = await readFileContent(itemId); + if (!text) { + throw new Error('Сервис распознавания не нашёл в счёте текста.'); } - throw new Error('Текст счёта не появился в колонке file_content — повторите распознавание позже.'); + return text; } diff --git a/src/webparts/requestPortal/forms/PaymentRequest/services/needBasis.service.ts b/src/webparts/requestPortal/forms/PaymentRequest/services/needBasis.service.ts index 990ec48..7b84811 100644 --- a/src/webparts/requestPortal/forms/PaymentRequest/services/needBasis.service.ts +++ b/src/webparts/requestPortal/forms/PaymentRequest/services/needBasis.service.ts @@ -76,7 +76,9 @@ export async function loadNeedBasis(context: WebPartContext, needId: string): Pr } const rows = (await apiGet(context, `/Requests/${encodeURIComponent(needId)}/positions`)) as RawRecord[]; const positions: IBasisPosition[] = rows.map((row) => ({ - code: String(row.uniqueCode ?? ''), + // Отдельного кода у позиции потребности нет; номер строки табличной части 1С + // (lineNumber) уникален в пределах заявки — он и служит кодом позиции. + code: row.lineNumber !== undefined && row.lineNumber !== null ? String(row.lineNumber) : '', name: String(row.materialName ?? ''), unit: String(row.unit ?? ''), qty: Number(row.quantity ?? 0), diff --git a/src/webparts/requestPortal/shared/ai/gemini.service.ts b/src/webparts/requestPortal/shared/ai/gemini.service.ts index 3efe5e0..9afca3b 100644 --- a/src/webparts/requestPortal/shared/ai/gemini.service.ts +++ b/src/webparts/requestPortal/shared/ai/gemini.service.ts @@ -28,5 +28,10 @@ export async function askGemini(context: WebPartContext, prompt: string, resp if (!response.ok) { throw new Error(`Proxy/Gemini: ${response.status} ${response.statusText}`); } - return (await response.json()) as T; + const body = (await response.json()) as T & { error?: unknown }; + // Пустой ответ модели прокси отдаёт как 200 с { error } — это сбой, а не данные. + if (body && typeof body.error === 'string') { + throw new Error(`Proxy/Gemini: ${body.error}`); + } + return body; } diff --git a/src/webparts/requestPortal/shared/dictionary/dictionaryList.service.ts b/src/webparts/requestPortal/shared/dictionary/dictionaryList.service.ts index cbbf65d..dd2e3a2 100644 --- a/src/webparts/requestPortal/shared/dictionary/dictionaryList.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/dictionaryList.service.ts @@ -1,60 +1,36 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config'; +import { RawRecord, extractRecords, isGuid, mapBankAccount, mapContract, mapNamed } from './oneCRecords'; /** Запись справочника 1С в том виде, в каком её показывает выпадающий список. */ export interface IDictionaryItem { id: string; /** Основная подпись (название, номер счёта). */ label: string; - /** Пояснение под подписью (банк, компания и т.п.); '' — нет. */ + /** Пояснение под подписью (банк, контрагент и т.п.); '' — нет. */ note: string; } -type RawRecord = Record; - -// Справочники 1С (BankAccounts, Contracts, CashFlowItemGroups, …) отдают записи -// без единой схемы, а точные имена полей на живом API не сверены, поэтому -// поле читаем по списку вероятных имён, а не по одному жёсткому ключу. -const ID_KEYS = ['id', 'Id', 'oid', 'Oid', 'code', 'Code']; -const LABEL_KEYS = ['name', 'Name', 'title', 'Title', 'number', 'Number', 'accountNumber', 'AccountNumber', 'description', 'Description']; -const NOTE_KEYS = ['bankName', 'BankName', 'bank', 'Bank', 'iban', 'Iban', 'IBAN', 'company', 'Company', 'counterparty', 'Counterparty']; - -function pickString(record: RawRecord, keys: string[]): string { - for (const key of keys) { - const value = record[key]; - if (typeof value === 'string' && value.trim() !== '') return value.trim(); - if (typeof value === 'number') return String(value); - } - return ''; +/** Результат загрузки для DictionaryPicker. */ +export interface IDictionaryLoadResult { + items: IDictionaryItem[]; + /** Пояснение под полем (напр. «счета проекта не найдены, показаны все счета компании»). */ + hint?: string; } -/** Ответ — либо плоский массив, либо { items: [...] } (как у /Project). */ -function extractRecords(body: unknown): RawRecord[] { - if (Array.isArray(body)) return body as RawRecord[]; - const items = (body as { items?: unknown } | null)?.items; - return Array.isArray(items) ? (items as RawRecord[]) : []; -} +/** Сколько записей просим у постраничных справочников (Contracts, BankAccounts): сервер режет до 200. */ +const SEARCH_PAGE_SIZE = 50; -export function mapDictionaryRecord(record: RawRecord): IDictionaryItem { - const label = pickString(record, LABEL_KEYS); - const id = pickString(record, ID_KEYS) || label; - const noteRaw = pickString(record, NOTE_KEYS); - return { id, label, note: noteRaw === label ? '' : noteRaw }; -} - -// Справочник с теми же параметрами в рамках сессии не меняется — один промис на -// (путь + параметры), ошибку не кешируем (следующий вызов попробует снова). +// Один промис на адрес запроса в рамках сессии; ошибку не кешируем (следующий вызов попробует снова). const cache = new Map>(); -/** - * GET {api}/Dictionary/? → список записей. Пустые параметры в - * запрос не попадают: фильтр по компании/проекту включается, только когда они выбраны. - */ -export function getDictionaryList( +/** GET {api}/Dictionary/?. Пустые параметры в запрос не попадают. */ +function fetchDictionary( context: WebPartContext, path: string, - params: Record = {} + params: Record, + map: (record: RawRecord) => IDictionaryItem ): Promise { const query = Object.keys(params) .filter((key) => params[key] !== '') @@ -72,7 +48,7 @@ export function getDictionaryList( throw new Error(`Dictionary/${path}: ${response.status} ${response.statusText}`); } return extractRecords(await response.json()) - .map(mapDictionaryRecord) + .map(map) .filter((item) => item.label !== ''); })().catch((e) => { cache.delete(url); @@ -81,3 +57,58 @@ export function getDictionaryList( cache.set(url, request); return request; } + +function guidOrEmpty(value: string): string { + return isGuid(value) ? value.trim() : ''; +} + +/** + * Банковские счета компании и проекта (поиск — на сервере: по названию, коду, + * номеру счёта, банку). Совпадают ли Id проекта из /Project с project_id в + * bank_accounts, не гарантировано, поэтому при пустом ответе по проекту + * повторяем запрос только по компании и говорим об этом под полем. + */ +export async function searchBankAccounts( + context: WebPartContext, + filter: { companyId: string; projectId: string }, + search: string +): Promise { + const base = { search: search.trim(), companyId: guidOrEmpty(filter.companyId), pageSize: String(SEARCH_PAGE_SIZE) }; + const projectId = guidOrEmpty(filter.projectId); + if (!projectId) { + return { items: await fetchDictionary(context, 'BankAccounts', base, mapBankAccount) }; + } + const byProject = await fetchDictionary(context, 'BankAccounts', { ...base, projectId }, mapBankAccount); + if (byProject.length > 0) return { items: byProject }; + const byCompany = await fetchDictionary(context, 'BankAccounts', base, mapBankAccount); + return { + items: byCompany, + hint: byCompany.length > 0 ? 'Счетов проекта не найдено — показаны счета компании.' : undefined + }; +} + +/** + * Действующие договоры 1С. Фильтра по компании у API нет — ищем на сервере по + * названию, коду договора и контрагенту. + */ +export async function searchContracts(context: WebPartContext, search: string): Promise { + const items = await fetchDictionary( + context, + 'Contracts', + { search: search.trim(), pageSize: String(SEARCH_PAGE_SIZE) }, + mapContract + ); + return { items }; +} + +/** Группы статей ДДС — справочник маленький, отдаётся целиком. */ +export async function getCashFlowGroups(context: WebPartContext): Promise { + return { items: await fetchDictionary(context, 'CashFlowItemGroups', {}, mapNamed) }; +} + +/** Статьи ДДС группы (GET /Dictionary/CashFlowItems?id=<группа>). Без группы список пуст. */ +export async function getCashFlowItems(context: WebPartContext, groupId: string): Promise { + const id = guidOrEmpty(groupId); + if (!id) return { items: [], hint: 'Сначала выберите группу статьи ДДС.' }; + return { items: await fetchDictionary(context, 'CashFlowItems', { id }, mapNamed) }; +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index 9d3ee90..674e4df 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -33,8 +33,13 @@ export { } from './employeeLookup.service'; export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines'; export type { IOrganizationLine } from './organizationLines'; -export { getDictionaryList } from './dictionaryList.service'; -export type { IDictionaryItem } from './dictionaryList.service'; +export { + searchBankAccounts, + searchContracts, + getCashFlowGroups, + getCashFlowItems +} from './dictionaryList.service'; +export type { IDictionaryItem, IDictionaryLoadResult } from './dictionaryList.service'; export { DictionaryPicker } from './ui/DictionaryPicker'; export type { IDictionaryPickerProps } from './ui/DictionaryPicker'; export { ItemPicker } from './ui/ItemPicker'; diff --git a/src/webparts/requestPortal/shared/dictionary/oneCRecords.test.ts b/src/webparts/requestPortal/shared/dictionary/oneCRecords.test.ts new file mode 100644 index 0000000..5b2ccf8 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/oneCRecords.test.ts @@ -0,0 +1,29 @@ +import { isGuid, mapBankAccount, mapContract, mapNamed } from './oneCRecords'; + +describe('isGuid', () => { + it('принимает GUID с дефисами и без', () => { + expect(isGuid('3f2504e0-4f89-11d3-9a0c-0305e82c3301')).toBe(true); + expect(isGuid('3F2504E04F8911D39A0C0305E82C3301')).toBe(true); + }); + it('отвергает пустое и чужие форматы', () => { + expect(isGuid('')).toBe(false); + expect(isGuid('12345')).toBe(false); + }); +}); + +describe('мапперы справочников 1С', () => { + it('банковский счёт: название, под ним номер счёта, банк и валюта', () => { + const item = mapBankAccount({ id: 'a', name: 'Основной', account_number: 'KZ123', bank: 'Halyk', valuta: 'KZT' }); + expect(item).toEqual({ id: 'a', label: 'Основной', note: 'KZ123 · Halyk · KZT' }); + }); + it('банковский счёт без названия подписывается номером счёта', () => { + expect(mapBankAccount({ id: 'a', account_number: 'KZ123', bank: 'Halyk' })).toEqual({ id: 'a', label: 'KZ123', note: 'Halyk' }); + }); + it('договор: под названием контрагент, код и срок', () => { + const item = mapContract({ id: 'c', code: 'Д-1', name: 'Поставка', counterparty_name: 'ТОО Альфа', deadline: '2027-01-31T00:00:00' }); + expect(item).toEqual({ id: 'c', label: 'Поставка', note: 'ТОО Альфа · Д-1 · до 2027-01-31' }); + }); + it('статья ДДС: только название', () => { + expect(mapNamed({ id: 'g', name: 'Материалы' })).toEqual({ id: 'g', label: 'Материалы', note: '' }); + }); +}); diff --git a/src/webparts/requestPortal/shared/dictionary/oneCRecords.ts b/src/webparts/requestPortal/shared/dictionary/oneCRecords.ts new file mode 100644 index 0000000..480f1e7 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/oneCRecords.ts @@ -0,0 +1,60 @@ +import type { IDictionaryItem } from './dictionaryList.service'; + +/** + * Разбор ответов справочников 1С из api-tnsu-portal (DictionaryController). Отдельно + * от сервиса, чтобы проверять тестами без SPFx-зависимостей (как companiesCache). + */ + +export type RawRecord = Record; + +const GUID_PATTERN = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; + +/** + * Фильтры справочников api.tnsu.kz — Guid: значение не в формате GUID сервер + * отвергает с 400, и весь список пропадает. Такой фильтр лучше не передавать. + */ +export function isGuid(value: string): boolean { + return GUID_PATTERN.test(value.trim()); +} + +function str(value: unknown): string { + return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''; +} + +function joinNote(parts: string[]): string { + return parts.filter(Boolean).join(' · '); +} + +/** Ответ — либо плоский массив, либо страница { total, page, pageSize, items }. */ +export function extractRecords(body: unknown): RawRecord[] { + if (Array.isArray(body)) return body as RawRecord[]; + const items = (body as { items?: unknown } | null)?.items; + return Array.isArray(items) ? (items as RawRecord[]) : []; +} + +// GET /Dictionary/BankAccounts → { id, code, name, account_number, valuta, bank, project, company, … } +export function mapBankAccount(record: RawRecord): IDictionaryItem { + const name = str(record.name); + const account = str(record.account_number); + return { + id: str(record.id), + label: name || account, + note: joinNote([name ? account : '', str(record.bank), str(record.valuta)]) + }; +} + +// GET /Dictionary/Contracts → { id, code, name, deadline, counterparty_id, counterparty_name } +export function mapContract(record: RawRecord): IDictionaryItem { + const deadline = str(record.deadline).slice(0, 10); + return { + id: str(record.id), + label: str(record.name) || str(record.code), + note: joinNote([str(record.counterparty_name), str(record.code), deadline ? `до ${deadline}` : '']) + }; +} + +// GET /Dictionary/CashFlowItemGroups, /Dictionary/CashFlowItems?id= → [{ id, name }] +export function mapNamed(record: RawRecord): IDictionaryItem { + return { id: str(record.id), label: str(record.name), note: '' }; +} + diff --git a/src/webparts/requestPortal/shared/dictionary/ui/DictionaryPicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/DictionaryPicker.tsx index 6fadfaa..f102c02 100644 --- a/src/webparts/requestPortal/shared/dictionary/ui/DictionaryPicker.tsx +++ b/src/webparts/requestPortal/shared/dictionary/ui/DictionaryPicker.tsx @@ -1,15 +1,18 @@ import * as React from 'react'; -import type { WebPartContext } from '@microsoft/sp-webpart-base'; import { useFormReadOnly } from '@tnsu/ui-kit'; -import { getDictionaryList, IDictionaryItem } from '../dictionaryList.service'; +import { IDictionaryItem, IDictionaryLoadResult } from '../dictionaryList.service'; import { ItemPicker } from './ItemPicker'; export interface IDictionaryPickerProps { - context: WebPartContext; - /** Путь справочника после /Dictionary/, напр. «BankAccounts». */ - path: string; - /** Параметры фильтра (companyId, projectId…); пустые значения в запрос не идут. */ - params?: Record; + /** + * Загрузка вариантов. search — текст поля, если справочник ищет на сервере + * (serverSearch), иначе всегда ''. + */ + load: (search: string) => Promise; + /** Меняется — список перезагружается (напр. выбрали другую компанию или группу). */ + loadKey: string; + /** Поиск на сервере (большие постраничные справочники: договоры, счета). */ + serverSearch?: boolean; id?: string; value: string; /** label — текст поля; id — id записи справочника ('' — текст введён вручную). */ @@ -18,40 +21,63 @@ export interface IDictionaryPickerProps { readOnly?: boolean; } +/** Пауза после ввода перед запросом к серверу — чтобы не слать запрос на каждую букву. */ +const SEARCH_DEBOUNCE_MS = 300; + /** - * Выбор записи справочника 1С (GET /Dictionary/). Если справочник не - * загрузился — поле остаётся обычным текстовым (заявку не блокируем из-за - * недоступности справочника), а под ним показывается причина. + * Выбор записи справочника 1С. Если справочник не загрузился — поле остаётся + * обычным текстовым (заявку не блокируем из-за недоступности справочника), а под + * ним показывается причина. */ -export const DictionaryPicker: React.FC = ({ context, path, params, readOnly, ...rest }) => { +export const DictionaryPicker: React.FC = ({ load, loadKey, serverSearch, value, readOnly, ...rest }) => { const contextReadOnly = useFormReadOnly(); const effectiveReadOnly = readOnly ?? contextReadOnly; const [items, setItems] = React.useState([]); - const [loadError, setLoadError] = React.useState(''); + const [hint, setHint] = React.useState(''); - // Параметры — объект, новый на каждом рендере родителя; в зависимость идёт стабильная строка. - const paramsKey = JSON.stringify(params || {}); + // load — новая функция на каждом рендере родителя; актуальную держим в ref, + // а перезагрузку запускают только loadKey и текст поиска. + const loadRef = React.useRef(load); + loadRef.current = load; + const search = serverSearch ? value : ''; React.useEffect(() => { // В режиме чтения список не нужен — справочник не дёргаем. if (effectiveReadOnly) return undefined; let alive = true; - setLoadError(''); - getDictionaryList(context, path, JSON.parse(paramsKey) as Record) - .then((list) => { - if (alive) setItems(list); - }) - .catch((e: Error) => { - if (alive) { - setItems([]); - setLoadError(`Справочник «${path}» недоступен (${e.message}) — введите значение вручную.`); - } - }); + const timer = setTimeout( + () => { + loadRef + .current(search) + .then((result) => { + if (!alive) return; + setItems(result.items); + setHint(result.hint || ''); + }) + .catch((e: Error) => { + if (!alive) return; + setItems([]); + setHint(`Справочник недоступен (${e.message}) — введите значение вручную.`); + }); + }, + serverSearch ? SEARCH_DEBOUNCE_MS : 0 + ); return () => { alive = false; + clearTimeout(timer); }; - }, [context, path, paramsKey, effectiveReadOnly]); + }, [loadKey, search, serverSearch, effectiveReadOnly]); - return ; + return ( + + ); }; diff --git a/src/webparts/requestPortal/shared/dictionary/ui/ItemPicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/ItemPicker.tsx index e64f710..44b21ef 100644 --- a/src/webparts/requestPortal/shared/dictionary/ui/ItemPicker.tsx +++ b/src/webparts/requestPortal/shared/dictionary/ui/ItemPicker.tsx @@ -15,6 +15,8 @@ export interface IItemPickerProps { readOnly?: boolean; /** Пояснение под полем (напр. почему список пуст). */ hint?: string; + /** false — варианты уже отфильтрованы сервером по тексту поля, показываем их как есть. */ + filterLocally?: boolean; } const MAX_SUGGESTIONS = 40; @@ -24,17 +26,17 @@ const MAX_SUGGESTIONS = 40; * Показывает варианты и при пустом тексте (в отличие от ProjectPicker): справочники * банковских счетов и статей короткие, их удобнее пролистать, чем угадывать запрос. */ -export const ItemPicker: React.FC = ({ items, id, value, onChange, placeholder, readOnly, hint }) => { +export const ItemPicker: React.FC = ({ items, id, value, onChange, placeholder, readOnly, hint, filterLocally = true }) => { const contextReadOnly = useFormReadOnly(); const effectiveReadOnly = readOnly ?? contextReadOnly; const [open, setOpen] = React.useState(false); const suggestions = React.useMemo(() => { - const query = value.trim().toLowerCase(); + const query = filterLocally ? value.trim().toLowerCase() : ''; return items .filter((item) => !query || item.label.toLowerCase().indexOf(query) !== -1 || item.note.toLowerCase().indexOf(query) !== -1) .slice(0, MAX_SUGGESTIONS); - }, [items, value]); + }, [items, value, filterLocally]); return (