Compare commits
4 Commits
efbe732995
...
90b009dfc9
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b009dfc9 | |||
| 6ad40bc523 | |||
| 9b0135b379 | |||
| 3a6aaa4722 |
@@ -12,3 +12,12 @@
|
|||||||
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
|
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
|
||||||
причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим
|
причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим
|
||||||
список пока не показывается. (#1)
|
список пока не показывается. (#1)
|
||||||
|
- СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах
|
||||||
|
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
|
||||||
|
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
|
||||||
|
«Сохранить и продолжить». (#5)
|
||||||
|
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
||||||
|
до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6)
|
||||||
|
|||||||
+15
-10
@@ -5,10 +5,13 @@ import { Request, Position, FormStep } from '../types/domain';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { useRequests } from '../hooks/useRequests';
|
import { useRequests } from '../hooks/useRequests';
|
||||||
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
||||||
import { RequestsTable } from './RequestsTable';
|
import { RequestsTable } from './RequestsTable';
|
||||||
import {
|
import {
|
||||||
|
KnownPosition,
|
||||||
buildPinnedRequests,
|
buildPinnedRequests,
|
||||||
countSelectedInRequest,
|
countSelectedInRequest,
|
||||||
|
mergeKnownPositions,
|
||||||
planStep1Save,
|
planStep1Save,
|
||||||
resolveProject,
|
resolveProject,
|
||||||
resolveSelectedPositions,
|
resolveSelectedPositions,
|
||||||
@@ -27,6 +30,8 @@ interface Props {
|
|||||||
|
|
||||||
const DEFAULT_DAYS = 5;
|
const DEFAULT_DAYS = 5;
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
// Пауза в наборе номера, после которой поиск уходит на сервер.
|
||||||
|
const SEARCH_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
export const Step1PositionSelection: React.FC<Props> = ({
|
export const Step1PositionSelection: React.FC<Props> = ({
|
||||||
service,
|
service,
|
||||||
@@ -44,7 +49,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
|
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
|
||||||
new Set(formData.selectedPositions.map(selectionKey))
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
@@ -55,8 +60,11 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
[formData.selectedPositions, ctProjectName],
|
[formData.selectedPositions, ctProjectName],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Поиск по номеру — на сервере (часть номера, в пределах периода): иначе ради одной старой
|
||||||
|
// потребности приходилось грузить весь период.
|
||||||
|
const serverSearch = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS);
|
||||||
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
|
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
|
||||||
useRequests(service, days, isReadOnly, pinnedRequests);
|
useRequests(service, days, serverSearch, isReadOnly, pinnedRequests);
|
||||||
|
|
||||||
const readOnlyRequests = useMemo(() => {
|
const readOnlyRequests = useMemo(() => {
|
||||||
if (!isReadOnly) return [];
|
if (!isReadOnly) return [];
|
||||||
@@ -80,6 +88,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
|
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
|
||||||
|
|
||||||
const filteredRequests = useMemo(() => {
|
const filteredRequests = useMemo(() => {
|
||||||
|
// Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ,
|
||||||
|
// которые добавляются к выдаче без учёта поиска.
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
|
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 safePage = Math.min(currentPage, totalPages);
|
||||||
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
|
||||||
// Build position→request map for resolving selection metadata
|
// Позиция → потребность для выбора и «Сохранить». Копим, а не пересобираем: поиск и смена периода
|
||||||
|
// меняют список, а выбранные раньше позиции должны дойти до сохранения.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = new Map<string, { position: Position; request: Request }>();
|
setPositionMap(prev => mergeKnownPositions(prev, requests));
|
||||||
requests.forEach(req => {
|
|
||||||
req.positions.forEach(pos => {
|
|
||||||
map.set(pos.id, { position: pos, request: req });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setPositionMap(map);
|
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
|
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
|
||||||
|
|||||||
+43
-1
@@ -1,12 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
|
KnownPosition,
|
||||||
buildPinnedRequests,
|
buildPinnedRequests,
|
||||||
countSelectedInRequest,
|
countSelectedInRequest,
|
||||||
|
mergeKnownPositions,
|
||||||
planStep1Save,
|
planStep1Save,
|
||||||
resolveProject,
|
resolveProject,
|
||||||
resolveSelectedPositions,
|
resolveSelectedPositions,
|
||||||
selectionKey,
|
selectionKey,
|
||||||
} from './step1Plan';
|
} from './step1Plan';
|
||||||
import { Position } from '../types/domain';
|
import { Position, Request } from '../types/domain';
|
||||||
|
|
||||||
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
|
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
|
||||||
return {
|
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', () => {
|
describe('resolveSelectedPositions', () => {
|
||||||
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
|
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
|
||||||
// иначе «Сохранить» неактивна, хотя выбор есть.
|
// иначе «Сохранить» неактивна, хотя выбор есть.
|
||||||
|
|||||||
@@ -49,6 +49,25 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
|
|||||||
return count;
|
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 выбор есть, а «Сохранить» неактивна.
|
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
|
||||||
export function resolveSelectedPositions(
|
export function resolveSelectedPositions(
|
||||||
|
|||||||
+3
-2
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
|
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
|
||||||
import { CurrencyRate } from '../types/domain';
|
import { CurrencyRate } from '../types/domain';
|
||||||
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import styles from './CurrencyRatesForm.module.scss';
|
import styles from './CurrencyRatesForm.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,9 +59,9 @@ export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly =
|
|||||||
|
|
||||||
const updateRate = useCallback(
|
const updateRate = useCallback(
|
||||||
(code: string, value: string) => {
|
(code: string, value: string) => {
|
||||||
const parsed = parseFloat(value.replace(',', '.'));
|
const parsed = parseNumberInput(value);
|
||||||
onChange(
|
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]
|
[rates, onChange]
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@ import { CurrencyRate } from '../types/domain';
|
|||||||
import { PositionRow } from './matrixTypes';
|
import { PositionRow } from './matrixTypes';
|
||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { getUnitStatus } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import { unitStatusClass } from './unitStatusStyles';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
@@ -55,7 +56,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity
|
(posQtyOverrides[pos.id] !== undefined ? parseNumberInput(posQtyOverrides[pos.id]) : undefined) ?? pos.quantity
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+11
-10
@@ -6,6 +6,7 @@ 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, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
|
||||||
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
||||||
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||||
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
@@ -275,8 +276,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
});
|
});
|
||||||
const qtyOverrides: Record<string, number> = {};
|
const qtyOverrides: Record<string, number> = {};
|
||||||
Object.keys(posQtyOverridesRef.current).forEach(id => {
|
Object.keys(posQtyOverridesRef.current).forEach(id => {
|
||||||
const v = parseFloat(posQtyOverridesRef.current[id]);
|
const v = parseNumberInput(posQtyOverridesRef.current[id]);
|
||||||
if (!isNaN(v)) qtyOverrides[id] = v;
|
if (v !== undefined) qtyOverrides[id] = v;
|
||||||
});
|
});
|
||||||
onSaveRef.current({
|
onSaveRef.current({
|
||||||
offers: updatedOffers,
|
offers: updatedOffers,
|
||||||
@@ -399,8 +400,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
setEdits(prev => {
|
setEdits(prev => {
|
||||||
const pos = allPositionsRef.current.find(p => p.id === positionId);
|
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 items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
|
||||||
const parsed = parseFloat(value.replace(',', '.'));
|
const parsed = parseNumberInput(value);
|
||||||
const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000;
|
const numVal = parsed === undefined ? 0 : Math.round(parsed * 1000) / 1000;
|
||||||
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
|
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -416,8 +417,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
setEdits(prev => {
|
setEdits(prev => {
|
||||||
const pos = allPositionsRef.current.find(p => p.id === positionId);
|
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 items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
|
||||||
const parsed = parseFloat(value.replace(',', '.'));
|
const parsed = parseNumberInput(value);
|
||||||
const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000;
|
const override = parsed === undefined ? undefined : Math.round(parsed * 1000) / 1000;
|
||||||
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) };
|
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 handleDeliveryKmChange = (posId: string, supId: string, val: string): void => {
|
||||||
const km = parseFloat(val);
|
const km = parseNumberInput(val);
|
||||||
const key = `${posId}::${supId}`;
|
const key = `${posId}::${supId}`;
|
||||||
const updated = isNaN(km)
|
const updated = km === undefined
|
||||||
? (({ [key]: _, ...rest }) => rest)(deliveryKm)
|
? (({ [key]: _, ...rest }) => rest)(deliveryKm)
|
||||||
: { ...deliveryKm, [key]: km };
|
: { ...deliveryKm, [key]: km };
|
||||||
setDeliveryKm(updated);
|
setDeliveryKm(updated);
|
||||||
@@ -894,8 +895,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
spId: extraPosSpIds[ep.id] ?? ep.spId,
|
spId: extraPosSpIds[ep.id] ?? ep.spId,
|
||||||
})),
|
})),
|
||||||
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
|
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
|
||||||
const v = parseFloat(posQtyOverrides[id]);
|
const v = parseNumberInput(posQtyOverrides[id]);
|
||||||
if (!isNaN(v)) acc[id] = v;
|
if (v !== undefined) acc[id] = v;
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {}),
|
||||||
step3DeliveryKm: deliveryKm,
|
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(
|
export function useRequests(
|
||||||
service: CtService,
|
service: CtService,
|
||||||
days: number,
|
days: number,
|
||||||
|
requestNumber: string,
|
||||||
skip: boolean,
|
skip: boolean,
|
||||||
pinnedRequests: Request[],
|
pinnedRequests: Request[],
|
||||||
): UseRequestsResult {
|
): UseRequestsResult {
|
||||||
@@ -51,7 +53,7 @@ export function useRequests(
|
|||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|
||||||
service
|
service
|
||||||
.fetchRequests(days)
|
.fetchRequests(days, requestNumber || undefined)
|
||||||
.then(async data => {
|
.then(async data => {
|
||||||
const pinned = await loadMissingPinned(service, pinnedRequests, data);
|
const pinned = await loadMissingPinned(service, pinnedRequests, data);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -69,7 +71,7 @@ export function useRequests(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [service, days, reloadToken, skip, pinnedRequests]);
|
}, [service, days, requestNumber, reloadToken, skip, pinnedRequests]);
|
||||||
|
|
||||||
const loadPositions = useCallback(
|
const loadPositions = useCallback(
|
||||||
async (requestId: string): Promise<Position[]> => {
|
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);
|
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 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}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user