Заявка на услуги #18
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user