feat(ct): своя единица у логистики и доп. расходов на Шаге 3 (#11)
У логистики единица была зашита — «рейс», у доп. расходов её задавали только при добавлении. Теперь единица строки — поле ввода; поставщики, у которых её не меняли, получают новую, своя единица поставщика остаётся. Подсказка в количестве логистики — единица строки. Строка уже в SP — единица обновляется в «Позиции СТ» при сохранении. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-4
@@ -25,7 +25,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
|
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
|
||||||
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
|
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
|
||||||
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
|
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
|
||||||
handleDeleteExtraPosition,
|
handleDeleteExtraPosition, handleExtraPositionUnitChange,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
|
|
||||||
const ep = extraPositions.find(e => e.id === pos.id);
|
const ep = extraPositions.find(e => e.id === pos.id);
|
||||||
@@ -47,7 +47,22 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.unitCell}>{pos.unit}</td>
|
{/* Единица строки — своя (у логистики по умолчанию «рейс»: бывает «тонна», «машина», «услуга»). */}
|
||||||
|
<td className={styles.unitCell}>
|
||||||
|
{!isReadOnly ? (
|
||||||
|
<div className={styles.offerInput}>
|
||||||
|
<TextInput
|
||||||
|
value={pos.unit}
|
||||||
|
onChange={val => handleExtraPositionUnitChange(pos.id, val)}
|
||||||
|
placeholder="ед. изм."
|
||||||
|
borderless
|
||||||
|
ariaLabel={`Ед. изм. строки ${pos.materialName}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pos.unit
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className={styles.qtyCell}>—</td>
|
<td className={styles.qtyCell}>—</td>
|
||||||
{suppliersWithOffer.map(s => {
|
{suppliersWithOffer.map(s => {
|
||||||
const key = key0(s);
|
const key = key0(s);
|
||||||
@@ -120,9 +135,9 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
|
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
|
||||||
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
|
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
|
||||||
placeholder="рейсы"
|
placeholder={pos.unit || 'кол-во'}
|
||||||
borderless
|
borderless
|
||||||
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`}
|
ariaLabel={`Кол-во (${pos.unit || 'ед.'}) ${s.name} ${pos.materialName}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+29
-2
@@ -1,7 +1,34 @@
|
|||||||
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
|
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
|
||||||
import { ExtraPosition } from './matrixTypes';
|
import { EditableOffers, ExtraPosition } from './matrixTypes';
|
||||||
import { SupplierOffer } from '../types/domain';
|
import { SupplierOffer } from '../types/domain';
|
||||||
|
|
||||||
|
describe('applyPositionUnitChange (единица доп. позиции стала своей вместо «рейс»)', () => {
|
||||||
|
const edits: EditableOffers = {
|
||||||
|
'sup_a::extra_pos_1': [{ supplierId: 'sup_a', positionId: 'extra_pos_1', itemIndex: 0, unit: 'рейс', pricePerUnit: 50000 }],
|
||||||
|
'sup_b::extra_pos_1': [{ supplierId: 'sup_b', positionId: 'extra_pos_1', itemIndex: 0, unit: 'машина' }],
|
||||||
|
'sup_c::extra_pos_1': [{ supplierId: 'sup_c', positionId: 'extra_pos_1', itemIndex: 0 }],
|
||||||
|
'sup_a::pos_1': [{ supplierId: 'sup_a', positionId: 'pos_1', itemIndex: 0, unit: 'рейс' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyPositionUnitChange(edits, 'extra_pos_1', 'рейс', 'тонна');
|
||||||
|
|
||||||
|
it('у поставщика с прежней единицей позиции — новая, цена не теряется', () => {
|
||||||
|
expect(result['sup_a::extra_pos_1'][0]).toMatchObject({ unit: 'тонна', pricePerUnit: 50000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у поставщика без своей единицы — новая', () => {
|
||||||
|
expect(result['sup_c::extra_pos_1'][0].unit).toBe('тонна');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('свою единицу поставщика не трогает', () => {
|
||||||
|
expect(result['sup_b::extra_pos_1'][0].unit).toBe('машина');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('другие позиции не трогает, даже с той же единицей', () => {
|
||||||
|
expect(result['sup_a::pos_1'][0].unit).toBe('рейс');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('computeOffer', () => {
|
describe('computeOffer', () => {
|
||||||
it('считает сумму в тенге для КП в тенге', () => {
|
it('считает сумму в тенге для КП в тенге', () => {
|
||||||
const offer = computeOffer(
|
const offer = computeOffer(
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ export function buildOfferKey(supplierId: string, positionId: string): string {
|
|||||||
return `${supplierId}::${positionId}`;
|
return `${supplierId}::${positionId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поменяли единицу доп. позиции (логистика, доп. расходы): у поставщиков, у которых единица та же,
|
||||||
|
// что была у позиции (её не трогали), — новая. Свою единицу поставщика не меняем. Ячейки без правок
|
||||||
|
// и так показывают единицу позиции.
|
||||||
|
export function applyPositionUnitChange(
|
||||||
|
edits: EditableOffers,
|
||||||
|
positionId: string,
|
||||||
|
oldUnit: string,
|
||||||
|
newUnit: string,
|
||||||
|
): EditableOffers {
|
||||||
|
const next: EditableOffers = {};
|
||||||
|
Object.keys(edits).forEach(key => {
|
||||||
|
const isThisPosition = key.split('::')[1] === positionId;
|
||||||
|
next[key] = isThisPosition
|
||||||
|
? edits[key].map(item => (!item.unit || item.unit === oldUnit ? { ...item, unit: newUnit } : item))
|
||||||
|
: edits[key];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ColumnOffersInput {
|
export interface ColumnOffersInput {
|
||||||
columns: Array<{ id: string; isExtraSupplier: boolean }>;
|
columns: Array<{ id: string; isExtraSupplier: boolean }>;
|
||||||
positions: PositionRow[];
|
positions: PositionRow[];
|
||||||
|
|||||||
+16
-1
@@ -4,7 +4,7 @@ import { SupplierOffer, CurrencyRate, FormStep, SupplierExclusion } from '../typ
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
||||||
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
|
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
|
||||||
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
||||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import { getStep3SaveError } from './step3Validation';
|
import { getStep3SaveError } from './step3Validation';
|
||||||
@@ -681,6 +681,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
onSave({ step3DeliveryKm: updated });
|
onSave({ step3DeliveryKm: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Своя единица доп. позиции вместо заданной при добавлении (у логистики — «рейс»). Поставщики, у
|
||||||
|
// которых единицу не меняли, получают новую. В SP уходит при «Сохранить» (см. handleSave).
|
||||||
|
const handleExtraPositionUnitChange = (positionId: string, unit: string): void => {
|
||||||
|
if (isReadOnly) return;
|
||||||
|
const oldUnit = extraPositions.find(ep => ep.id === positionId)?.unit ?? '';
|
||||||
|
const updated = extraPositions.map(ep => (ep.id === positionId ? { ...ep, unit } : ep));
|
||||||
|
setExtraPositions(updated);
|
||||||
|
setEdits(prev => applyPositionUnitChange(prev, positionId, oldUnit, unit));
|
||||||
|
onSave({ step3ExtraPositions: updated });
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteExtraPosition = (positionId: string): void => {
|
const handleDeleteExtraPosition = (positionId: string): void => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
const updated = extraPositions.filter(ep => ep.id !== positionId);
|
const updated = extraPositions.filter(ep => ep.id !== positionId);
|
||||||
@@ -760,6 +771,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
if (ep.spId) {
|
if (ep.spId) {
|
||||||
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
||||||
extraPosSpIds[ep.id] = ep.spId;
|
extraPosSpIds[ep.id] = ep.spId;
|
||||||
|
// Единицу могли поменять после создания. Пишем всегда — строк доп. расходов единицы,
|
||||||
|
// а отслеживать «менялась ли» — лишнее состояние.
|
||||||
|
await service.updateCTPositionUnit(ep.spId, ep.unit);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const saved = await service.createCTPositions(ct.id, [{
|
const saved = await service.createCTPositions(ct.id, [{
|
||||||
@@ -992,6 +1006,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
handleAddDelivery,
|
handleAddDelivery,
|
||||||
handleDeliveryKmChange,
|
handleDeliveryKmChange,
|
||||||
handleDeleteExtraPosition,
|
handleDeleteExtraPosition,
|
||||||
|
handleExtraPositionUnitChange,
|
||||||
validateBeforeSave,
|
validateBeforeSave,
|
||||||
handleSave,
|
handleSave,
|
||||||
draftSavedAt,
|
draftSavedAt,
|
||||||
|
|||||||
@@ -397,6 +397,21 @@ export class CtService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Единица доп. позиции Шага 3 (логистика, доп. расходы) — её можно поменять после создания.
|
||||||
|
// MERGE на «Позиции СТ» ресивер не блокирует (блокирует только на «Оценка поставщиков»).
|
||||||
|
async updateCTPositionUnit(positionSpId: number, unit: string): Promise<void> {
|
||||||
|
const res = await this.http.fetch(this._liById(LISTS.CT_POSITIONS, positionSpId), SPHttpClient.configurations.v1, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;odata=nometadata",
|
||||||
|
"IF-MATCH": "*",
|
||||||
|
"X-HTTP-Method": "MERGE",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ unit }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ошибка обновления единицы позиции: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
|
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
|
||||||
// один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки).
|
// один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки).
|
||||||
// Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен:
|
// Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен:
|
||||||
|
|||||||
Reference in New Issue
Block a user