fix: заявка на оплату под реальный API справочников и OCR
Tests / frontend-test (pull_request) Successful in 1m32s
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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user