diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md index 4a19dad..f7576d1 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/README.md +++ b/src/webparts/requestPortal/forms/ServiceRequest/README.md @@ -12,9 +12,10 @@ ## Особое поведение - Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`). - Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке. -- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `EstimateTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно. -- Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его. `WorkVolumesTotal` дублируется числовой колонкой аналогично `EstimateTotal`. -- Табличные части сериализуются в JSON-колонки (`AnnexesJson`, `WorkVolumesJson`, `EstimateJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`). +- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. +- Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его. +- `Costs`/`WorkVolumes` сериализуются в JSON-колонки (`EstimateJson`/`WorkVolumesJson`) ОБЪЕКТОМ `{ groups, total }` — готовый итог кладётся прямо в JSON рядом с группами (см. `serializeGroups`/`parseGroupedJson` в `cost.ts`), а не отдельной числовой колонкой: он и так пересчитывается из groups при каждом сохранении, отдельная колонка была бы лишней схемой ради того же значения. Сама форма читает только `groups` — total из хранилища не доверяет, всегда считает заново (`costTotal`); `total` в JSON нужен тем, кто читает колонку напрямую (отчёты, sp-query), не поднимая форму. +- `Annexes` сериализуется в `AnnexesJson` просто массивом (без обёртки total — там считать нечего) — тот же приём, что в `TmcTransfer` (`ItemsJson`). - Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется. - Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/cost.ts b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts index 87c3d78..6bf813b 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/cost.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts @@ -31,6 +31,43 @@ export function hasAnyCostItem(groups: ICostGroup[]): boolean { return groups.some((group) => group.items.some((row) => row.description.trim().length > 0)); } +/** + * Форма JSON-колонки для групп (EstimateJson / WorkVolumesJson): группы + + * готовый итог. Итог кладём РЯДОМ с группами в тот же JSON, а не отдельной + * числовой колонкой SharePoint — он и так пересчитывается из groups при + * каждом сохранении (см. serializeGroups), отдельная колонка была бы просто + * ещё одним местом, которое нужно провижинить и держать в курсе схемы. + */ +export interface IGroupedJson { + groups: ICostGroup[]; + total: number; +} + +/** Собрать JSON-колонку: группы как есть + пересчитанный итог. */ +export function serializeGroups(groups: ICostGroup[]): IGroupedJson { + return { groups, total: costTotal(groups) }; +} + +/** + * Разобрать JSON-колонку групп. Итог из хранилища НЕ используется формой — + * она всегда считает его заново из groups (см. costTotal); в разобранном + * объекте total пригождается только тем, кто читает эту колонку напрямую + * (отчёты, sp-query), не поднимая форму. + */ +export function parseGroupedJson(raw: unknown): ICostGroup[] { + if (typeof raw !== 'string' || raw.trim() === '') return []; + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as ICostGroup[]; // старый формат (просто массив), до появления total + if (parsed && typeof parsed === 'object' && Array.isArray((parsed as IGroupedJson).groups)) { + return (parsed as IGroupedJson).groups; + } + return []; + } catch { + return []; + } +} + /** * Сумма для показа: «1 234 567,89». Без символа валюты — валюта подписана в * заголовке колонки (тенге), как в бумажной форме. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts index 64eb37d..7d172ce 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/load.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/load.ts @@ -1,6 +1,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow, ICostGroup } from './model'; +import { parseGroupedJson } from './cost'; +import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow } from './model'; export interface ILoadedRequest { data: IFormData; @@ -90,7 +91,7 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi ContractKind: (item.ContractKind as string) || '', ContractAction: (item.ContractAction as string) || '', ScopeOfWork: (item.ScopeOfWork as string) || '', - WorkVolumes: parseRows(item.WorkVolumesJson), + WorkVolumes: parseGroupedJson(item.WorkVolumesJson), SuggestedSuppliers: (item.SuggestedSuppliers as string) || '', EstimatedStartDate: toDateInput(item.EstimatedStartDate), EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), @@ -98,7 +99,7 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi // fileName — через spread с дефолтом: заявки, сохранённые до появления // прикреплённых файлов у строки приложения, не имеют его в JSON вовсе. Annexes: parseRows(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })), - Costs: parseRows(item.EstimateJson) + Costs: parseGroupedJson(item.EstimateJson) }; return { diff --git a/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json index 2e44937..6d77ce3 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json +++ b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json @@ -23,15 +23,13 @@ { "internalName": "ContractAction", "title": "Действие с договором", "type": "Choice", "choices": ["Новый договор", "Доп. соглашение", "Возобновление договора", "Пролонгация договора", "Расторжение договора"] }, { "internalName": "ScopeOfWork", "title": "Объём работ", "type": "Note" }, - { "internalName": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON)", "type": "Note" }, - { "internalName": "WorkVolumesTotal", "title": "Итого по объёмам работ", "type": "Number", "decimals": 2 }, + { "internalName": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON, с итогом)", "type": "Note" }, { "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" }, { "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true }, { "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true }, { "internalName": "GeneralRemarks", "title": "Общие замечания", "type": "Note" }, { "internalName": "AnnexesJson", "title": "Приложения (JSON)", "type": "Note" }, - { "internalName": "EstimateJson", "title": "Сметная стоимость (JSON)", "type": "Note" }, - { "internalName": "EstimateTotal", "title": "Итого без НДС", "type": "Number", "decimals": 2 } + { "internalName": "EstimateJson", "title": "Сметная стоимость (JSON, с итогом)", "type": "Note" } ] } diff --git a/src/webparts/requestPortal/forms/ServiceRequest/submit.ts b/src/webparts/requestPortal/forms/ServiceRequest/submit.ts index a89f08f..2d3263b 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/submit.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/submit.ts @@ -1,6 +1,6 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; -import { costTotal } from './cost'; +import { serializeGroups } from './cost'; import { FORM_ID, IFormData } from './model'; /** @@ -21,7 +21,8 @@ function buildTitle(data: IFormData): string { /** * Поля элемента списка данных из модели формы. Табличные части уходят в - * JSON-колонки, итог сметы — в отдельную числовую колонку. + * JSON-колонки; у групп (Costs/WorkVolumes) в тот же JSON кладётся и готовый + * итог — см. serializeGroups в cost.ts. */ function buildItem(data: IFormData, status: string): Record { const item: Record = { Title: buildTitle(data), RequestStatus: status }; @@ -33,16 +34,8 @@ function buildItem(data: IFormData, status: string): Record { }); item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null; - item.WorkVolumesJson = data.WorkVolumes.length > 0 ? JSON.stringify(data.WorkVolumes) : null; - item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(data.Costs) : null; - // Итоги сметы и объёмов работ дублируем числовыми колонками намеренно: в - // самой форме они считаются на лету (см. cost.ts), но представлениям - // SharePoint и отчётам нужна сумма без разбора JSON — иначе любой отчёт по - // заявкам пришлось бы писать кодом. - const workVolumesTotal = costTotal(data.WorkVolumes); - item.WorkVolumesTotal = workVolumesTotal > 0 ? workVolumesTotal : null; - const estimateTotal = costTotal(data.Costs); - item.EstimateTotal = estimateTotal > 0 ? estimateTotal : null; + item.WorkVolumesJson = data.WorkVolumes.length > 0 ? JSON.stringify(serializeGroups(data.WorkVolumes)) : null; + item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(serializeGroups(data.Costs)) : null; return item; }