Один расчёт.
Одна согласованная модель.
1. Архитектура v1
Браузер
│ HTTP :3000 · Next.js / React / TypeScript
│ GET страницы, SVG, HTML-документация
│ fetch('/api/...') — тот же origin
▼
Next.js rewrite /api/*
│ HTTP loopback :5080, API_URL на стороне сервера
▼
ASP.NET Core 10 · C# Minimal API
├─ Каталог и зоны (серверные константы)
├─ Валидация и расчёт decimal
├─ Ограничение запросов
└─ PortalStore · SemaphoreSlim
└─ App_Data/portal.json (атомарная замена файла)Стек выбран в соответствии с запросом: C# на бэкенде и Next.js/React на фронтенде. Не используется подмена C# логики обработчиками Next.js. Весь расчёт цены и создание заявки выполняются в API.
ADR-01: для локального однопроцессного MVP выбран JSON без внешних пакетов. Это облегчает запуск, но не подходит для нескольких экземпляров API, крупного каталога или промышленного хранения персональных данных. Следующий этап — PostgreSQL, миграции, транзакции и уникальность Order.QuoteId на уровне БД.
ADR-02: серверный proxy позволяет браузеру использовать один origin. CORS для прямого доступа к API не включён. Оба процесса по умолчанию слушают только 127.0.0.1. ADR-03: нет оплаты и состояния «Подтверждено», пока не реализованы поставщики, остатки и диспетчеризация.
2. Модель данных
Product 1 ─── N Quote 1 ─── 0..1 Order Zone 1 ─── N Quote Order содержит полный снимок Quote. Product и Zone живут в Catalog (код). Quote и Order сохраняются в одном StoreState (JSON). Receipt — ограниченная проекция Order для клиента.
| Сущность | Поля | Правила |
|---|---|---|
| Product | id, name, category, fraction, price, density, description, use, standard, color | price: RUB/м³; density: т/м³; id стабилен; тестовые данные |
| Zone | id, name, pricePerTrip, note | 5 зон, pickup имеет нулевой тариф |
| Quote | id, productId/name, quantity/unit, volume/weight, zoneId/name, trips, materialCost, deliveryCost, total, currency, expiresAt, tariffVersion, isEstimate | GUID без дефисов; 24 часа; tariffVersion=demo-v1; monetary decimal |
| Order | id, token, quote, name, phone, address, deliveryDate, customerType, company, comment, consentAt, consentVersion, createdAt | id: KR- + 12 hex; token: 64 hex; один Order на Quote |
| Receipt | id, token, status, total, productName, volume, deliveryDate, createdAt | Не возвращает имя, телефон, адрес, компанию и комментарий |
| StoreState | quotes[], orders[] | Все записи одного экземпляра сервера в одном файле |
Ссылочная целостность проверяется приложением при создании расчёта. Заявка сохраняет снимок, поэтому последующие изменения каталога не изменяют уже зафиксированную сумму. Устаревшие свободные расчёты очищаются при следующем создании расчёта; снимки в заявках остаются. Автоматической очистки заявок нет.
Точность
Деньги — C# decimal, округление до копеек AwayFromZero. Объём при переводе тонн округляется вверх до 0,001 м³, чтобы не занизить загрузку машины. Отображаемая масса округляется до 0,001 т. Число рейсов вычисляется по неокруглённой массе из нормализованного объёма. Даты API — UTC ISO 8601, дата поставки — календарная YYYY-MM-DD по Москве.
3. Алгоритм расчёта
Вход: productId, quantity, unit ∈ {m3, t}, zoneId
1. Проверить материал и зону.
2. quantity > 0 и ≤ 1000.
3. rawVolume = unit == t ? quantity / density : quantity.
4. volume = ceil(rawVolume × 1000) / 1000.
5. Проверить 3 ≤ volume ≤ 500.
6. weightExact = volume × density.
7. trips = pickup ? 0 : ceil(max(volume / 12, weightExact / 20)).
8. materialCost = round(volume × price, 2, AwayFromZero).
9. deliveryCost = trips × zone.pricePerTrip.
10. total = materialCost + deliveryCost.
11. Зафиксировать расчёт; expiresAt = UTC now + 24h.| Пример | Материал | Рейсы / доставка | Итого |
|---|---|---|---|
| 10 м³ карьерного песка, до 30 км | 10 × 650 = 6 500 ₽; 15 т | 1 × 6 500 = 6 500 ₽ | 13 000 ₽ |
| 24 м³ мытого песка, до 30 км | 24 × 980 = 23 520 ₽; 38,4 т | 2 × 6 500 = 13 000 ₽ | 36 520 ₽ |
| 25 м³ ПГС, Москва | 25 × 1 250 = 31 250 ₽; 41,25 т | 3 × 5 500 = 16 500 ₽ | 47 750 ₽ |
| 15 т карьерного песка, самовывоз | 15 / 1,5 = 10 м³; 6 500 ₽ | 0 рейсов; 0 ₽ | 6 500 ₽ |
Объёмный помощник на фронтенде: V = площадь(м²) × слой(см) / 100. Он не включает коэффициент уплотнения, потери или технологический запас. Пользователь переносит результат в серверный калькулятор.
4. HTTP API
Base URL для прямых локальных запросов: http://127.0.0.1:5080; браузер обращается к http://127.0.0.1:3000/api. JSON: camelCase, UTF-8, числа в машинном формате с точкой. Лимит тела — 16 КБ. Некорректный JSON и несовместимые типы дают HTTP 400; инфраструктурные ошибки — Problem Details 500 без внутренних подробностей.
| Метод | Путь | Вход / доступ | Ответ |
|---|---|---|---|
| GET | /api/health | Нет | 200: status, version, mode |
| GET | /api/products?category=sand&search=… | Нет | 200: Product[]; неизвестная категория — пустой массив |
| GET | /api/products/{id} | Нет | 200: Product; 404 |
| GET | /api/delivery-zones | Нет | 200: Zone[] |
| POST | /api/quotes | QuoteRequest | 200: Quote; 400 — некорректный ввод |
| POST | /api/orders | OrderRequest | 201 — создана; 200 — повтор; 400 — поля/расчёт |
| GET | /api/orders/{id} | X-Order-Token: секрет | 200: Receipt; 404 — номер/секрет неверны |
Для всех операций, кроме health: максимум 60 запросов в минуту на IP, без очереди; превышение — 429. Тело 429 может быть пустым. Прокси пересылает запросы с loopback, поэтому в текущем локальном режиме лимит фактически общий для браузеров этого сервера.
Создание расчёта
POST /api/quotes
Content-Type: application/json
{"productId":"sand-quarry","quantity":10,"unit":"m3","zoneId":"near"}
200 OK
{
"id": "<32 hex>",
"productId": "sand-quarry", "productName": "Песок карьерный",
"quantity": 10, "unit": "m3", "volume": 10, "weight": 15,
"zoneId": "near", "zoneName": "До 30 км от МКАД", "trips": 1,
"materialCost": 6500, "deliveryCost": 6500, "total": 13000,
"currency": "RUB", "expiresAt": "<UTC ISO 8601>",
"tariffVersion": "demo-v1", "isEstimate": true
}Создание заявки
POST /api/orders
{
"quoteId": "<id расчёта>",
"name": "Тестовый клиент", "phone": "+7 900 000-00-00",
"address": "Тестовый объект, улица 1",
"deliveryDate": "<сегодня..+90 дней, YYYY-MM-DD>",
"customerType": "private", "company": null,
"comment": "Нужен подъезд для самосвала", "consent": true
}
201 Created → Receipt
{
"id":"KR-<12 hex>", "token":"<64 hex>",
"status":"Новая заявка", "total":13000,
"productName":"Песок карьерный", "volume":10,
"deliveryDate":"<YYYY-MM-DD>", "createdAt":"<UTC ISO 8601>"
}Цена в OrderRequest отсутствует. Лишние JSON-поля игнорируются стандартным десериализатором и не меняют итог. При повторе одного quoteId с теми же именем и телефоном возвращается прежний Receipt, даже если повторное тело содержит другой комментарий или дату: заявка не обновляется. Для изменений нужен новый расчёт.
Валидация заявки
| Поле | Правило |
|---|---|
| quoteId | Существует; ещё не истёк при первом создании |
| name | После Trim от 2 до 80 символов |
| phone | 11 цифр; разрешены +, пробелы, скобки, дефисы, длина строки 10–24; владение номером не проверяется |
| address | После Trim 5–300 символов, в том числе для самовывоза |
| deliveryDate | Точный формат yyyy-MM-dd, диапазон сегодня..+90 дней по UTC+3 |
| customerType | private или business |
| company | Обязательно для business; до 160 символов |
| comment | Необязательно; до 1000 символов |
| consent | Должно быть true; сервер ставит timestamp и версию |
Проверка заявки
GET /api/orders/KR-…
X-Order-Token: <64 hex>
200 → Receipt
404 → {"message":"Заявка не найдена или код доступа неверен"}Общий формат доменной ошибки: {"message":"Текст для пользователя"}. Фронтенд обрабатывает также пустые/не-JSON ответы и недоступность API; не показывает успешную отправку до ответа сервера. Во время запроса кнопка блокируется. Устаревший ответ расчёта после изменения параметров отбрасывается по локальной ревизии.
5. Безопасность и ограничения
| Мера v1 | Назначение | Остаточный риск / следующий шаг |
|---|---|---|
| Loopback по умолчанию | Не публиковать локальное демо в сети | При публичном запуске TLS, reverse proxy, конфигурация hosts |
| Секрет 256 бит, сравнение constant-time | Доступ к статусу без открытого списка заявок | Секрет хранится в файле и localStorage; нужны хеширование токена, срок и отзыв |
| Receipt без контактных данных | Не выдавать персональные поля через публичный GET | Номер, сумма и дата тоже требуют бережного обращения |
| Серверная цена и snapshot | Нельзя подменить сумму из браузера | Тарифы требуют контроля версий и аудита |
| Rate limit и 16 КБ body | Ограничить поток и размер запросов | Не замена антиботу, квотам хранения и WAF |
| Согласие с версией и временем | Зафиксировать действие пользователя | Нет проверки личности; юридические тексты требуют владельца |
| React escaping, нет HTML из формы | Снизить риск инъекции в клиентский UI | Нужны CSP и аудит для публичной эксплуатации |
| Нет cookies авторизации и аккаунтов | Меньше состояния в локальном MVP | Будущая админка требует RBAC, MFA, журналирования |
| X-Frame-Options, nosniff, no-referrer | Базовые заголовки фронтенда | Политика CSP не настроена; публичный аудит обязателен |
В v1 нет сторонних аналитических трекеров и удалённых шрифтов; изображения — собственные локальные SVG. Метаданные запрещают индексацию демо. Эти меры не делают прототип готовым для публичного приёма реальных данных.
6. Хранение, эксплуатация и развёртывание
Один процесс API. SemaphoreSlim сериализует операции чтения/изменения/записи. Новый JSON сначала пишется во временный файл, затем заменяет основной в той же директории. Это защищает от частичной записи при обычном завершении операции, но не заменяет транзакционную БД, fsync и резервное копирование. Несколько процессов с одним файлом запрещены.
Путь: DataDirectory из конфигурации, по умолчанию backend/Karerchik.Api/App_Data. Каталог создаётся автоматически. Ошибка чтения JSON не приводит к тихой потере данных — запрос завершается ошибкой. На диске данные не зашифрованы. Автоматической очистки заказов и интерфейса удаления нет.
Запуск Windows
# Из папки v1: powershell -ExecutionPolicy Bypass -File .scriptsstart.ps1 # Портал: http://127.0.0.1:3000 # API: http://127.0.0.1:5080/api/health # Остановка только процессов проекта: powershell -ExecutionPolicy Bypass -File .scriptsstop.ps1 # Проверки: dotnet build .ackendKarerchik.Api -c Release cd frontend npm ci npm run build npm run typecheck cd .. node --test tests/api.test.mjs
Требуются .NET SDK 10 и Node.js 24 (проверено в среде проекта). Пакеты фронтенда фиксируются package-lock.json; запускать npm ci. API не использует внешние NuGet-пакеты. Скрипт start использует production-сборку Next.js, при её отсутствии выполняет сборку.
Восстановление и резервная копия
- Остановить оба процесса штатным скриптом.
- Скопировать App_Data/portal.json в защищённое резервное хранилище.
- Для восстановления заменить файл из копии при остановленном API.
- Запустить, проверить health и известную тестовую заявку по номеру и секрету.
Не включать рабочие заявки, токены, логи и зависимости в Git. Каталог App_Data исключён .gitignore. Тесты используют отдельное временное хранилище и не затрагивают рабочие заявки.
Нефункциональные требования
| ID | Требование | Статус / измерение |
|---|---|---|
| NFR-01 | Адаптивность от 360 px, без горизонтального скролла приложения | CSS media queries; ручная браузерная проверка |
| NFR-02 | Клавиатура, видимый фокус, подписи полей, native dialog | Реализованы; полный WCAG-аудит не выполнен |
| NFR-03 | Устойчивое сохранение после успешного 201 | Проверяется интеграционно с перезапуском API |
| NFR-04 | Согласованный расчёт денег и грузоподъёмности | decimal и граничные API-тесты |
| NFR-05 | Ошибки без внутренних трассировок | Problem Details / сообщения домена |
| NFR-06 | Локальная воспроизводимая сборка | Lockfile, фиксированный стек, README и сценарии |
| NFR-07 | Промышленная доступность и нагрузка | SLO и нагрузочные тесты не определены; до запуска выбрать целевые значения |
7. Источники по стеку
Next.js — официальная установка; Microsoft Learn — ASP.NET Core APIs. Версии npm проверены 16.10.2026: Next.js 16.4.2, React 19.3.1; фактические зависимости зафиксированы lockfile.