SLN1
Сервис для отдела закупа: принимает CSV-выгрузки поставщиков (IEK, SystemElectric), рассчитывает рекомендуемые заказы по SKU со срочностью и…
О проекте
Команда: SLN1
Кейс HackAlem.ai: Логистика — Электрокомплект · ekt.kz
Стадия: Рабочий MVP
Задача кейса: Автоматический расчёт заказов поставщикам для пополнения склада, с утверждением менеджером
Суть
Сервис для отдела закупа: принимает CSV-выгрузки поставщиков (IEK, SystemElectric), рассчитывает рекомендуемые заказы по SKU со срочностью и объяснением. Менеджер подтверждает заказы в дашборде и выгружает CSV.
Как устроено
Дашборд отправляет файлы в FastAPI. Каждый файл проходит через .NET-микросервис extractor (парсинг CSV в cp1251), результат сохраняется фрагментами в SQLite. Расчёт по SKU: из спроса исключаются редкие крупные разовые продажи (больше медианы×4, если их не более 15%), спрос агрегируется по месяцам, в месяцы stockout восстанавливается, считается индекс сезонности, прогноз строится по последним 6 месяцам с десезонализацией. Страховой запас задаётся через CV категории (5–21 дней), учитываются поставки в пути с датами прихода в пределах lead time 30 дней, заказ округляется до MOQ. Дата исчерпания запаса моделируется по дням и определяет срочность (critical/soon/planned).
Ключевые решения
- сезонный индекс
- компенсация stockout
- робастное исключение выбросов
- safety stock по CV
- посуточная симуляция запаса
- округление до MOQ
Метрики
На реальных файлах: 3181 SKU IEK и 724 SKU SystemElectric, 0 ошибок загрузки
Особенности
Товар в пути учитывается как список поставок с датами, а не одним числом: засчитывается только то, что придёт в пределах lead time, а в объяснении названы даты окончания запаса и ближайшей поставки. В README честная таблица статусов реализованного и нереализованного (тренд, lead time по поставщику, 1С).
Стек
Python · FastAPI · SQLite · C#/.NET · HTML/CSS/JS · Docker Compose
Модели
OpenAI API
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-5cdb988b-sln1.
README
README.mdHackAlem AI — Track 05: Logistics
Задание 01 — «Автоматический расчёт заказов поставщикам для пополнения склада»
Владелец задачи: ТОО «Электрокомплект»
Основной пользователь: менеджер отдела закупа
Решение автоматизирует подготовку рекомендаций по пополнению склада: пользователь загружает исходные файлы, система рассчитывает рекомендуемые количества по поставщикам, показывает срочность и объяснение, после чего менеджер проверяет и подтверждает результат.
Заказы поставщикам автоматически не отправляются. Финальное решение остаётся за ответственным сотрудником.
1. Что должно решать решение
По официальному ТЗ расчёт должен учитывать:
- историю продаж;
- текущие остатки;
- товары в пути;
- категории товаров;
- сезонность;
- устойчивый рост спроса;
- прогноз прироста;
- упущенный спрос в периоды stockout;
- разовые крупные заказы и выбросы;
- поставщиков и сроки поставки.
Целевой сценарий:
загрузка файлов
→ обработка данных
→ расчёт рекомендаций
→ просмотр по поставщикам
→ проверка объяснений
→ корректировка / подтверждение
→ экспорт
2. Current status
MVP полностью рабочий end-to-end, проверено на настоящих файлах партнёра (не на моках):
- все 6 файлов IEK + все 6 файлов SystemElectric загружены одним запросом → 0 ошибок, 3181 SKU по IEK и 724 SKU по SystemElectric;
- extractor реально парсит CSV (cp1251,
;), а не отдаёт preview; - ответ backend проходит собственную валидацию dashboard (
validateData) без ошибок; - повторная частичная загрузка (например, только обновлённый "Товар в пути") не стирает ранее загруженных поставщиков — backend хранит фрагменты в SQLite и на каждый запрос пересчитывает и возвращает всех известных поставщиков.
| Компонент | Статус |
|---|---|
| Dashboard | Implemented |
POST /api/upload | Implemented |
| FastAPI backend | Implemented |
| SQLite cache (переживает рестарт backend'а) | Implemented |
| Расчёт по SKU | Implemented |
| Сезонность | Implemented |
| Stockout compensation | Implemented |
| Учёт текущего остатка | Implemented |
| Учёт товара в пути с датами поставок | Implemented |
| Дефицит по датам (запас закончится / поставка придёт) | Implemented |
| Базовое исключение крупных выбросов | Implemented |
| Влияние категории на расчёт | Implemented |
| Supplier grouping | Implemented |
| Explanation | Implemented |
| Urgency | Implemented |
| CSV export | Implemented |
| Human approval в UI | Implemented |
| CSV extractor (IEK + SystemElectric) | Implemented |
| End-to-end реальные файлы → рекомендации | Implemented, verified |
Customer-level anomaly по customer_id | Not implemented (в данных партнёра нет обезличенного ID клиента) |
| Устойчивый trend / growth (отдельно от сезонности) | Not implemented |
| Отдельный forecast growth input | Not implemented |
| Supplier-specific lead time | Not implemented (фиксировано 30 дней) |
| Интеграция с 1С | Not implemented |
| Автоматическая отправка поставщику | Not implemented by design |
3. Архитектура
Рабочий поток (проверен end-to-end на реальных файлах):
Dashboard
│
│ POST /api/upload (несколько файлов, multipart)
▼
FastAPI backend
│
├─ каждый файл отдельным запросом → extractor /extract
├─ canonical fragment {supplier, file_type, items} → SQLite (upsert по поставщик+тип файла)
├─ сборка полного набора по КАЖДОМУ известному поставщику из накопленных фрагментов
└─ process_supplier(...) по каждому
│
▼
JSON recommendations (все поставщики, не только из этого запроса)
│
▼
Dashboard
→ таблица
→ фильтры
→ approve
→ CSV export
Для расчёта backend ожидает фрагменты типов:
sales
stock
transit
Дополнительно поддерживается:
moq
Файлы Сезонность и Ежемесячные продажи в количественном выражении extractor распознаёт и принимает (не выдаёт ошибку), но их содержимое не используется в расчёте — сезонность и агрегаты считаются внутри process_supplier из сырой истории продаж.
Каждый запрос к /api/upload пересчитывает и возвращает всех поставщиков, известных backend'у, а не только тех, чьи файлы пришли в этом запросе — иначе частичная догрузка одного поставщика убирала бы остальных из ответа и, соответственно, с экрана.
4. API
POST /api/upload
Принимает несколько файлов одним multipart-запросом:
multipart/form-data
files: File[]
Backend:
- отправляет каждый файл в extractor отдельным запросом (по одному файлу за раз);
- сохраняет canonical fragments в SQLite (upsert по
supplier + file_type, старая версия этого типа файла для поставщика полностью заменяется); - собирает полные данные по всем известным поставщикам из накопленных фрагментов;
- запускает
process_supplier(...)по каждому; - возвращает готовый JSON, включая поставщиков, не затронутых этим конкретным запросом.
Ожидаемый ответ:
{
"generated_at": "2026-09-23",
"suppliers": [
{
"supplier": "IEK",
"items": [
{
"sku": "SKU-001",
"name": "Товар",
"category": "Категория",
"current_stock": 20,
"in_transit_qty": 10,
"recommended_qty": 55,
"urgency": "critical",
"reason": "Краткое объяснение расчёта"
}
]
}
],
"errors": []
}
urgency:
critical
soon
planned
POST /api/recalculate
Принимает уже нормализованные данные и запускает расчёт без загрузки файлов.
GET /api/health
{
"status": "ok"
}
5. Методология расчёта
Расчёт выполняется по каждому SKU внутри process_supplier(...).
5.1. Исключение крупных разовых транзакций
Используется robust-эвристика:
large transaction:
abs(qty) > median(abs(qty)) × 4
Крупные операции исключаются из регулярного спроса только если они редкие:
share of large transactions <= 15%
Если крупные продажи повторяются часто, алгоритм не исключает их автоматически.
Ограничение: отдельный анализ аномалии по обезличенному
customer_idпока не реализован.
5.2. Агрегация спроса
После очистки продажи агрегируются по месяцам:
YYYY-MM → sum(qty)
5.3. Stockout / упущенный спрос
Stockout определяется только по данным остатков:
monthly_stock <= 0
Нулевые продажи сами по себе stockout не означают.
Для stockout-месяца спрос восстанавливается:
- по среднему спросу того же календарного месяца в другие годы;
- если такой истории нет — по среднему non-stockout спросу.
5.4. Сезонность
Для каждого календарного месяца рассчитывается:
seasonality_index =
average demand for calendar month
/
average demand for all months
5.5. Forecast
Текущий forecast использует последние 6 месяцев:
recent mean
→ deseasonalization
→ seasonal coefficient target month
Отдельный устойчивый trend / growth factor пока не реализован.
5.6. Категория и safety stock
Для категории рассчитывается волатильность:
CV = std(monthly demand) / mean(monthly demand)
Страховой запас:
safety_stock_days = 7 × (1 + average category CV)
с ограничением:
5 ≤ safety_stock_days ≤ 21
5.7. Товар в пути: даты поставок, а не одно число
Вход — не одно число "сколько в пути", а список поставок с датой прибытия:
incoming_shipments: [{ qty, expected_date }, ...]
Для IEK дата извлекается прямо из заголовка колонки файла "Товар в пути" (например, "поступление до 10.10.2026"). Для SystemElectric, где файл не содержит дат прихода, вся партия считается пришедшей "завтра" — это единственное текущее упрощение для этого поставщика.
В расчёт объёма заказа засчитывается только то, что придёт в пределах горизонта поставки (lead_time_days):
qty_arriving_in_lead_time =
sum(qty for shipments where today <= expected_date <= today + lead_time_days)
Поставки, которые придут позже этого окна, в объём заказа не засчитываются — они не помогут закрыть ближайшую потребность.
5.8. Рекомендуемое количество
Текущий default lead time:
30 дней
Формула:
daily_forecast = monthly_forecast / 30
demand_for_lead_time =
daily_forecast × lead_time_days
safety_stock =
daily_forecast × safety_stock_days
raw_need =
demand_for_lead_time
+ safety_stock
- current_stock
- qty_arriving_in_lead_time
recommended_qty =
max(0, raw_need)
Если задан MOQ, заказ округляется вверх до кратности MOQ.
5.9. Дефицит по датам
Отдельно от объёма заказа считается дата исчерпания запаса — симуляция дня за днём: текущий остаток тратится по daily_forecast, а каждая известная поставка пополняет его на свою дату. Если очередная поставка приходит позже, чем закончится запас — это дефицит, reason явно называет обе даты:
"запаса хватит до 2026-10-05, ближайшая поставка ожидается 2026-10-15 - вероятен дефицит на 10 дн."
Если дефицита нет — обоснование говорит, до какой даты хватит запаса с учётом поставок. Именно эта дата (не плоское число дней) используется для определения срочности.
5.10. Срочность
По дням до расчётной даты исчерпания запаса (см. 5.9):
< 7 дней → critical
< 21 дня → soon
>= 21 дня → planned
6. Explainability
Для каждой рекомендации формируется reason, который может включать:
- прогноз спроса;
- сезонный коэффициент;
- страховой запас;
- категорию;
- stockout-коррекцию;
- исключённые крупные заказы;
- дни покрытия текущим остатком;
- предупреждение об отсутствии актуального остатка.
7. Dashboard
Dashboard реализован на:
HTML
CSS
JavaScript ES Modules
Поддерживаются:
- выбор нескольких файлов;
- drag & drop;
- удаление файлов перед отправкой;
- один синхронный
POST /api/upload; - loading state;
- HTTP/network/JSON error states;
- partial errors;
- KPI;
- поиск;
- фильтр поставщика;
- фильтр срочности;
- группировка по поставщикам;
- раскрываемое explanation;
- approve по одной позиции;
- approve видимых позиций поставщика;
- фильтр подтверждённых;
- CSV export.
dashboard/mock_recommendations.json используется только как fixture для frontend-тестов. Рабочий dashboard его не загружает.
Корневой mock_recommendations.json удалён как неиспользуемый.
8. Структура проекта
.
├── dashboard/
│ ├── index.html
│ ├── styles.css
│ ├── dashboard.mjs
│ ├── recommendations.mjs
│ ├── mock_recommendations.json
│ ├── package.json
│ └── tests/
├── src/
│ ├── api/
│ │ ├── main.py
│ │ └── storage.py
│ ├── calc_engine/
│ │ └── pipeline.py
│ └── extractor/
│ └── Extractor/
├── tests/
│ ├── test_api.py
│ ├── test_pipeline.py
│ └── fixtures/
├── Docs/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example
└── README.md
9. Установка
Python
Требуется Python 3.11+.
python -m venv .venv
Linux / macOS:
source .venv/bin/activate
Windows PowerShell:
.\.venv\Scripts\Activate.ps1
Установка зависимостей:
pip install -r requirements.txt
Dashboard tests
Для frontend-тестов требуется Node.js.
npm ci --prefix dashboard
Extractor
Extractor — ASP.NET Core приложение. Для локального запуска требуется .NET SDK либо Docker.
10. Запуск
Основной backend + dashboard
FastAPI сам раздаёт содержимое dashboard/, поэтому для локального запуска достаточно:
Linux / macOS:
PYTHONPATH=src uvicorn api.main:app --host 127.0.0.1 --port 8001
Windows PowerShell:
$env:PYTHONPATH="src"
uvicorn api.main:app --host 127.0.0.1 --port 8001
Dashboard:
http://127.0.0.1:8001/
Health check:
http://127.0.0.1:8001/api/health
Так dashboard и /api/upload работают с одного origin.
Только просмотр dashboard без API
python3 -m http.server 8000 --bind 127.0.0.1 --directory dashboard
Открыть:
http://127.0.0.1:8000/
В этом режиме UI доступен, но /api/upload отсутствует.
Docker Compose
docker compose up --build
Сервисы:
extractor : 8080
backend : 8001
Dashboard входит в образ backend и раздаётся через FastAPI.
11. Environment variables
.env.example:
OPENAI_API_KEY=
Backend также использует:
EXTRACTOR_URL
UPLOADS_DB_PATH
В docker-compose.yml они задаются для backend автоматически.
OPENAI_API_KEY не требуется для текущей работы /extract — парсинг детерминированный (без LLM), переменная оставлена в .env.example как задел на будущее и может быть пустой/любой.
Секреты не должны попадать в Git.
12. Тестирование
Backend / calculation engine
PYTHONPATH=src pytest -q
Проверено на текущем архиве:
15 passed
Dashboard
npm test --prefix dashboard
npm run check --prefix dashboard
Проверено на текущем архиве:
19 passed
Frontend-тесты покрывают:
- выбор и удаление файлов;
- drag & drop;
- multipart POST;
- loading state;
- защиту от повторной отправки;
- обновление результатов;
- HTTP/network/JSON errors;
- partial errors;
- фильтры;
- approve;
- explanation;
- CSV export.
Backend-тесты покрывают:
- сезонность;
- stockout compensation;
- устойчивость к крупной разовой продаже;
- влияние
in_transit/дат поставок; - влияние категории;
- explanation;
- urgency;
- API contracts;
- SQLite cache и частичные обновления;
- возврат всех известных поставщиков на каждый запрос;
- деградацию при недоступном/некорректно отвечающем extractor.
Дополнительно вручную (не автоматизировано) проверено: все 12 реальных файлов IEK + SystemElectric за один запрос — 0 ошибок, 3181 + 724 SKU, ответ проходит validateData дашборда.
13. Must Have — текущее покрытие
| Требование | Статус |
|---|---|
| Базовый расчёт по SKU (все источники, изменение любого влияет на результат) | Implemented |
| История продаж | Implemented |
| Текущие остатки | Implemented |
| Товары в пути (с датами поставок) | Implemented |
| Категория товара (влияет на safety stock) | Implemented |
| Forecast growth input | Not implemented |
| Сезонность | Implemented |
| Устойчивый рост спроса | Not implemented separately (учтён неявно через тренд последних 6 мес.) |
| Дефицит по датам с учётом поставок | Implemented |
| Stockout / lost demand | Implemented |
| Крупные разовые заказы | Implemented at transaction level |
| Крупная продажа одному клиенту | Not implemented separately (в данных партнёра нет обезличенного ID клиента) |
| Supplier grouping | Implemented |
| Recommended quantity | Implemented |
| Urgency | Implemented |
| Explanation | Implemented |
| Dashboard / table | Implemented |
| Export | Implemented: CSV |
| Human approval | Implemented in dashboard |
| Автоматическая отправка поставщику | Intentionally not implemented |
14. Ограничения текущего MVP
- Определение поставщика/типа файла — по имени файла (плюс резервная эвристика по содержимому для файла продаж IEK, где в имени файла нет бренда). Если партнёр когда-нибудь пришлёт файл с совсем другим именем — потребуется расширить эвристику.
- Customer-level anomaly detection по обезличенному клиенту отсутствует — в предоставленных данных партнёра нет поля с ID клиента.
- Отдельный устойчивый trend / growth отсутствует — учитывается неявно через тренд последних 6 месяцев.
- Внешний forecast growth не передаётся в расчёт.
- Lead time сейчас фиксирован значением
30дней (не по поставщику). - Для SystemElectric нет дат поставок в исходном файле "Товар в пути" — весь объём считается приходящим "завтра"; для IEK даты извлекаются из файла по-настоящему.
- Нет прямой интеграции с 1С.
- Approve — UI-состояние, а не размещение заказа у поставщика.
- Extractor не использует LLM — парсинг детерминированный (regex/CSV),
OPENAI_API_KEYне задействован.
15. Privacy & Security
- клиентские данные должны оставаться обезличенными;
- нельзя использовать неанонимизированные данные клиентов;
- API keys и пароли не должны попадать в Git;
- исходные данные партнёра не должны публиковаться без разрешения;
.envне должен коммититься;- итоговые рекомендации проверяет ответственный сотрудник;
- заказ не отправляется поставщику автоматически.
16. Ближайшие TODO
P0 (end-to-end на реальных файлах) — выполнено:
- привести
/extractк canonical fragment contract; - прогнать реальные файлы поставщиков через
/api/upload; - выполнить полный smoke test:
upload → extract → cache → calculate → JSON → dashboard.
Дальше, по приоритету:
- даты поставок для SystemElectric (сейчас только IEK даёт реальные даты из файла);
- customer-level anomaly detection (нужен ID клиента от партнёра);
- устойчивый trend / growth отдельно от сезонности;
- forecast growth input;
- supplier-specific lead time.
Авторы коммитов
История коммитов
- 2026-09-23 18:29Update README: MVP is end-to-end working, verified on real filesZhanarBaken
- 2026-09-23 18:24Return all known suppliers from /api/upload, not just this request'sZhanarBaken
- 2026-09-23 18:19Handle all 12 real supplier files without errorsZhanarBaken
- 2026-09-23 18:13Guarantee non-empty name/category in calc_engine outputZhanarBaken
- 2026-09-23 18:05Implement real CSV extraction for the .NET extractor serviceZhanarBaken
- 2026-09-23 17:57upd READMEgdyussen
- 2026-09-23 17:56Track incoming shipment dates instead of a flat in_transit_qty numberZhanarBaken
- 2026-09-23 17:51Remove comments/docstrings from calc_engine, api, and testsZhanarBaken
Все коммиты (35)
- 2026-09-23 17:50chore: remove unused root mock recommendationsgdyussen
- 2026-09-23 17:45update READMEgdyussen
- 2026-09-23 17:38Serve the dashboard from the backend to fix relative /api/upload callsZhanarBaken
- 2026-09-23 17:35Ignore the whole data/ runtime directory, not just .db filesZhanarBaken
- 2026-09-23 17:29Persist uploaded file fragments in SQLite instead of an in-memory cacheZhanarBaken
- 2026-09-23 17:17Wire /api/upload to the extractor microservice over HTTP, add Docker packagingZhanarBaken
- 2026-09-23 17:16next upd dashboardgdyussen
- 2026-09-23 17:12Make category actually affect recommended_qty, not just display textZhanarBaken
- 2026-09-23 17:06Flag when current-month stock is missing instead of silently treating as 0ZhanarBaken
- 2026-09-23 17:00Merge branch 'main' of https://github.com/BAITC-Hacks/hack-5cdb988b-sln1Abay Chilikbayev
- 2026-09-23 17:00Заготовка ExtractAbay Chilikbayev
- 2026-09-23 16:59upd dashbordgdyussen
- 2026-09-23 16:48git ignoreAbay Chilikbayev
- 2026-09-23 16:48Заготовка для экстрактораAbay Chilikbayev
- 2026-09-23 16:47Заготовка для экстрактораAbay Chilikbayev
- 2026-09-23 16:27update READMEgdyussen
- 2026-09-23 16:11Add FastAPI backend with /api/recalculate and /api/uploadZhanarBaken
- 2026-09-23 15:50Add procurement dashboardgdyussen
- 2026-09-23 15:50Add mock_recommendations.json referenced by README for frontend devZhanarBaken
- 2026-09-23 15:47Add order recommendation calc engine with outlier/seasonality/stockout handlingZhanarBaken
- 2026-09-23 15:40Update README for procurement dashboardgdyussen
- 2026-09-23 15:36chore: update repository filesZhanarBaken
- 2026-09-23 14:50chore: add safe project scaffoldZhanarBaken
- 2026-09-23 14:48Merge branch 'main' of https://github.com/BAITC-Hacks/hack-5cdb988b-sln1Abay Chilikbayev
- 2026-09-23 14:48заготовкаAbay Chilikbayev
- 2026-09-23 14:27docs: add repository overviewgdyussen
- 2026-09-23 13:18InitAbay Chilikbayev
Структура репозитория (53 файлов)
.env.example .gitignore Dockerfile Docs/положения из регламента.md README.md app/.gitkeep dashboard/.gitignore dashboard/README.md dashboard/dashboard.mjs dashboard/index.html dashboard/mock_recommendations.json dashboard/package-lock.json dashboard/package.json dashboard/recommendations.mjs dashboard/styles.css dashboard/tests/dashboard.test.mjs dashboard/tests/recommendations.test.mjs docker-compose.yml requirements.txt src/.gitkeep src/api/__init__.py src/api/main.py src/api/storage.py src/calc_engine/__init__.py src/calc_engine/pipeline.py src/extractor/Extractor/.dockerignore src/extractor/Extractor/Extractor.AppHost/AppHost.cs src/extractor/Extractor/Extractor.AppHost/Extractor.AppHost.csproj src/extractor/Extractor/Extractor.AppHost/Properties/launchSettings.json src/extractor/Extractor/Extractor.AppHost/appsettings.Development.json src/extractor/Extractor/Extractor.AppHost/appsettings.json src/extractor/Extractor/Extractor.ServiceDefaults/Extensions.cs src/extractor/Extractor/Extractor.ServiceDefaults/Extractor.ServiceDefaults.csproj src/extractor/Extractor/Extractor.slnx src/extractor/Extractor/Extractor/Dockerfile src/extractor/Extractor/Extractor/Extractor.csproj src/extractor/Extractor/Extractor/Extractor.csproj.user src/extractor/Extractor/Extractor/Extractor.http src/extractor/Extractor/Extractor/Program.cs src/extractor/Extractor/Extractor/Properties/launchSettings.json src/extractor/Extractor/Extractor/Services/Extract/ExcelMarkdownService.cs src/extractor/Extractor/Extractor/Services/Extract/ExtractorService.cs src/extractor/Extractor/Extractor/Services/Extract/OpenAiClient.cs src/extractor/Extractor/Extractor/Services/Extract/RealCsvExtractor.cs src/extractor/Extractor/Extractor/appsettings.Development.json src/extractor/Extractor/Extractor/appsettings.json src/extractor/gitignore tests/.gitkeep tests/conftest.py tests/fixtures/build_fixture.py tests/fixtures/mock_input.json tests/test_api.py tests/test_pipeline.py
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.