refactor(ServiceRequest): итог групп — прямо в JSON, без отдельной колонки

Итог сметы и объёмов работ теперь кладём прямо в JSON-колонку рядом с
группами ({ groups, total } — см. serializeGroups/parseGroupedJson в
cost.ts), а не отдельной числовой колонкой SharePoint. Форма total из
хранилища не доверяет, всегда считает заново; в JSON он нужен только тем,
кто читает колонку напрямую (отчёты, sp-query), не поднимая форму.

Убраны EstimateTotal/WorkVolumesTotal из провижининг-манифеста — они ещё
не были провижинены на тенанте, миграция не нужна.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-09-01 15:50:20 +05:00
parent 6e2ad78e6e
commit 0b9bb80b1f
5 changed files with 52 additions and 22 deletions
@@ -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` пишет маршрут с пустым списком исполнителей.
@@ -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». Без символа валюты — валюта подписана в
* заголовке колонки (тенге), как в бумажной форме.
@@ -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<ICostGroup>(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<IAnnexRow>(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })),
Costs: parseRows<ICostGroup>(item.EstimateJson)
Costs: parseGroupedJson(item.EstimateJson)
};
return {
@@ -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" }
]
}
@@ -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<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
@@ -33,16 +34,8 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
});
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;
}