Compare commits

..

4 Commits

Author SHA1 Message Date
Тимур Городецкий 90b009dfc9 docs: CHANGELOG — цены с пробелами (#6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:00:11 +05:00
Тимур Городецкий 6ad40bc523 fix(ct): цена с пробелами между разрядами считается целиком (#6)
Ввод чисел разбирался parseFloat, который читает до первого пробела: цену
«100 000», скопированную из КП, таблица считала как 100. Все поля ввода СТ
(цена, цена в ₸, количество, километраж, курс валюты) разбирает одна функция:
обычные и неразрывные пробелы, «275 293,50», «1,234,567.89», хвост «₸».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:00:05 +05:00
Тимур Городецкий 9b0135b379 docs: CHANGELOG — серверный поиск на шаге 1 (#5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:48:08 +05:00
Тимур Городецкий 3a6aaa4722 feat(ct): поиск по номеру потребности на шаге 1 — на сервере (#5)
Раньше поиск фильтровал в браузере всё, что загружено за период: чтобы найти
старую потребность, приходилось поднимать период и ждать загрузки сотен
потребностей ради одной. Теперь номер уходит в API (?requestNumber=, часть
номера, в пределах периода) после паузы в наборе — приходят только совпадения.

Попутно: позиции, выбранные до смены списка (поиск, другой период), больше не
пропадают при «Сохранить» — карта позиций копится, а не пересобирается из
текущего списка.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:48:01 +05:00
12 changed files with 209 additions and 28 deletions
+9
View File
@@ -12,3 +12,12 @@
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим
список пока не показывается. (#1)
- СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах
периода. Старую потребность можно найти, не загружая весь период. (#5)
### Исправлено
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
«Сохранить и продолжить». (#5)
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6)
@@ -5,10 +5,13 @@ import { Request, Position, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { RequestsTable } from './RequestsTable';
import {
KnownPosition,
buildPinnedRequests,
countSelectedInRequest,
mergeKnownPositions,
planStep1Save,
resolveProject,
resolveSelectedPositions,
@@ -27,6 +30,8 @@ interface Props {
const DEFAULT_DAYS = 5;
const PAGE_SIZE = 10;
// Пауза в наборе номера, после которой поиск уходит на сервер.
const SEARCH_DEBOUNCE_MS = 500;
export const Step1PositionSelection: React.FC<Props> = ({
service,
@@ -44,7 +49,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(selectionKey))
);
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
const [positionMap, setPositionMap] = useState<Map<string, KnownPosition>>(new Map());
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
@@ -55,8 +60,11 @@ export const Step1PositionSelection: React.FC<Props> = ({
[formData.selectedPositions, ctProjectName],
);
// Поиск по номеру — на сервере (часть номера, в пределах периода): иначе ради одной старой
// потребности приходилось грузить весь период.
const serverSearch = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS);
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
useRequests(service, days, isReadOnly, pinnedRequests);
useRequests(service, days, serverSearch, isReadOnly, pinnedRequests);
const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return [];
@@ -80,6 +88,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => {
// Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ,
// которые добавляются к выдаче без учёта поиска.
const q = searchQuery.trim().toLowerCase();
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
@@ -97,15 +107,10 @@ export const Step1PositionSelection: React.FC<Props> = ({
const safePage = Math.min(currentPage, totalPages);
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Build position→request map for resolving selection metadata
// Позиция → потребность для выбора и «Сохранить». Копим, а не пересобираем: поиск и смена периода
// меняют список, а выбранные раньше позиции должны дойти до сохранения.
useEffect(() => {
const map = new Map<string, { position: Position; request: Request }>();
requests.forEach(req => {
req.positions.forEach(pos => {
map.set(pos.id, { position: pos, request: req });
});
});
setPositionMap(map);
setPositionMap(prev => mergeKnownPositions(prev, requests));
}, [requests]);
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
@@ -1,12 +1,14 @@
import {
KnownPosition,
buildPinnedRequests,
countSelectedInRequest,
mergeKnownPositions,
planStep1Save,
resolveProject,
resolveSelectedPositions,
selectionKey,
} from './step1Plan';
import { Position } from '../types/domain';
import { Position, Request } from '../types/domain';
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
return {
@@ -75,6 +77,46 @@ describe('countSelectedInRequest', () => {
});
});
describe('mergeKnownPositions', () => {
function makeRequest(id: string, positionIds: string[]): Request {
return {
id, requestNumber: `081/26-04/${id}`, project: { id: 'Проект', name: 'Проект', code: '' },
discipline: '', workType: '', organization: '', createdAt: '',
positionsCount: positionIds.length, positions: positionIds.map(p => makePosition(p)),
};
}
// Выбрали позицию → нашли другую потребность поиском на сервере (первой в выдаче уже нет) →
// «Сохранить» должно сохранить обе. Раньше карта позиций пересобиралась из текущего списка,
// и выбор до поиска молча пропадал (так же — при смене периода).
it('позиции прежнего списка остаются после загрузки нового', () => {
const beforeSearch = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]);
const afterSearch = mergeKnownPositions(beforeSearch, [makeRequest('req2', ['req2__0'])]);
expect(Array.from(afterSearch.keys()).sort()).toEqual(['req1__0', 'req2__0']);
expect(afterSearch.get('req1__0')?.request.id).toBe('req1');
});
it('повторная загрузка той же потребности обновляет позицию', () => {
const first = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]);
const reloaded = makeRequest('req1', ['req1__0']);
reloaded.positions[0].quantity = 5;
const result = mergeKnownPositions(first, [reloaded]);
expect(result.get('req1__0')?.position.quantity).toBe(5);
});
it('не меняет переданную карту', () => {
const known = new Map<string, KnownPosition>();
mergeKnownPositions(known, [makeRequest('req1', ['req1__0'])]);
expect(known.size).toBe(0);
});
});
describe('resolveSelectedPositions', () => {
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
// иначе «Сохранить» неактивна, хотя выбор есть.
@@ -49,6 +49,25 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
return count;
}
export interface KnownPosition {
position: Position;
request: Request;
}
// Все позиции, которые показывались на шаге 1, — копятся, а не заменяются при каждой загрузке.
// Список потребностей меняется (поиск по номеру на сервере, другой период), а выбор остаётся:
// позиция, выбранная до поиска, должна дойти до «Сохранить», хоть её потребности и нет в выдаче.
export function mergeKnownPositions(
known: Map<string, KnownPosition>,
requests: Request[],
): Map<string, KnownPosition> {
const next = new Map(known);
requests.forEach(request => {
request.positions.forEach(position => next.set(position.id, { position, request }));
});
return next;
}
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
export function resolveSelectedPositions(
@@ -2,6 +2,7 @@ import * as React from 'react';
import { useCallback, useState } from 'react';
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { parseNumberInput } from '../utils/parseNumberInput';
import styles from './CurrencyRatesForm.module.scss';
interface Props {
@@ -58,9 +59,9 @@ export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly =
const updateRate = useCallback(
(code: string, value: string) => {
const parsed = parseFloat(value.replace(',', '.'));
const parsed = parseNumberInput(value);
onChange(
rates.map(r => (r.code === code ? { ...r, rateToKzt: isNaN(parsed) ? 0 : parsed } : r))
rates.map(r => (r.code === code ? { ...r, rateToKzt: parsed ?? 0 } : r))
);
},
[rates, onChange]
@@ -4,6 +4,7 @@ import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { getUnitStatus } from '../utils/unitConversion';
import { parseNumberInput } from '../utils/parseNumberInput';
import { unitStatusClass } from './unitStatusStyles';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
@@ -55,7 +56,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
/>
</div>
) : (
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity
(posQtyOverrides[pos.id] !== undefined ? parseNumberInput(posQtyOverrides[pos.id]) : undefined) ?? pos.quantity
)}
</td>
</>
@@ -6,6 +6,7 @@ import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput';
import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -275,8 +276,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
});
const qtyOverrides: Record<string, number> = {};
Object.keys(posQtyOverridesRef.current).forEach(id => {
const v = parseFloat(posQtyOverridesRef.current[id]);
if (!isNaN(v)) qtyOverrides[id] = v;
const v = parseNumberInput(posQtyOverridesRef.current[id]);
if (v !== undefined) qtyOverrides[id] = v;
});
onSaveRef.current({
offers: updatedOffers,
@@ -399,8 +400,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.'));
const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000;
const parsed = parseNumberInput(value);
const numVal = parsed === undefined ? 0 : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
});
},
@@ -416,8 +417,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.'));
const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000;
const parsed = parseNumberInput(value);
const override = parsed === undefined ? undefined : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) };
});
},
@@ -661,9 +662,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
};
const handleDeliveryKmChange = (posId: string, supId: string, val: string): void => {
const km = parseFloat(val);
const km = parseNumberInput(val);
const key = `${posId}::${supId}`;
const updated = isNaN(km)
const updated = km === undefined
? (({ [key]: _, ...rest }) => rest)(deliveryKm)
: { ...deliveryKm, [key]: km };
setDeliveryKm(updated);
@@ -894,8 +895,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
spId: extraPosSpIds[ep.id] ?? ep.spId,
})),
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
const v = parseFloat(posQtyOverrides[id]);
if (!isNaN(v)) acc[id] = v;
const v = parseNumberInput(posQtyOverrides[id]);
if (v !== undefined) acc[id] = v;
return acc;
}, {}),
step3DeliveryKm: deliveryKm,
@@ -0,0 +1,14 @@
import { useEffect, useState } from 'react';
// Значение, которое обновляется только после паузы delayMs в изменениях — чтобы поиск с запросом
// к серверу уходил, когда пользователь перестал печатать, а не на каждую букву.
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
@@ -27,9 +27,11 @@ async function loadMissingPinned(service: CtService, pinned: Request[], loaded:
);
}
// requestNumber — поиск по номеру на сервере (часть номера, в пределах периода); пусто — весь период.
export function useRequests(
service: CtService,
days: number,
requestNumber: string,
skip: boolean,
pinnedRequests: Request[],
): UseRequestsResult {
@@ -51,7 +53,7 @@ export function useRequests(
setError(undefined);
service
.fetchRequests(days)
.fetchRequests(days, requestNumber || undefined)
.then(async data => {
const pinned = await loadMissingPinned(service, pinnedRequests, data);
if (!cancelled) {
@@ -69,7 +71,7 @@ export function useRequests(
return () => {
cancelled = true;
};
}, [service, days, reloadToken, skip, pinnedRequests]);
}, [service, days, requestNumber, reloadToken, skip, pinnedRequests]);
const loadPositions = useCallback(
async (requestId: string): Promise<Position[]> => {
@@ -0,0 +1,52 @@
import { parseNumberInput } from './parseNumberInput';
describe('parseNumberInput', () => {
// Претензия ОМТС от 2026-10-23, п.6: цену скопировали из КП «100 000» — сумма посчиталась от 100.
it('пробелы между разрядами не обрывают число', () => {
expect(parseNumberInput('100 000')).toBe(100000);
expect(parseNumberInput('1 00000')).toBe(100000);
expect(parseNumberInput('10 0 000')).toBe(100000);
});
// Word, Excel и PDF ставят между разрядами неразрывный пробел, а не обычный.
it('неразрывные пробелы — тоже', () => {
expect(parseNumberInput('100 000')).toBe(100000);
expect(parseNumberInput('1 234 567,5')).toBe(1234567.5);
});
it('запятая — десятичный разделитель, как в русских КП', () => {
expect(parseNumberInput('275 293,50')).toBe(275293.5);
expect(parseNumberInput('0,061')).toBe(0.061);
});
it('точка — тоже десятичный разделитель', () => {
expect(parseNumberInput('286575.5')).toBe(286575.5);
});
// КП на английском: «1,234,567.89». Есть и запятая, и точка — десятичный разделитель последний.
it('английский формат с разделителями разрядов', () => {
expect(parseNumberInput('1,234,567.89')).toBe(1234567.89);
expect(parseNumberInput('1.234.567,89')).toBe(1234567.89);
});
it('несколько одинаковых разделителей — это разряды', () => {
expect(parseNumberInput('1,000,000')).toBe(1000000);
expect(parseNumberInput('1.000.000')).toBe(1000000);
});
it('хвост из валюты или единиц не мешает', () => {
expect(parseNumberInput('100 000 ₸')).toBe(100000);
expect(parseNumberInput('1 540 USD')).toBe(1540);
});
it('пусто или не число — undefined, а не 0: «не введено» ≠ «ноль»', () => {
expect(parseNumberInput('')).toBeUndefined();
expect(parseNumberInput(' ')).toBeUndefined();
expect(parseNumberInput('abc')).toBeUndefined();
});
it('обычные числа как раньше', () => {
expect(parseNumberInput('635')).toBe(635);
expect(parseNumberInput('-5')).toBe(-5);
});
});
@@ -0,0 +1,32 @@
// Число из поля ввода. Цены и количества копируют из КП, Excel и PDF: «100 000», «275 293,50»,
// «1,234,567.89», с неразрывными пробелами между разрядами. parseFloat читает до первого пробела —
// «100 000» превращалось в 100, и сумма считалась в тысячу раз меньше.
export function parseNumberInput(value: string): number | undefined {
// \s в JS покрывает и неразрывные пробелы (U+00A0, U+202F).
const compact = value.replace(/\s/g, '');
if (compact === '') return undefined;
const parsed = parseFloat(normalizeSeparators(compact));
return isNaN(parsed) ? undefined : parsed;
}
// Приводит разделители к виду «1234567.89»:
// - есть и «,», и «.» — десятичный тот, что последний, другой — разряды («1,234.5», «1.234,5»);
// - один и тот же знак несколько раз — разряды («1,000,000»);
// - один знак один раз — десятичный (в русских КП это запятая: «0,061»).
function normalizeSeparators(text: string): string {
const lastComma = text.lastIndexOf(',');
const lastDot = text.lastIndexOf('.');
if (lastComma !== -1 && lastDot !== -1) {
const decimal = lastComma > lastDot ? ',' : '.';
const thousands = decimal === ',' ? '.' : ',';
return text.split(thousands).join('').replace(decimal, '.');
}
const separator = lastComma !== -1 ? ',' : lastDot !== -1 ? '.' : undefined;
if (separator === undefined) return text;
const occurrences = text.split(separator).length - 1;
return occurrences > 1 ? text.split(separator).join('') : text.replace(separator, '.');
}
@@ -179,9 +179,12 @@ export class CtService {
return tokenProvider.getToken(AAD_RESOURCE);
}
async fetchRequests(days: number): Promise<Request[]> {
// requestNumber — поиск на сервере: по части номера, в пределах периода days. Без него —
// все потребности периода.
async fetchRequests(days: number, requestNumber?: string): Promise<Request[]> {
const token = await this._getApiToken();
const res = await fetch(`${EXTERNAL_API}/Requests/${days}`, {
const query = requestNumber ? `?requestNumber=${encodeURIComponent(requestNumber)}` : "";
const res = await fetch(`${EXTERNAL_API}/Requests/${days}${query}`, {
headers: { Authorization: `Bearer ${token}` },
});