From 88ce1d498e4fbb9947d3937c1a9f82d6e0372fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 30 Oct 2026 09:34:35 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=91?= =?UTF-8?q?=D0=A1=D0=A3=20=D0=B8=20=D1=80=D0=B5=D1=86=D0=B5=D0=BF=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B1=D0=B5=D1=82=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заявка заказывалась свободным текстом, без привязки к реальным рецептам завода. Теперь БСУ выбирается из API bsu.tnsu.kz (BsuId/BsuName в шапке), а класс смеси в позиции — из рецептов этого БСУ (formulaId + снимок состава, т.к. на заводе рецепт могут поменять). Состав показывается плашками. Если API не ответил, отправку не блокируем — ручной ввод. Новые колонки BsuId/BsuName нужно провизионировать до деплоя. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ConcreteRequest/ConcreteRequestForm.tsx | 24 +++++-- .../forms/ConcreteRequest/README.md | 11 +++- .../forms/ConcreteRequest/bsu/IBsu.ts | 15 +++++ .../forms/ConcreteRequest/bsu/bsu.service.ts | 40 ++++++++++++ .../ConcreteRequest/bsu/useBsuCatalog.ts | 62 ++++++++++++++++++ .../forms/ConcreteRequest/load.ts | 4 ++ .../forms/ConcreteRequest/model.ts | 29 ++++++++- .../forms/ConcreteRequest/provisioning.json | 2 + .../ConcreteRequest/sections/BsuField.tsx | 51 +++++++++++++++ .../sections/HeaderSection.tsx | 19 +++++- .../sections/ItemsEditor.module.scss | 55 ++++++++++++++++ .../ConcreteRequest/sections/ItemsEditor.tsx | 18 +++--- .../ConcreteRequest/sections/ItemsSection.tsx | 32 ++++++++-- .../sections/MixClassField.tsx | 64 +++++++++++++++++++ .../sections/RecipeIngredients.tsx | 47 ++++++++++++++ src/webparts/requestPortal/shared/config.ts | 9 +++ 16 files changed, 460 insertions(+), 22 deletions(-) create mode 100644 src/webparts/requestPortal/forms/ConcreteRequest/bsu/IBsu.ts create mode 100644 src/webparts/requestPortal/forms/ConcreteRequest/bsu/bsu.service.ts create mode 100644 src/webparts/requestPortal/forms/ConcreteRequest/bsu/useBsuCatalog.ts create mode 100644 src/webparts/requestPortal/forms/ConcreteRequest/sections/BsuField.tsx create mode 100644 src/webparts/requestPortal/forms/ConcreteRequest/sections/MixClassField.tsx create mode 100644 src/webparts/requestPortal/forms/ConcreteRequest/sections/RecipeIngredients.tsx diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index e776057..57418aa 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -43,7 +43,8 @@ import { dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField, withoutFormula } from './model'; +import { useBsuCatalog } from './bsu/useBsuCatalog'; import { saveConcreteRequest, concreteRequestApprovalService } from './submit'; import { loadConcreteRequest } from './load'; import { HeaderSection } from './sections/HeaderSection'; @@ -167,6 +168,19 @@ const ConcreteRequestForm: React.FC = ({ .catch(() => undefined); }; + // Справочник БСУ нужен только при заполнении — в режиме чтения показываем сохранённые названия. + const bsu = useBsuCatalog(context, data.BsuId, !readOnly); + + // Рецепты позиций принадлежат конкретному заводу — при смене БСУ сбрасываем их. + const handleBsuChange = (bsuId: string, bsuName: string): void => { + setData((prev) => ({ + ...prev, + BsuId: bsuId, + BsuName: bsuName, + Items: bsuId === prev.BsuId ? prev.Items : prev.Items.map(withoutFormula) + })); + }; + // Маршрут согласования зависит от начальника участка И проекта (в админке // approval-route для этого типа заявки настроено условие ByProject) — // planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока @@ -205,10 +219,10 @@ const ConcreteRequestForm: React.FC = ({ }); const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0); - const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems); + const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && data.BsuName.trim() && hasItems); const validate = (): string | undefined => { if (!isValid) { - return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).'; + return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).'; } if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; @@ -303,7 +317,9 @@ const ConcreteRequestForm: React.FC = ({ set, context, onSiteChiefChange: handleSiteChiefChange, - onForemanChange: handleForemanChange + onForemanChange: handleForemanChange, + bsu: readOnly ? undefined : bsu, + onBsuChange: handleBsuChange }; // «Удалить черновик» — только у сохранённой заявки в статусе «Черновик». diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/README.md b/src/webparts/requestPortal/forms/ConcreteRequest/README.md index 672f1b4..852b245 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/README.md +++ b/src/webparts/requestPortal/forms/ConcreteRequest/README.md @@ -51,11 +51,20 @@ здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`), а не строки плоской таблицы — адаптивнее на мобильных, чем строка с горизонтальным скроллом. +- **БСУ и рецепты** (`bsu/`, API `https://bsu.tnsu.kz/api/v1`, токен того же + AAD-приложения, что у approval-route): в шапке выбирается БСУ (`GET /bsu` → + `BsuId`/`BsuName`, обязателен), в позиции — рецепт этого БСУ + (`GET /bsu/{id}/formulas`): в позицию пишутся `formulaId`, название в + `mixClass` и снимок состава `ingredients` (на заводе рецепт могут поменять). + Смена БСУ сбрасывает выбранные рецепты (`withoutFormula`). API не ответил — + отправку **не блокируем**: предупреждение и ручной ввод (название БСУ / + класс смеси текстом, как до справочника). В просмотре API не вызывается — + показываются сохранённые названия. - **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`. ## Секции формы HeaderSection (дата поставки · начальник участка · организация (read-only) · -проект · мастер участка) + ItemsEditor (табличная часть поставок) +проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок) ## Точки входа - rptype: `wh-concrete-request` diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/bsu/IBsu.ts b/src/webparts/requestPortal/forms/ConcreteRequest/bsu/IBsu.ts new file mode 100644 index 0000000..bcd9df3 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/bsu/IBsu.ts @@ -0,0 +1,15 @@ +/** БСУ (бетонно-смесительный узел) — ответ GET /bsu. */ +export interface IBsu { + id: string; + /** Бэкенд может отдать null — подпись в списке строит bsuLabel. */ + name: string | null; +} + +/** Рецепт (формула) смеси конкретного БСУ — ответ GET /bsu/{id}/formulas. */ +export interface IBsuFormula { + id: number; + /** Марка/класс («В25», «pp M100»). Бывает пустой строкой. */ + name: string; + /** Состав одной строкой: «Бункер 1: 670; Цемент 1: 400; Вода: 146; …». */ + ingredients: string; +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/bsu/bsu.service.ts b/src/webparts/requestPortal/forms/ConcreteRequest/bsu/bsu.service.ts new file mode 100644 index 0000000..116793c --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/bsu/bsu.service.ts @@ -0,0 +1,40 @@ +import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { BSU_API_BASE_URL, BSU_API_CLIENT_ID } from '../../../shared/config'; +import { IBsu, IBsuFormula } from './IBsu'; + +async function getJson(context: WebPartContext, path: string): Promise { + const client = await context.aadHttpClientFactory.getClient(BSU_API_CLIENT_ID); + const response: HttpClientResponse = await client.get(`${BSU_API_BASE_URL}${path}`, AadHttpClient.configurations.v1); + if (!response.ok) { + throw new Error(`БСУ ${path}: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +// Список БСУ в рамках сессии не меняется — грузим один раз (как projects.service). +let bsuCache: Promise | undefined; +export function getBsuList(context: WebPartContext): Promise { + if (!bsuCache) { + bsuCache = getJson(context, '/bsu').catch((e) => { + bsuCache = undefined; + throw e; + }); + } + return bsuCache; +} + +/** Рецепты выбранного БСУ. Не кэшируем: БСУ переключают редко, а рецепты на заводе могут правиться. */ +export function getBsuFormulas(context: WebPartContext, bsuId: string): Promise { + return getJson(context, `/bsu/${encodeURIComponent(bsuId)}/formulas`); +} + +/** Подпись БСУ в списке: имени может не быть — тогда хотя бы начало id, чтобы пункты различались. */ +export function bsuLabel(bsu: IBsu): string { + return bsu.name?.trim() || `БСУ ${bsu.id.slice(0, 8)}`; +} + +/** Подпись рецепта: пустое имя встречается (id 13) — показываем номер. */ +export function formulaLabel(formula: IBsuFormula): string { + return formula.name.trim() || `Рецепт №${formula.id}`; +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/bsu/useBsuCatalog.ts b/src/webparts/requestPortal/forms/ConcreteRequest/bsu/useBsuCatalog.ts new file mode 100644 index 0000000..5713b32 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/bsu/useBsuCatalog.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from 'react'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { friendlyErrorMessage } from '../../../shared/diagnostics'; +import { getBsuFormulas, getBsuList } from './bsu.service'; +import { IBsu, IBsuFormula } from './IBsu'; + +/** Список из API с явными состояниями загрузки и ошибки. */ +export interface IRemoteList { + items: T[]; + loading: boolean; + /** '' — ошибки нет. */ + error: string; +} + +/** Справочник БСУ для формы: список заводов и рецепты выбранного. */ +export interface IBsuCatalog { + bsu: IRemoteList; + formulas: IRemoteList; +} + +function emptyList(): IRemoteList { + return { items: [], loading: false, error: '' }; +} + +/** load = null — грузить нечего (выключено или не выбран БСУ). load должен быть стабильным (useCallback). */ +function useRemoteList(load: (() => Promise) | null): IRemoteList { + const [state, setState] = useState>(emptyList); + + useEffect(() => { + if (!load) { + setState(emptyList()); + return; + } + let alive = true; + setState({ items: [], loading: true, error: '' }); + load() + .then((items) => { + if (alive) setState({ items, loading: false, error: '' }); + }) + .catch((e) => { + if (alive) setState({ items: [], loading: false, error: friendlyErrorMessage(e) }); + }); + return () => { + alive = false; + }; + }, [load]); + + return state; +} + +/** + * Заводы и рецепты выбранного завода. enabled = false (заявка только для чтения) — + * ничего не грузим: в просмотре показываются сохранённые в заявке названия. + */ +export function useBsuCatalog(context: WebPartContext, bsuId: string, enabled: boolean): IBsuCatalog { + const loadBsu = useCallback(() => getBsuList(context), [context]); + const loadFormulas = useCallback(() => getBsuFormulas(context, bsuId), [context, bsuId]); + + const bsu = useRemoteList(enabled ? loadBsu : null); + const formulas = useRemoteList(enabled && bsuId ? loadFormulas : null); + return { bsu, formulas }; +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/load.ts b/src/webparts/requestPortal/forms/ConcreteRequest/load.ts index 8ed2cdb..f499893 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/load.ts +++ b/src/webparts/requestPortal/forms/ConcreteRequest/load.ts @@ -45,6 +45,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom 'Organization', 'Project', 'ProjectId', + 'BsuId', + 'BsuName', 'ItemsJson', 'RequestStatus', 'RequestUniqueCode' @@ -62,6 +64,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom Organization: (item.Organization as string) || '', Project: (item.Project as string) || '', ProjectId: (item.ProjectId as string) || '', + BsuId: (item.BsuId as string) || '', + BsuName: (item.BsuName as string) || '', Items: parseItems(item.ItemsJson) }; diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/model.ts b/src/webparts/requestPortal/forms/ConcreteRequest/model.ts index 3741efd..4b48055 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/model.ts +++ b/src/webparts/requestPortal/forms/ConcreteRequest/model.ts @@ -1,6 +1,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { ISelectOption } from '../../shared/ui'; import { RequestTypeId } from '../../models/types'; +import { IBsuCatalog } from './bsu/useBsuCatalog'; /** * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков @@ -18,8 +19,18 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request'; * согласования (ByProject, см. IFormData). */ export interface IConcreteItem { - /** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */ + /** + * Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен + * или заявка старая — свободный текст (напр. «C20/25 F75 W6»). + */ mixClass: string; + /** + * id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или + * заявка подана до справочника БСУ, поэтому поле необязательное. + */ + formulaId?: number; + /** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */ + ingredients?: string; /** Количество, м³. */ volume: string; /** Подвижность смеси — см. WORKABILITY_OPTIONS. */ @@ -38,6 +49,12 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = { note: '' }; +/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */ +export function withoutFormula(item: IConcreteItem): IConcreteItem { + if (item.formulaId === undefined) return item; + return { ...item, formulaId: undefined, mixClass: '', ingredients: '' }; +} + /** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */ export const WORKABILITY_OPTIONS: ISelectOption[] = [ { value: 'П1', label: 'П1' }, @@ -90,6 +107,10 @@ export interface IFormData { Project: string; /** oid проекта из справочника; '' — введён вручную без выбора. */ ProjectId: string; + /** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */ + BsuId: string; + /** Название БСУ — для просмотра и печати без обращения к API. */ + BsuName: string; /** Табличная часть — позиции поставки (сериализуется в ItemsJson). */ Items: IConcreteItem[]; } @@ -108,6 +129,10 @@ export interface ISectionProps { onSiteChiefChange?: (login: string) => void; /** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */ onForemanChange?: (login: string) => void; + /** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */ + bsu?: IBsuCatalog; + /** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */ + onBsuChange?: (bsuId: string, bsuName: string) => void; } export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…'; @@ -122,5 +147,7 @@ export const EMPTY_DATA: IFormData = { Organization: '', Project: '', ProjectId: '', + BsuId: '', + BsuName: '', Items: [] }; diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json b/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json index ff1212e..efe72dc 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json +++ b/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json @@ -12,6 +12,8 @@ { "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Project", "title": "Проект", "type": "Text", "required": true }, { "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }, + { "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" }, + { "internalName": "BsuName", "title": "БСУ", "type": "Text" }, { "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" } ] diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/sections/BsuField.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/sections/BsuField.tsx new file mode 100644 index 0000000..51ae573 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/sections/BsuField.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Alert, FormField, Select, TextInput } from '../../../shared/ui'; +import { IBsuCatalog } from '../bsu/useBsuCatalog'; +import { bsuLabel } from '../bsu/bsu.service'; + +interface Props { + bsuId: string; + bsuName: string; + /** Не передан (просмотр) — показываем сохранённое название. */ + catalog?: IBsuCatalog; + onChange: (bsuId: string, bsuName: string) => void; +} + +const LABEL = 'БСУ (бетонный завод)'; + +/** + * Выбор БСУ из API. Если API не ответил — отправку не блокируем: предупреждение + * и ручной ввод названия (BsuId остаётся пустым). + */ +export const BsuField: React.FC = ({ bsuId, bsuName, catalog, onChange }) => { + if (!catalog || catalog.bsu.error) { + return ( + + {catalog && ( + + Не удалось загрузить список БСУ ({catalog.bsu.error}). Введите название вручную. + + )} + onChange('', v)} placeholder="Название БСУ" /> + + ); + } + + const options = catalog.bsu.items.map((b) => ({ value: b.id, label: bsuLabel(b) })); + + return ( + + ({ value: String(f.id), label: formulaLabel(f) }))} + placeholder={pickerPlaceholder(picker)} + readOnly={!picker.bsuChosen || picker.loading} + /> + ) : ( + onChange({ mixClass: v, formulaId: undefined, ingredients: '' })} + placeholder="C20/25 F75 W6" + /> + )} + {row.ingredients && } + + ); +}; diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/sections/RecipeIngredients.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/sections/RecipeIngredients.tsx new file mode 100644 index 0000000..b1f993f --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/sections/RecipeIngredients.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import styles from './ItemsEditor.module.scss'; + +interface Props { + /** Состав строкой из API БСУ: «Бункер 1: 670; Цемент 1: 400; Вода: 146». */ + value: string; +} + +interface IIngredient { + name: string; + amount: string; +} + +/** «Бункер 1: 670; Вода: 146» → [{Бункер 1, 670}, {Вода, 146}]. Хоть одна часть не по формату — null. */ +function parseIngredients(value: string): IIngredient[] | null { + const parts = value.split(';').map((p) => p.trim()).filter((p) => p.length > 0); + const result: IIngredient[] = []; + for (const part of parts) { + const colon = part.lastIndexOf(':'); + if (colon <= 0) return null; + result.push({ name: part.slice(0, colon).trim(), amount: part.slice(colon + 1).trim() }); + } + return result.length > 0 ? result : null; +} + +/** Состав рецепта плашками «компонент — количество»; формат не распознан — исходная строка. */ +export const RecipeIngredients: React.FC = ({ value }) => { + const ingredients = parseIngredients(value); + + return ( +
+ Состав рецепта + {ingredients ? ( +
    + {ingredients.map((ing, i) => ( +
  • + {ing.name} + {ing.amount} +
  • + ))} +
+ ) : ( + {value} + )} +
+ ); +}; diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 1cb7693..87994dc 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -52,6 +52,15 @@ export function getApprovalApiBaseUrl(): string { return _approvalApiBaseUrl; } +/** + * API БСУ (бетонно-смесительные узлы и их рецепты) — справочник для «Заявки на бетон». + * Токен — того же AAD-приложения, что у approval-route и ЭДО (проверено по aud рабочего + * токена). Если у БСУ появится своё приложение — поменять только эту константу и объявить + * доступ в package-solution.json (webApiPermissionRequests). + */ +export const BSU_API_CLIENT_ID = APPROVAL_API_CLIENT_ID; +export const BSU_API_BASE_URL = 'https://bsu.tnsu.kz/api/v1'; + /** * ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и * у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения