feat(ct): поставщики без КП — списком под таблицей сравнения, в т.ч. у согласующего
Поставщиков без КП убрали из колонок (64973a2), но взамен нигде не показывали. Теперь на
шаге 3 под «Дополнительной информацией» — список «Поставщики, не предоставившие КП» с
комментарием. «Не получены КП» сохраняются с привязкой к СТ (колонка CT была в списке, но не
заполнялась, а дедуп шёл по названию на все СТ) и синхронизируются по «Сохранить» на шагах
2 и 3 — удалённые убираются; по ссылке согласующий видит список из SP. Комментарий больше
не теряется при возврате на шаг 2.
This commit is contained in:
@@ -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<ICtCreateFormProps> = ({ 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<ICtCreateFormProps> = ({ 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 } : {}),
|
||||
|
||||
+2
-1
@@ -45,7 +45,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
|
||||
(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<string | undefined>(undefined);
|
||||
@@ -70,6 +70,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
id: s.localId,
|
||||
name: s.name,
|
||||
hasOffer: false,
|
||||
comment: s.comment,
|
||||
}));
|
||||
onSave({ suppliers: [...withOffer, ...withoutOffer] });
|
||||
};
|
||||
|
||||
+9
-5
@@ -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<Partial<CTFo
|
||||
savedRates = await service.saveCurrencyRates(currencyRates);
|
||||
}
|
||||
|
||||
for (const s of noOfferSuppliers) {
|
||||
await service.saveNoOfferSupplierItem(s.name, s.comment);
|
||||
}
|
||||
|
||||
const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
|
||||
|
||||
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
|
||||
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<Partial<CTFo
|
||||
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
|
||||
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
|
||||
|
||||
// «Не получены КП» — с привязкой к СТ: по ссылке согласующий видит список под таблицей сравнения.
|
||||
const allNoOfferSuppliers = [...preservedNoOfferSuppliers, ...suppliersWithoutOffer];
|
||||
if (formData.ct) {
|
||||
await service.syncNoOfferSuppliers(formData.ct.id, getNoOfferSuppliers(allNoOfferSuppliers));
|
||||
}
|
||||
|
||||
return {
|
||||
currencyRates: savedRates,
|
||||
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
|
||||
suppliers: [...mergedSuppliersWithOffer, ...allNoOfferSuppliers],
|
||||
offers: mergedOffers,
|
||||
};
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { NoOfferSupplierEntry } from '../utils/noOfferSuppliers';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
interface Props {
|
||||
entries: NoOfferSupplierEntry[];
|
||||
}
|
||||
|
||||
// Поставщики, не предоставившие КП (Шаг 2), — списком под таблицей сравнения. Колонками их не
|
||||
// показываем: сравнивать у них нечего, а список нужен согласующим — к кому обращались.
|
||||
export const NoOfferSuppliersList: React.FC<Props> = ({ entries }) => {
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className={styles.criteriaSection}>
|
||||
<h4 className={styles.criteriaSectionTitle}>Поставщики, не предоставившие КП</h4>
|
||||
<ul className={styles.noOfferSupplierList}>
|
||||
{entries.map(entry => (
|
||||
<li key={entry.name} className={styles.noOfferSupplierItem}>
|
||||
<span className={styles.noOfferSupplierName}>{entry.name}</span>
|
||||
{entry.comment && <span className={styles.noOfferSupplierComment}>{entry.comment}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+26
@@ -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%;
|
||||
|
||||
+4
@@ -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<Props> = ({
|
||||
|
||||
<CriteriaTable matrix={matrix} />
|
||||
|
||||
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} />
|
||||
|
||||
<AiCommentBlock
|
||||
comment={ai.aiComment}
|
||||
decodedComment={ai.decodedAiComment}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, Cr
|
||||
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
|
||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||
|
||||
let extraIdCounter = 0;
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface Supplier {
|
||||
inn?: string; // UI only — not stored in SP
|
||||
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
|
||||
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
|
||||
comment?: string; // только у поставщика без КП (hasOffer: false) — почему нет КП
|
||||
status?: string;
|
||||
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
||||
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getNoOfferSuppliers, noOfferRecordsToSuppliers, planNoOfferSync, withNoOfferSuppliers } from './noOfferSuppliers';
|
||||
import { Supplier } from '../types/domain';
|
||||
|
||||
describe('planNoOfferSync («Не получены КП» по одной СТ)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<NoOfferSupplierRecord[]> {
|
||||
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<Record<string, unknown>>).map((item) => ({
|
||||
id: Number(item.Id),
|
||||
name: String(item.Title ?? ""),
|
||||
comment: String(item.comment ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
// Приводит «Не получены КП» этой СТ к списку с шага 2: новых — создать, удалённых и изменённых — убрать.
|
||||
async syncNoOfferSuppliers(ctId: string, entries: NoOfferSupplierEntry[]): Promise<void> {
|
||||
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<{
|
||||
|
||||
Reference in New Issue
Block a user