diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index 53c4a40..d4feb44 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -10,6 +10,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl import { Step4Approval } from './Step4Approval/Step4Approval'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import { buildOffersFromSpRecords } from './utils/spOffers'; +import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers'; import styles from './CtCreateForm.module.scss'; import '@tnsu/ui-kit/styles.css'; @@ -65,6 +66,13 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, ]).then(async ([ct, approvalMatrix, positions]) => { const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined); const rawOffers = await service.loadFullOffersForPositions(positionSpIds); + // Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке). + const noOfferFromSp = noOfferRecordsToSuppliers( + await service.loadNoOfferSuppliers(initialCtId).catch(err => { + console.warn('[CtCreateForm] не удалось загрузить поставщиков без КП:', err); + return []; + }), + ); // Build supplierSpId → offerId map from session uploadedKpFiles. // Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии — @@ -152,7 +160,7 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, ...(isNotificationLink || 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). - ...(formData.suppliers.length === 0 ? { suppliers: spSuppliers } : {}), + suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp), ...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}), ...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}), ...(isNotificationLink ? { approvalSent: true } : {}), diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx index 794a601..03f8746 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx @@ -45,7 +45,7 @@ export const Step2CommercialOffers: React.FC = ({ const [noOfferSuppliers, setNoOfferSuppliers] = useState(() => (formData.suppliers ?? []) .filter(s => !s.hasOffer) - .map(s => ({ localId: s.id, name: s.name, comment: '' })), + .map(s => ({ localId: s.id, name: s.name, comment: s.comment ?? '' })), ); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(undefined); @@ -70,6 +70,7 @@ export const Step2CommercialOffers: React.FC = ({ id: s.localId, name: s.name, hasOffer: false, + comment: s.comment, })); onSave({ suppliers: [...withOffer, ...withoutOffer] }); }; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts index 82d5a85..fbbdb7b 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts @@ -3,6 +3,7 @@ import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { KpFile, NoOfferEntry } from './kpTypes'; import { buildSupplierColumnId } from '../utils/supplierColumns'; +import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; export interface PersistStep2Args { service: CtService; @@ -76,16 +77,13 @@ export async function persistStep2(args: PersistStep2Args): Promise ({ id: s.localId, name: s.name, hasOffer: false, + comment: s.comment, })); // Persist offers with currency data to SP so shared links show correct currency columns. @@ -128,9 +126,15 @@ export async function persistStep2(args: PersistStep2Args): Promise = ({ entries }) => { + if (entries.length === 0) return null; + return ( +
+

Поставщики, не предоставившие КП

+
    + {entries.map(entry => ( +
  • + {entry.name} + {entry.comment && {entry.comment}} +
  • + ))} +
