fix: заявка на оплату под реальный API справочников и OCR
Tests / frontend-test (pull_request) Successful in 1m32s

Сверено с 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 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-27 12:35:53 +05:00
parent e976b4e8d4
commit 023c0913f1
11 changed files with 271 additions and 110 deletions
@@ -17,14 +17,17 @@
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
- **Ранее поданные заявки на оплату** — читаются из списка данных этой же формы по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`), без черновиков, отклонённых и возвращённых. Только текущая партиция списка. Снимок пишется в `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` (на это — откат на фильтр по компании).
- Доступ пользователей портала на чтение списков сайта СТ.
## Секции формы
@@ -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<ISectionProps> = ({ data, set, patch, context }) => {
@@ -15,16 +22,16 @@ export const PaymentSection: React.FC<ISectionProps> = ({ 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 (
<FormGrid>
<FormField label="Банковский счёт" htmlFor="BankAccountName" required>
<DictionaryPicker
id="BankAccountName"
context={context}
path="BankAccounts"
params={accountParams}
load={(search) => 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<ISectionProps> = ({ data, set, patch, cont
<FormField label="Договор" htmlFor="ContractName">
<DictionaryPicker
id="ContractName"
context={context}
path="Contracts"
params={{ companyId: data.CompanyId }}
load={(search) => searchContracts(context, search)}
loadKey="contracts"
serverSearch
value={data.ContractName}
placeholder="Договор из 1С…"
placeholder="Название, код договора или контрагент…"
onChange={(label, id) => patch({ ContractName: label, ContractId: id })}
/>
</FormField>
@@ -67,8 +74,8 @@ export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, cont
<FormField label="Группа статьи ДДС" htmlFor="CashFlowGroupName" required>
<DictionaryPicker
id="CashFlowGroupName"
context={context}
path="CashFlowItemGroups"
load={() => 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<ISectionProps> = ({ data, set, patch, cont
<FormField label="Статья ДДС" htmlFor="CashFlowItemName" required>
<DictionaryPicker
id="CashFlowItemName"
context={context}
path="CashFlowItems"
params={{ groupId: data.CashFlowGroupId }}
load={() => getCashFlowItems(context, data.CashFlowGroupId)}
loadKey={data.CashFlowGroupId}
value={data.CashFlowItemName}
onChange={(label, id) => patch({ CashFlowItemName: label, CashFlowItemId: id })}
/>
@@ -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<IStoredInvoice> {
return { itemId: item.Id, storedName, serverRelativeUrl: added.ServerRelativeUrl };
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function readFileContent(itemId: number): Promise<string> {
const item = (await getSP()
.web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE)
@@ -66,14 +59,14 @@ async function readFileContent(itemId: number): Promise<string> {
}
/**
* Отправить сохранённый счёт на OCR и вернуть распознанный текст из колонки
* file_content. Сервис отвечает до того, как текст записан в колонку, поэтому
* колонку опрашиваем несколько раз.
* Отправить сохранённый счёт на OCR (POST /Document/extractPDFText) и вернуть
* распознанный текст из колонки file_content. Сервис отвечает только после того,
* как записал текст в колонку, поэтому читаем её сразу, без опроса.
*/
export async function recognizeInvoice(context: WebPartContext, itemId: number): Promise<string> {
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;
}
@@ -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),
@@ -28,5 +28,10 @@ export async function askGemini<T>(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;
}
@@ -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<string, unknown>;
// Справочники 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<string, Promise<IDictionaryItem[]>>();
/**
* GET {api}/Dictionary/<path>?<params> → список записей. Пустые параметры в
* запрос не попадают: фильтр по компании/проекту включается, только когда они выбраны.
*/
export function getDictionaryList(
/** GET {api}/Dictionary/<path>?<params>. Пустые параметры в запрос не попадают. */
function fetchDictionary(
context: WebPartContext,
path: string,
params: Record<string, string> = {}
params: Record<string, string>,
map: (record: RawRecord) => IDictionaryItem
): Promise<IDictionaryItem[]> {
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<IDictionaryLoadResult> {
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<IDictionaryLoadResult> {
const items = await fetchDictionary(
context,
'Contracts',
{ search: search.trim(), pageSize: String(SEARCH_PAGE_SIZE) },
mapContract
);
return { items };
}
/** Группы статей ДДС — справочник маленький, отдаётся целиком. */
export async function getCashFlowGroups(context: WebPartContext): Promise<IDictionaryLoadResult> {
return { items: await fetchDictionary(context, 'CashFlowItemGroups', {}, mapNamed) };
}
/** Статьи ДДС группы (GET /Dictionary/CashFlowItems?id=<группа>). Без группы список пуст. */
export async function getCashFlowItems(context: WebPartContext, groupId: string): Promise<IDictionaryLoadResult> {
const id = guidOrEmpty(groupId);
if (!id) return { items: [], hint: 'Сначала выберите группу статьи ДДС.' };
return { items: await fetchDictionary(context, 'CashFlowItems', { id }, mapNamed) };
}
@@ -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';
@@ -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: '' });
});
});
@@ -0,0 +1,60 @@
import type { IDictionaryItem } from './dictionaryList.service';
/**
* Разбор ответов справочников 1С из api-tnsu-portal (DictionaryController). Отдельно
* от сервиса, чтобы проверять тестами без SPFx-зависимостей (как companiesCache).
*/
export type RawRecord = Record<string, unknown>;
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: '' };
}
@@ -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<string, string>;
/**
* Загрузка вариантов. search — текст поля, если справочник ищет на сервере
* (serverSearch), иначе всегда ''.
*/
load: (search: string) => Promise<IDictionaryLoadResult>;
/** Меняется — список перезагружается (напр. выбрали другую компанию или группу). */
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/<path>). Если справочник не
* загрузился — поле остаётся обычным текстовым (заявку не блокируем из-за
* недоступности справочника), а под ним показывается причина.
* Выбор записи справочника 1С. Если справочник не загрузился — поле остаётся
* обычным текстовым (заявку не блокируем из-за недоступности справочника), а под
* ним показывается причина.
*/
export const DictionaryPicker: React.FC<IDictionaryPickerProps> = ({ context, path, params, readOnly, ...rest }) => {
export const DictionaryPicker: React.FC<IDictionaryPickerProps> = ({ load, loadKey, serverSearch, value, readOnly, ...rest }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [items, setItems] = React.useState<IDictionaryItem[]>([]);
const [loadError, setLoadError] = React.useState<string>('');
const [hint, setHint] = React.useState<string>('');
// Параметры — объект, новый на каждом рендере родителя; в зависимость идёт стабильная строка.
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<string, string>)
.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 <ItemPicker {...rest} items={items} readOnly={readOnly} hint={loadError} />;
return (
<ItemPicker
{...rest}
value={value}
items={items}
readOnly={readOnly}
hint={hint}
// Сервер уже отфильтровал (в т.ч. по коду и контрагенту, которых нет в подписи) — повторно не фильтруем.
filterLocally={!serverSearch}
/>
);
};
@@ -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<IItemPickerProps> = ({ items, id, value, onChange, placeholder, readOnly, hint }) => {
export const ItemPicker: React.FC<IItemPickerProps> = ({ items, id, value, onChange, placeholder, readOnly, hint, filterLocally = true }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [open, setOpen] = React.useState<boolean>(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 (
<div className={styles.wrap}>