Compare commits

...

6 Commits

Author SHA1 Message Date
Тимур Городецкий e40fc402d9 fix(ct): поле км в строке логистики не схлопывается при 3+ поставщиках
Минимальные ширины половинок «рейсы | км», инпут занимает всю половинку, у км убраны стрелки
type=number — они обрезали число.
2026-10-20 14:28:46 +05:00
Тимур Городецкий c1e8792174 feat(ct): поставщик-победитель записывается в шапку СТ на шаге 3
По «Сохранить и продолжить» выбранный поставщик пишется в лукап winSupplier: на шаге 4 меняется
только статус, а поставщик мог быть как обычной колонкой, так и добавленным на шаге 3.
2026-10-20 14:28:45 +05:00
Тимур Городецкий 5192311fff fix: заголовок меню портала «Снабжение» вместо заглушки «Договора»
Название вынесено в SIDEBAR_BRAND — реестр и база контрагентов берут его из одного места.
2026-10-20 14:26:21 +05:00
Тимур Городецкий 4079bddc71 fix(approval): повторная отправка на согласование не падает на удалении старого маршрута
recycle() слал POST без Content-Type с пустым odata-version — SPFx (jsonRequest) сам бросал
«requires the OData-Version header to be 3.0 or 4.0» до запроса. writeRoute общий: повторная
отправка после доработки/перезапуска ломалась во всех трёх формах; в КПС тот же recycle.
2026-10-02 12:29:23 +05:00
Тимур Городецкий 045a4f655b refactor(ct): режим формы — по автору и статусу СТ, а не по регистру ctid/ctId
Раньше ?ctid= (ссылки потока, реестр не-черновиков) включал режим согласующего любому, кто открыл,
и режим залипал в сессии: автор СТ «На доработке» по ссылке из письма не мог править.
Теперь getCtAccess: автор правит в draft…formed и «На доработке»; согласующий и автор на
согласовании/после — просмотр с ценами на шаге 3; посторонний — просмотр без цен.
ctid/ctId равнозначны, approvalSent/isNotificationLink убраны.
2026-10-02 12:07:59 +05:00
Тимур Городецкий 2f00a2dcc9 fix(ct): «Сохранить и продолжить» на шаге 3 пишет «Не получены КП» и доп. информацию в SP
Вызовы syncNoOfferSuppliers/replaceCriteria из п.3 и п.4 претензий не попали в коммиты
f5853b1 и 2b102b8 (вошли только импорты): без них согласующий и PDF не видели поставщиков
без КП и доп. поля, если шаг 2 прошли крошками.
2026-10-02 12:07:44 +05:00
16 changed files with 243 additions and 57 deletions
@@ -20,7 +20,7 @@ import {
Alert
} from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
import { buildSidebarItems, CONTRAGENTS_ID } from './sidebarNav';
import { buildSidebarItems, CONTRAGENTS_ID, SIDEBAR_BRAND } from './sidebarNav';
import { useDocumentDashboard } from './registry/useDocumentDashboard';
import { DashboardRow } from './registry/documentRegistry';
@@ -133,7 +133,7 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
items={sidebarItems}
activeId={activeType}
onChange={onSidebarChange}
brand="Договора"
brand={SIDEBAR_BRAND}
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
@@ -52,8 +52,8 @@ export function createCtDocumentConfig(): DocumentTypeConfig {
})
},
createNewLabel: 'Создать новую форму СТ',
// Форма CT встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
// — как читает сама форма (см. forms/ct/.../hooks/useCtForm.ts).
// Форма CT встроена в портал (маршрут). ctId / ctid форма читает одинаково — режим (правка/просмотр)
// она считает сама по автору и статусу (forms/ct/.../utils/ctAccess.ts); регистр — для старых ссылок.
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
mapItem: (raw): DashboardRow => {
@@ -1,6 +1,9 @@
import { ISidebarItem, IconName } from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
// Заголовок сайдбара — одинаковый на всех экранах портала.
export const SIDEBAR_BRAND = 'Снабжение';
// Общий раздел «База контрагентов» (отдельный маршрут ?form=contragents).
export const CONTRAGENTS_ID = 'contragents';
export const CONTRAGENTS_TITLE = 'База контрагентов';
@@ -14,6 +14,7 @@ import {
import {
buildSidebarItems,
CONTRAGENTS_ID,
SIDEBAR_BRAND,
} from "../../../components/sidebarNav";
import { ContragentsService } from "../services/ContragentsService";
import { SuppliersService } from "../services/SuppliersService";
@@ -164,7 +165,7 @@ export const ContragentsRegistry: React.FC<IContragentsRegistryProps> = ({
items={buildSidebarItems()}
activeId={CONTRAGENTS_ID}
onChange={onSidebarChange}
brand="Договора"
brand={SIDEBAR_BRAND}
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
@@ -2,7 +2,8 @@ import * as React from 'react';
import { useMemo, useEffect, useState } from 'react';
import type { ICtCreateFormProps } from './ICtCreateFormProps';
import { CtService } from '../services/CtService';
import { useCtForm } from './hooks/useCtForm';
import { CTFormData, useCtForm } from './hooks/useCtForm';
import { IMatrixRow } from '../../../shared/approval';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
@@ -11,6 +12,7 @@ import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers';
import { isUserInApprovalChain } from './utils/approvalAccess';
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css';
@@ -23,7 +25,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
@@ -34,15 +35,18 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
ensureHighestStep,
} = useCtForm();
// Цены и КП показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
// правило по цепочке согласования останется. Участника ищем по почте (ФИО — запасной вариант).
const userInApprovalChain = isUserInApprovalChain(
formData.approvalMatrix ?? [],
{ email: service.currentUserEmail, displayName: userDisplayName },
);
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
const accessFor = (ct: CTFormData['ct'], approvalMatrix: IMatrixRow[]): CtAccess => getCtAccess({
hasCt: !!ct,
status: ct?.status,
isAuthor: isCtAuthor(ct?.authorEmail, service.currentUserEmail),
isInChain: isUserInApprovalChain(approvalMatrix, { email: service.currentUserEmail, displayName: userDisplayName }),
isRestrictedCompany: isRestrictedView,
});
const access = accessFor(formData.ct, formData.approvalMatrix ?? []);
const hidePrices = !access.canSeePrices;
useEffect(() => {
service.getCurrentUserProfile()
@@ -58,8 +62,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load existing CT from SP when opened via notification link (?ctid=N)
// Always reloads from SP to get fresh approval statuses
// Открыта существующая СТ (?ctId=N или ?ctid=N — равнозначно): статус и маршрут — всегда свежие из SP.
// Остальные данные — из SP в режиме просмотра (у проверяющего нет своих правок) или когда сессии нет.
useEffect(() => {
if (!initialCtId) return;
Promise.all([
@@ -67,6 +71,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
service.getApprovalMatrixForRequest(initialCtId),
service.loadPositionsForCT(initialCtId),
]).then(async ([ct, approvalMatrix, positions]) => {
const loadedAccess = accessFor(ct, approvalMatrix);
const preferSp = loadedAccess.isReview;
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
@@ -144,7 +150,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
let uploadedKpFiles = formData.uploadedKpFiles;
if (isNotificationLink || uploadedKpFiles.length === 0) {
if (preferSp || uploadedKpFiles.length === 0) {
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
if (kpFilesRaw.length > 0) {
uploadedKpFiles = kpFilesRaw.map(f => ({
@@ -160,22 +166,25 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
approvalMatrix,
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or when opened via notification link (always fresh).
// Only use SP-derived data when session has none, or in review mode (always fresh).
suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
...(preferSp || formData.offers.length === 0 ? { offers } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(isNotificationLink ? { approvalSent: true } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
});
if (isNotificationLink) {
// Teams notification link — always show Step 3 (comparison table + approval chain)
if (loadedAccess.isReview) {
// Просмотр (согласующий, автор СТ на согласовании, посторонний) — сразу шаг 3: таблица + маршрут.
goToStep(3);
ensureHighestStep(4);
markStepComplete(3);
markStepComplete(4);
} else if (ct.status === 'rework') {
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
ensureHighestStep(4);
goToStep(4);
} else if (approvalMatrix.length > 0) {
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
ensureHighestStep(4);
@@ -210,7 +219,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
<Step2CommercialOffers
service={service}
formData={formData}
isNotificationLink={isNotificationLink}
canEdit={access.canEdit}
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
isRestrictedView={hidePrices}
onSave={updateFormData}
@@ -224,7 +233,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
<Step3ComparisonTable
service={service}
formData={formData}
isNotificationLink={isNotificationLink}
canEdit={access.canEdit}
isReview={access.isReview}
isRestrictedView={hidePrices}
onSave={updateFormData}
onNext={goNext}
@@ -18,7 +18,8 @@ import styles from './Step2CommercialOffers.module.scss';
interface Props {
service: CtService;
formData: CTFormData;
isNotificationLink?: boolean;
// Правка СТ (getCtAccess): без неё — только просмотр, загрузка КП и сохранение недоступны.
canEdit?: boolean;
isReadOnly?: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
@@ -32,7 +33,7 @@ interface Props {
export const Step2CommercialOffers: React.FC<Props> = ({
service,
formData,
isNotificationLink,
canEdit = true,
onSave,
onNext,
onPrev,
@@ -176,7 +177,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
onFileRemove={handleQueueFileRemove}
currencyOptions={currencyOptions}
onCurrencyChange={kp.handleCurrencyChange}
disabled={isNotificationLink || isReadOnly}
disabled={!canEdit || isReadOnly}
/>
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
{kp.isProcessing && (
@@ -200,7 +201,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
onCurrencyChange={handleUploadedCurrencyChange}
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
disabled={isNotificationLink || isReadOnly}
disabled={!canEdit || isReadOnly}
/>
)}
@@ -210,7 +211,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{!isNotificationLink && !isReadOnly && (
{canEdit && !isReadOnly && (
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button>
@@ -9,8 +9,8 @@ interface Props {
service: CtService;
formData: CTFormData;
onSave: (partial: Partial<CTFormData>) => void;
isNotificationLink: boolean;
isReadOnly: boolean;
// Режим просмотра (getCtAccess): согласующий, автор СТ на согласовании, посторонний.
isReview: boolean;
}
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
@@ -20,9 +20,9 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
'На доработке': 'rework',
};
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
// Инлайн-цепочка согласования Шага 3 в режиме просмотра: read-only маршрут + DecisionPanel своей строки
// (панель сама проверяет, чья сейчас очередь). Правка состава/отправка — это Шаг 4, здесь их нет.
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isReview }) => {
const { ct } = formData;
const ctId = ct?.id;
const [decisionSeq, setDecisionSeq] = useState<number>(0);
@@ -31,12 +31,12 @@ export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
[service, ctId]
);
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isNotificationLink && isReadOnly, (loginName) =>
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isReview, (loginName) =>
service.getJobTitle(loginName)
);
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
if (!isNotificationLink || !isReadOnly || !ctId || editor.loading || editor.rows.length === 0) {
if (!isReview || !ctId || editor.loading || editor.rows.length === 0) {
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
}
@@ -341,7 +341,14 @@
vertical-align: middle;
}
.deliveryQtyCell {
// Минимальные ширины половинок ячейки «рейсы | км» строки логистики: рейсы — 3 цифры,
// км — 4 цифры плюс подпись «км». Без нижней границы при 3+ поставщиках таблица ужимала
// колонки, и поле км схлопывалось до невидимого — теперь вместо этого таблица шире (есть скролл).
$delivery-trips-min-width: 40px;
$delivery-km-min-width: 76px;
// Двойной класс — иначе min-width: 90px из .dataCell (объявлен ниже, та же специфичность) перебивает.
.dataCell.deliveryQtyCell {
min-width: 150px;
}
@@ -355,17 +362,38 @@
.deliveryQtyBox,
.deliveryKmBox {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
// Обёртка инпута должна занимать всё место половинки: без flex-grow она ужималась
// по содержимому, и инпут получал лишь остаток рядом с подписью «км».
.offerInput {
flex: 1 1 auto;
min-width: 0;
}
}
.deliveryQtyBox {
min-width: $delivery-trips-min-width;
}
.deliveryKmBox {
min-width: $delivery-km-min-width;
border-left: 1px solid $border;
padding-left: 2px;
// Стрелки type="number" в Chrome резервируют место даже невидимыми и обрезают число
// («200» → «20(»). Км вводят с клавиатуры, стрелки не нужны.
input {
color: $muted;
appearance: textfield;
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
@@ -22,7 +22,9 @@ import { VendorPickerDialog } from './VendorPickerDialog';
interface Props {
service: CtService;
formData: CTFormData;
isNotificationLink: boolean;
// Режим формы (getCtAccess): canEdit — правка таблицы; isReview — просмотр с маршрутом и кнопками решения.
canEdit: boolean;
isReview: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
@@ -33,7 +35,8 @@ interface Props {
export const Step3ComparisonTable: React.FC<Props> = ({
service,
formData,
isNotificationLink,
canEdit,
isReview,
isRestrictedView = false,
onSave,
onNext,
@@ -45,7 +48,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
// Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/handleSave) вынесено в хук; результат передаём
// как единый проп `matrix` в под-компоненты рендера (ComparisonMatrix/CriteriaTable/AddExtraRow),
// здесь дополнительно достаём то, что нужно контейнеру напрямую (тулбары, AI-payload, футер).
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
@@ -194,8 +197,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
service={service}
formData={formData}
onSave={onSave}
isNotificationLink={isNotificationLink}
isReadOnly={isReadOnly}
isReview={isReview}
/>
<div className={styles.footer}>
@@ -15,6 +15,8 @@ let extraIdCounter = 0;
export interface UseStep3MatrixArgs {
service: CtService;
formData: CTFormData;
// Таблица только на просмотр — режим формы (getCtAccess), а не признак из сессии.
isReadOnly: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onStepComplete: (step: FormStep) => void;
@@ -24,11 +26,9 @@ export interface UseStep3MatrixArgs {
// ВЕРБАТИМ из Step3ComparisonTable (§стайл-гайд: логика вне вёрстки). Клубок рефов сохраняет
// несохранённые правки при переходах шагов и F5 (unmount/beforeunload-persist).
export function useStep3Matrix(args: UseStep3MatrixArgs) {
const { service, formData, onSave, onNext, onStepComplete } = args;
const { service, formData, isReadOnly, onSave, onNext, onStepComplete } = args;
const { selectedPositions, suppliers, offers, currencyRates, ct } = formData;
const isReadOnly = formData.approvalSent;
const [extraSuppliers, setExtraSuppliers] = useState<ExtraSupplier[]>(() => {
const persisted = formData.step3ExtraSuppliers ?? [];
persisted.forEach(es => {
@@ -800,6 +800,27 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
});
await service.saveOffers(offersToSave, currencyRates);
// «Не получены КП» — к этой СТ, до согласования: шаг 2 могли пройти крошками, минуя его «Сохранить».
await service.syncNoOfferSuppliers(ct.id, getNoOfferSuppliers(suppliers));
// «Дополнительная информация» — в SP: иначе согласующий и PDF видят только то, что распознал ИИ.
await service.replaceCriteria(ct.id, buildCriteriaRecords(
suppliersWithOffer,
criteriaMap,
customCriteria,
column => column.spId ?? extraSupSpIds[column.id],
));
// Поставщик-победитель → winSupplier в шапке СТ (лукап на «Поставщики»). Пишем сразу по
// «Сохранить и продолжить», а не на Шаге 4 — там меняется только статус, а поставщик мог
// прийти как обычной колонкой (spId уже есть), так и добавленным на этом шаге (spId
// появляется только что, в extraSupSpIds).
if (selectedSupplierId) {
const winSupplierSpId = suppliers.find(s => s.id === selectedSupplierId)?.spId
?? extraSupSpIds[selectedSupplierId];
if (winSupplierSpId !== undefined) {
await service.saveWinSupplier(ct.id, winSupplierSpId);
}
}
// Persist offers with React IDs (sup.id / pos.id) so they survive F5 / step navigation —
// render keys use React IDs, so SP IDs in session cause empty cells on remount.
@@ -20,7 +20,6 @@ export interface CTFormData {
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
approvalSent: boolean;
step3PositionQtyOverrides?: Record<string, number>;
step3CriteriaEdits?: Record<string, { paymentType: string; deliveryType: string; deadline: string; sertificates: string }>;
step3CustomCriteria?: Array<{ id: string; label: string; values: Record<string, string> }>;
@@ -40,7 +39,6 @@ const INITIAL_FORM_DATA: CTFormData = {
step3ExtraSuppliers: [],
step3ExtraPositions: [],
uploadedKpFiles: [],
approvalSent: false,
step3PositionQtyOverrides: {},
step3CriteriaEdits: {},
step3CustomCriteria: [],
@@ -57,7 +55,8 @@ interface PersistedFormState {
function getUrlCtId(): string | undefined {
const params = new URLSearchParams(window.location.search);
// Accept 'ctId' (our format) and 'ctid' (Power Automate notification link)
// ctId (форма, реестр) и ctid (ссылки потока, реестр) равнозначны — это только id СТ. Режим формы
// (правка / просмотр) определяет getCtAccess по автору и статусу, а не регистр параметра.
return params.get('ctId') ?? params.get('ctid') ?? undefined;
}
@@ -86,7 +85,6 @@ export interface UseCtFormResult {
currentStep: FormStep;
formData: CTFormData;
initialCtId: string | undefined;
isNotificationLink: boolean;
goToStep: (step: FormStep) => void;
goNext: () => void;
goPrev: () => void;
@@ -99,7 +97,6 @@ export interface UseCtFormResult {
export function useCtForm(): UseCtFormResult {
const [initialCtId] = useState<string | undefined>(getUrlCtId);
const isNotificationLink = new URLSearchParams(window.location.search).has('ctid');
const [currentStep, setCurrentStep] = useState<FormStep>(() => {
const saved = initialCtId ? readSession(initialCtId) : null;
@@ -167,7 +164,6 @@ export function useCtForm(): UseCtFormResult {
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
@@ -0,0 +1,52 @@
import { getCtAccess, isCtAuthor } from './ctAccess';
const base = { hasCt: true, isAuthor: false, isInChain: false, isRestrictedCompany: false };
describe('getCtAccess — кто открыл СТ и в каком она статусе (а не регистр ctid/ctId в ссылке)', () => {
it('новая СТ — правка', () => {
expect(getCtAccess({ ...base, hasCt: false })).toEqual({ canEdit: true, canSeePrices: true, isReview: false });
});
it('автор: черновик — правка', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'formed' }).canEdit).toBe(true);
});
// СТ №… «На доработке» по ссылке из письма (ctid) открывалась автору только на просмотр — править было нельзя.
it('автор: «На доработке» — правка, даже если открыл по ссылке-уведомлению', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'rework' })).toEqual({ canEdit: true, canSeePrices: true, isReview: false });
});
it('автор: на согласовании / согласовано — просмотр с ценами', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'on_approval' })).toEqual({ canEdit: false, canSeePrices: true, isReview: true });
expect(getCtAccess({ ...base, isAuthor: true, status: 'approved' }).canEdit).toBe(false);
});
// «Отклонить» оставлено с перезапуском: править — после «Перезапустить согласование» на шаге 4.
it('автор: отклонено — просмотр до перезапуска', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'rejected' }).canEdit).toBe(false);
});
it('согласующий из цепочки — просмотр с ценами', () => {
expect(getCtAccess({ ...base, isInChain: true, status: 'on_approval' })).toEqual({ canEdit: false, canSeePrices: true, isReview: true });
});
it('посторонний — просмотр без цен, даже черновик', () => {
expect(getCtAccess({ ...base, status: 'formed' })).toEqual({ canEdit: false, canSeePrices: false, isReview: true });
});
it('компания с ограничением (astel-k) — без цен даже автору', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'formed', isRestrictedCompany: true }).canSeePrices).toBe(false);
});
});
describe('isCtAuthor', () => {
it('сверяет почту без учёта регистра', () => {
expect(isCtAuthor('Ivanov@tnsu.kz', 'ivanov@tnsu.kz')).toBe(true);
expect(isCtAuthor('ivanov@tnsu.kz', 'petrov@tnsu.kz')).toBe(false);
});
// У старых данных автор может быть не загружен — не блокируем никого на своих черновиках, как раньше.
it('автор неизвестен — считаем автором', () => {
expect(isCtAuthor(undefined, 'ivanov@tnsu.kz')).toBe(true);
});
});
@@ -0,0 +1,41 @@
import { CTStatus } from '../types/domain';
// Режим формы СТ определяется тем, КТО открыл и В КАКОМ статусе СТ, — а не регистром параметра в ссылке.
// Раньше `?ctid=` (ссылки потока, реестр для не-черновиков) включал «режим согласующего» любому, кто открыл,
// а `?ctId=` — правку; режим ещё и «прилипал» в сессии. Автор СТ «На доработке» по ссылке из письма
// получал только просмотр. Теперь ctid/ctId равнозначны — они лишь говорят, какую СТ открыть.
export interface CtAccessInput {
hasCt: boolean;
status?: CTStatus;
isAuthor: boolean;
isInChain: boolean; // участник цепочки согласования (см. isUserInApprovalChain)
isRestrictedCompany: boolean; // ограниченная компания (astel-k) — цены не видит никогда
}
export interface CtAccess {
canEdit: boolean; // правка шагов; статусные блокировки шагов 1–2 остаются поверх
canSeePrices: boolean; // цены и ссылки на КП
isReview: boolean; // просмотр: на шаге 3, данные свежие из SP, маршрут и кнопки решения
}
// Статусы, в которых автор правит СТ. «Отклонено» — нет: правка после «Перезапустить согласование».
const AUTHOR_EDIT_STATUSES: ReadonlySet<CTStatus> = new Set<CTStatus>([
'draft', 'kp_loading', 'kp_loaded', 'formed', 'rework',
]);
export function getCtAccess(input: CtAccessInput): CtAccess {
const canEdit = !input.hasCt || (input.isAuthor && AUTHOR_EDIT_STATUSES.has(input.status ?? 'formed'));
return {
canEdit,
canSeePrices: !input.isRestrictedCompany && (canEdit || input.isAuthor || input.isInChain),
isReview: input.hasCt && !canEdit,
};
}
// Автор СТ — по почте без учёта регистра. Автор неизвестен (старые данные, сессия до появления
// authorEmail) — считаем автором: раньше любой мог править СТ, не блокируем никого на своих черновиках.
export function isCtAuthor(authorEmail: string | undefined, userEmail: string): boolean {
if (!authorEmail) return true;
return authorEmail.trim().toLowerCase() === userEmail.trim().toLowerCase();
}
@@ -1434,6 +1434,25 @@ export class CtService {
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`);
}
// Запись поставщика-победителя в шапку СТ (лукап winSupplier → «Поставщики»), Шаг 3.
async saveWinSupplier(ctId: string, supplierSpId: number): Promise<void> {
const res = await this.http.fetch(
this._liById(LISTS.CT, ctId),
SPHttpClient.configurations.v1,
{
method: "POST",
headers: {
"Content-Type": "application/json;odata=nometadata",
"IF-MATCH": "*",
"X-HTTP-Method": "MERGE",
},
body: JSON.stringify({ winSupplierId: supplierSpId }),
},
);
if (!res.ok)
throw new Error(`Ошибка сохранения поставщика-победителя: ${res.status}`);
}
// Сформировать отчёт СТ и вернуть его как blob (без скачивания на диск).
// Нужен для подписания через NCALayer и сборки карточки SIGEX.
async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise<Blob> {
@@ -256,8 +256,14 @@ export class KpsCtFormService {
// Мягкое удаление (то же, что кнопка «Удалить» в UI). Работает на «защищённых» списках,
// где DELETE/MERGE падают с E_FAIL (§6). Для «обновления» такой записи: recycle() + повторный POST.
public async recycleListItem(listName: string, id: number): Promise<void> {
// Content-Type обязателен даже без тела: у POST с пустым odata-version SPFx иначе сам бросает
// «jsonRequest is enabled, which requires the OData-Version header…».
const options: ISPHttpClientOptions = {
headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' }
headers: {
Accept: 'application/json;odata=nometadata',
'Content-Type': 'application/json;odata=nometadata',
'odata-version': ''
}
};
const res = await this.spHttpClient.post(
`${this._liById(listName, id)}/recycle()`,
@@ -108,8 +108,14 @@ export class ApprovalListService {
}
private async recycleListItem(id: number): Promise<void> {
// Content-Type обязателен даже без тела: у POST с пустым odata-version SPFx иначе сам бросает
// «jsonRequest is enabled, which requires the OData-Version header…» — повторная отправка падала.
const options: ISPHttpClientOptions = {
headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' }
headers: {
Accept: 'application/json;odata=nometadata',
'Content-Type': 'application/json;odata=nometadata',
'odata-version': ''
}
};
const res = await this.spHttpClient.post(
`${this._liById(this.listTitle, id)}/recycle()`,