+
+ ); +}; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss index 162b95e..9d3e756 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss @@ -592,6 +592,32 @@ overflow-x: auto; } +// «Поставщики, не предоставившие КП» под таблицей (NoOfferSuppliersList) — в рамке как «Доп. информация». +.noOfferSupplierList { + list-style: none; + margin: 0; + padding: 8px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.noOfferSupplierItem { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + font-size: 13px; +} + +.noOfferSupplierName { + font-weight: 600; + color: $text; +} + +.noOfferSupplierComment { + color: $muted; +} + .criteriaTable { border-collapse: collapse; width: 100%; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx index 3509ec1..d51fd38 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx @@ -12,6 +12,8 @@ import { AiCommentBlock } from './AiCommentBlock'; import { useStep3Matrix } from './useStep3Matrix'; import { ComparisonMatrix } from './ComparisonMatrix'; import { CriteriaTable } from './CriteriaTable'; +import { NoOfferSuppliersList } from './NoOfferSuppliersList'; +import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; import { useAdataCheck } from './useAdataCheck'; import { useRecommendedPick } from './useRecommendedPick'; import { VendorPickerDialog } from './VendorPickerDialog'; @@ -171,6 +173,8 @@ export const Step3ComparisonTable: React.FC = ({ + + { + const existing = [ + { id: 1, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' }, + { id: 2, name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' }, + ]; + + it('новый поставщик — создать', () => { + const plan = planNoOfferSync(existing, [ + ...existing.map(e => ({ name: e.name, comment: e.comment })), + { name: 'ТОО «Новый»', comment: '' }, + ]); + + expect(plan.toCreate).toEqual([{ name: 'ТОО «Новый»', comment: '' }]); + expect(plan.toRemove).toEqual([]); + }); + + // Удалили на шаге 2 — должен уйти и из SP, иначе согласующий по ссылке увидит его снова. + it('удалённый на шаге 2 — убрать', () => { + const plan = planNoOfferSync(existing, [{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' }]); + + expect(plan.toRemove).toEqual([2]); + expect(plan.toCreate).toEqual([]); + }); + + it('изменённый комментарий — пересоздать запись', () => { + const plan = planNoOfferSync(existing, [ + { name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' }, + { name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' }, + ]); + + expect(plan.toRemove).toEqual([1]); + expect(plan.toCreate).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' }]); + }); + + it('пробелы по краям названия и комментария не считаются изменением', () => { + const plan = planNoOfferSync(existing, [ + { name: ' ТОО БЕЗ КП ТЕСТ ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!! ' }, + { name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' }, + ]); + + expect(plan).toEqual({ toCreate: [], toRemove: [] }); + }); +}); + +describe('getNoOfferSuppliers', () => { + it('поставщики без КП для списка под таблицей сравнения', () => { + const suppliers: Supplier[] = [ + { id: '263_277', spId: 263, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true }, + { id: 'noOffer_1', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' }, + ]; + + expect(getNoOfferSuppliers(suppliers)).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]); + }); +}); + +describe('поставщики без КП при открытии по ссылке', () => { + const fromSp = noOfferRecordsToSuppliers([{ id: 28, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]); + + it('записи SP становятся поставщиками без КП', () => { + expect(fromSp).toEqual([{ id: 'noOffer_28', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' }]); + }); + + it('добавляются к колонкам с КП', () => { + const withOffer: Supplier[] = [{ id: '263_277', spId: 263, name: 'Арлан', hasOffer: true }]; + + expect(withNoOfferSuppliers(withOffer, fromSp).map(s => s.id)).toEqual(['263_277', 'noOffer_28']); + }); + + it('свои поставщики без КП в сессии не заменяются', () => { + const session: Supplier[] = [{ id: 'local_1', name: 'Из сессии', hasOffer: false }]; + + expect(withNoOfferSuppliers(session, fromSp)).toEqual(session); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/noOfferSuppliers.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/noOfferSuppliers.ts new file mode 100644 index 0000000..2808c83 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/noOfferSuppliers.ts @@ -0,0 +1,47 @@ +import { Supplier } from '../types/domain'; + +// Поставщик, не предоставивший КП: название и комментарий (почему нет КП). +export interface NoOfferSupplierEntry { + name: string; + comment: string; +} + +// Запись списка «Не получены КП» этой СТ в SP. +export interface NoOfferSupplierRecord extends NoOfferSupplierEntry { + id: number; +} + +const sameEntry = (a: NoOfferSupplierEntry, b: NoOfferSupplierEntry): boolean => + a.name.trim() === b.name.trim() && a.comment.trim() === b.comment.trim(); + +// Синхронизация «Не получены КП» одной СТ с тем, что сейчас на шаге 2: новых — создать, удалённых и +// изменённых (другой комментарий) — убрать, изменённых — создать заново. Раньше записи только +// добавлялись и без привязки к СТ, поэтому по ссылке список было не восстановить. +export function planNoOfferSync( + existing: NoOfferSupplierRecord[], + desired: NoOfferSupplierEntry[], +): { toCreate: NoOfferSupplierEntry[]; toRemove: number[] } { + return { + toCreate: desired.filter(d => !existing.some(e => sameEntry(e, d))), + toRemove: existing.filter(e => !desired.some(d => sameEntry(e, d))).map(e => e.id), + }; +} + +// Поставщики без КП из колонок формы — для списка под таблицей сравнения (Шаг 3). +export function getNoOfferSuppliers(suppliers: Supplier[]): NoOfferSupplierEntry[] { + return suppliers + .filter(s => s.hasOffer === false) + .map(s => ({ name: s.name, comment: s.comment ?? '' })); +} + +// Поставщики без КП из SP (открытие по ссылке / без сессии) — в колонки формы с hasOffer: false. +// Префикс id — чтобы не пересечься с id колонок поставщиков с КП. +export function noOfferRecordsToSuppliers(records: NoOfferSupplierRecord[]): Supplier[] { + return records.map(r => ({ id: `noOffer_${r.id}`, name: r.name, hasOffer: false, comment: r.comment })); +} + +// Колонки формы с поставщиками без КП из SP: сессию не трогаем, если в ней уже есть свои без КП. +export function withNoOfferSuppliers(current: Supplier[], fromSp: Supplier[]): Supplier[] { + if (fromSp.length === 0 || current.some(s => s.hasOffer === false)) return current; + return [...current, ...fromSp]; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index fd11a3d..96a651e 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -30,6 +30,11 @@ import { keepLatestOfferRecords, parseOfferItemIndex, } from "../../../shared/offers/offerRecords"; +import { + NoOfferSupplierEntry, + NoOfferSupplierRecord, + planNoOfferSync, +} from "../components/utils/noOfferSuppliers"; // Round monetary values to 2 decimals before persisting to SharePoint (drops float tails). function round2(value: number): number { @@ -1288,23 +1293,38 @@ export class CtService { return { spId, offerId }; } - async saveNoOfferSupplierItem(name: string, comment?: string): Promise { - const escaped = name.replace(/'/g, "''"); - const checkRes = await this.http.get( - `${this._li(LISTS.NO_OFFERS)}?$filter=Title eq '${escaped}'&$select=Id&$top=1`, + // «Не получены КП» этой СТ (привязка по лукапу CT). Раньше записи сохранялись без СТ и с дедупом + // по названию на все СТ сразу — по ссылке список поставщиков без КП было не восстановить. + async loadNoOfferSuppliers(ctId: string): Promise { + const res = await this.http.get( + `${this._li(LISTS.NO_OFFERS)}?$select=Id,Title,comment&$filter=CTId eq ${parseInt(ctId, 10)}&$top=500`, SPHttpClient.configurations.v1, ); - if (checkRes.ok && ((await checkRes.json()).value ?? []).length > 0) return; - const res = await this.http.post( - this._li(LISTS.NO_OFFERS), - SPHttpClient.configurations.v1, - { + if (!res.ok) throw new Error(`Ошибка чтения поставщиков без КП: ${res.status}`); + const data = await res.json(); + return ((data.value ?? []) as Array>).map((item) => ({ + id: Number(item.Id), + name: String(item.Title ?? ""), + comment: String(item.comment ?? ""), + })); + } + + // Приводит «Не получены КП» этой СТ к списку с шага 2: новых — создать, удалённых и изменённых — убрать. + async syncNoOfferSuppliers(ctId: string, entries: NoOfferSupplierEntry[]): Promise { + const existing = await this.loadNoOfferSuppliers(ctId); + const { toCreate, toRemove } = planNoOfferSync(existing, entries); + await this._runChunked(toRemove, 20, async (id) => { + await this.http + .post(`${this._liById(LISTS.NO_OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {}) + .catch(() => undefined /* уже в корзине / не найден — игнорируем */); + }); + for (const entry of toCreate) { + const res = await this.http.post(this._li(LISTS.NO_OFFERS), SPHttpClient.configurations.v1, { headers: { "Content-Type": "application/json;odata=nometadata" }, - body: JSON.stringify({ Title: name, comment: comment ?? "" }), - }, - ); - if (!res.ok) - throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`); + body: JSON.stringify({ Title: entry.name.trim(), comment: entry.comment.trim(), CTId: parseInt(ctId, 10) }), + }); + if (!res.ok) throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`); + } } async getCurrentUserProfile(): Promise<{