Начальный коммит: библиотека стандартных изделий (Python CLI + .NET WPF)
This commit is contained in:
commit
7a5efc540f
33 files changed
+2630
No files matched your search
+31
@@ -0,0 +1,31 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
!examples/*.db
|
||||
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
*.user
|
||||
*.suo
|
||||
bin/
|
||||
publish/
|
||||
@@ -0,0 +1,101 @@
|
||||
# Библиотека стандартных изделий
|
||||
|
||||
Каталог стандартных изделий (крепёж, подшипники и т. п.) с хранением в SQLite.
|
||||
Две реализации с общей схемой БД и совместимым JSON:
|
||||
|
||||
- `dotnet/` — desktop-приложение для Windows 11 на C# / .NET 8 / WPF (основное);
|
||||
- `src/standard_products/` — Python-пакет и CLI `stdparts` (генератор и эталон данных).
|
||||
|
||||
## Desktop-приложение (Windows 11)
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet build
|
||||
dotnet run --project src/StandardProducts.App
|
||||
```
|
||||
|
||||
Публикация одним self-contained exe (не требует .NET у пользователя):
|
||||
|
||||
```bash
|
||||
dotnet publish src/StandardProducts.App -c Release -r win-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true -o publish
|
||||
```
|
||||
|
||||
Подробнее — [dotnet/README.md](dotnet/README.md).
|
||||
|
||||
## Python CLI
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
|
||||
stdparts init
|
||||
stdparts import examples/sample_products.json
|
||||
stdparts list
|
||||
stdparts search болт
|
||||
```
|
||||
|
||||
Без установки пакета:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m standard_products init
|
||||
PYTHONPATH=src python3 -m standard_products import examples/sample_products.json
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Назначение |
|
||||
| --- | --- |
|
||||
| `stdparts init` | создать базу и схему |
|
||||
| `stdparts add <обозначение> --name ...` | добавить изделие |
|
||||
| `stdparts show <обозначение>` | показать изделие в JSON |
|
||||
| `stdparts list [--limit N]` | список изделий |
|
||||
| `stdparts search [запрос] [--standard ...] [--category ...] [--material ...]` | поиск |
|
||||
| `stdparts import <file.json>` | импорт (upsert по обозначению) |
|
||||
| `stdparts export <file.json>` | экспорт всего каталога |
|
||||
| `stdparts delete <обозначение>` | удалить изделие |
|
||||
| `stdparts stats` | количество записей |
|
||||
|
||||
Путь к базе задаётся глобальным флагом `--db` (по умолчанию `standard_products.db`):
|
||||
|
||||
```bash
|
||||
stdparts --db data/catalog.db list
|
||||
```
|
||||
|
||||
### Добавление с атрибутами
|
||||
|
||||
```bash
|
||||
stdparts add "Болт M10x50 ГОСТ 7798-70" \
|
||||
--name "Болт с шестигранной головкой" \
|
||||
--standard "ГОСТ 7798-70" \
|
||||
--category "Крепёж" \
|
||||
--material "Сталь 20" \
|
||||
--mass 0.045 \
|
||||
--attr "резьба=M10" \
|
||||
--attr "покрытие=цинк"
|
||||
```
|
||||
|
||||
## Модель данных
|
||||
|
||||
Поля изделия описаны в [docs/schema.md](docs/schema.md).
|
||||
Свободные характеристики хранятся в JSON-поле `attributes`.
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
ruff check .
|
||||
mypy src
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
dotnet/ # C# / WPF приложение (Core + App + xunit)
|
||||
src/standard_products/ # Python-пакет: db.py, cli.py
|
||||
tests/ # pytest
|
||||
examples/ # пример JSON для импорта
|
||||
docs/ # документация, схема, промты
|
||||
```
|
||||
@@ -0,0 +1,172 @@
|
||||
# Промт: болт ГОСТ 7805-70 в каталоге standard_products
|
||||
|
||||
> Адаптация исходного промта «C++/Qt/OCCT desktop-приложение» под текущий
|
||||
> проект: Python 3.10+, SQLite, пакет `standard_products`, CLI `stdparts`.
|
||||
> 3D/STEP вынесены в опциональную фазу (см. приложение).
|
||||
|
||||
## РОЛЬ И КОНТЕКСТ
|
||||
|
||||
Ты — senior Python-разработчик. Работаешь в репозитории
|
||||
`библиотека-стандартных-изделий`: каталог стандартных изделий на SQLite.
|
||||
Уже есть пакет `src/standard_products/` (`db.py`, `cli.py`), тесты
|
||||
`tests/`, примеры `examples/`, документация `docs/`. Зависимостей в
|
||||
рантайме нет — только стандартная библиотека.
|
||||
|
||||
Задача — добавить в каталог болты с шестигранной головкой по
|
||||
ГОСТ 7805-70 (класс точности А) как параметрическое семейство:
|
||||
таблица размеров, валидация, генерация изделий и загрузка в базу.
|
||||
|
||||
## ЗАДАЧА (MVP)
|
||||
|
||||
1. Новый модуль `src/standard_products/gost_7805.py`:
|
||||
- `BoltSize` (frozen dataclass), таблица размеров, допустимые длины;
|
||||
- функции получения размера, проверки длины, формирования `Product`;
|
||||
- идемпотентная загрузка всего семейства в БД.
|
||||
2. Команды CLI в `src/standard_products/cli.py`:
|
||||
- `stdparts bolts` — таблица размеров;
|
||||
- `stdparts bolts --size M10 --length 40` — параметры одного болта в JSON;
|
||||
- `stdparts seed-bolts` — загрузить все комбинации M×L в каталог.
|
||||
3. Тесты `tests/test_gost_7805.py`.
|
||||
4. Документация `docs/gost-7805.md`.
|
||||
5. Экспорт `examples/gost_7805.json` (сгенерировать из таблицы, не руками).
|
||||
|
||||
## СТЕК (строго соблюдать)
|
||||
|
||||
- Python 3.10+, только стандартная библиотека в рантайме.
|
||||
- SQLite через существующий `db.py` (`Product`, `upsert_product`, `connect`).
|
||||
- pytest / ruff / mypy — dev-зависимости, уже настроены в `pyproject.toml`.
|
||||
- Никаких новых обязательных зависимостей.
|
||||
|
||||
## ТАБЛИЦА ПАРАМЕТРОВ (ГОСТ 7805-70, класс точности А)
|
||||
|
||||
Захардкодь в `gost_7805.py`:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BoltSize:
|
||||
d: float # номинальный диаметр резьбы, мм
|
||||
P: float # шаг резьбы, мм
|
||||
S: float # размер под ключ (между гранями), мм
|
||||
k: float # высота головки, мм
|
||||
e: float # размер по углам (справочно), мм
|
||||
b: float | None = None # длина резьбы, мм (в источнике не задана)
|
||||
```
|
||||
|
||||
Данные (только эти строки, не выдумывай свои):
|
||||
|
||||
| Размер | d | P | S | k | e |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| M6 | 6 | 1.0 | 10 | 4.0 | 11.05 |
|
||||
| M8 | 8 | 1.25 | 13 | 5.3 | 14.38 |
|
||||
| M10 | 10 | 1.5 | 16 | 6.4 | 17.77 |
|
||||
| M12 | 12 | 1.75 | 18 | 7.5 | 19.85 |
|
||||
| M16 | 16 | 2.0 | 24 | 10.0 | 26.75 |
|
||||
|
||||
Доступные длины стержня L (мм): `20, 25, 30, 35, 40, 50, 60, 70, 80`.
|
||||
|
||||
Значение `b` в источнике не приведено — оставь `None`, не подставляй
|
||||
расчётные или справочные значения из головы.
|
||||
|
||||
## ТРЕБОВАНИЯ К МОДУЛЮ
|
||||
|
||||
Публичный API (имена можно уточнить, но смысл сохранить):
|
||||
|
||||
```python
|
||||
BOLT_SIZES: dict[str, BoltSize] # ключи: "M6", "M8", "M10", "M12", "M16"
|
||||
ALLOWED_LENGTHS_MM: tuple[int, ...] # (20, 25, ..., 80)
|
||||
|
||||
def get_size(label: str) -> BoltSize: ...
|
||||
def validate_length(length: int) -> None: ...
|
||||
def bolt_designation(size: BoltSize, length: int) -> str: ...
|
||||
def build_product(size: BoltSize, length: int) -> Product: ...
|
||||
def iter_products() -> Iterator[Product]: ...
|
||||
def seed_bolts(connection: sqlite3.Connection) -> int: ...
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- `designation`: `"Болт M10x40 ГОСТ 7805-70"` (диаметр — целое без `.0`).
|
||||
- `name`: `"Болт с шестигранной головкой"`.
|
||||
- `standard`: `"ГОСТ 7805-70"`, `category`: `"Крепёж"`, `unit`: `"шт"`.
|
||||
- `material` и `mass_kg` не заданы в источнике — `None`.
|
||||
- Параметры ГОСТ кладутся в `attributes` (ключи на русском, как в проекте):
|
||||
`диаметр`, `шаг_резьбы`, `размер_под_ключ`, `высота_головки`,
|
||||
`размер_по_углам`, `длина`, `длина_резьбы` (если `b is None` — не добавлять).
|
||||
- `seed_bolts` использует `upsert_product`, поэтому повторный запуск не
|
||||
создаёт дубликатов и возвращает число обработанных записей (5 × 9 = 45).
|
||||
- Ошибки — `ValueError` с перечислением допустимых значений:
|
||||
`get_size("M7")`, `validate_length(45)`.
|
||||
- Никаких изменений схемы БД и сигнатур существующих функций `db.py`.
|
||||
|
||||
## ТРЕБОВАНИЯ К CLI
|
||||
|
||||
Добавить подкоманды, не ломая существующие:
|
||||
|
||||
```bash
|
||||
stdparts bolts # таблица размеров
|
||||
stdparts bolts --size M10 --length 40 # JSON одного изделия
|
||||
stdparts seed-bolts # загрузить 45 изделий (идемпотентно)
|
||||
```
|
||||
|
||||
- `--size` и `--length` валидируются теми же функциями; при ошибке —
|
||||
сообщение в `stderr` и код возврата 1.
|
||||
- `seed-bolts` печатает число загруженных записей.
|
||||
- Работает с глобальным `--db`, как остальные команды.
|
||||
|
||||
## ТЕСТЫ (обязательно)
|
||||
|
||||
`tests/test_gost_7805.py`:
|
||||
|
||||
1. Таблица содержит ровно 5 размеров, значения совпадают с промтом.
|
||||
2. `bolt_designation` даёт `"Болт M10x40 ГОСТ 7805-70"` и не даёт `M10.0`.
|
||||
3. `build_product` заполняет `standard/category/unit/attributes`.
|
||||
4. `get_size` и `validate_length` кидают `ValueError` на недопустимых входах.
|
||||
5. `seed_bolts` в temp-БД создаёт 45 записей; повторный вызов — всё ещё 45.
|
||||
6. Несколько записей находятся через `search_products(conn, "ГОСТ 7805-70")`.
|
||||
7. CLI: `bolts --size M10 --length 40` возвращает 0 и валидный JSON.
|
||||
|
||||
## ДОКУМЕНТАЦИЯ
|
||||
|
||||
`docs/gost-7805.md`: таблица размеров, допустимые длины, формат
|
||||
`designation`, соответствие полей `Product` ↔ `attributes`, примеры команд.
|
||||
Ссылка из `README.md` в разделе про данные.
|
||||
|
||||
## ОГРАНИЧЕНИЯ (ЧТО НЕ ДЕЛАТЬ — строго)
|
||||
|
||||
- ❌ Не добавлять C++, Qt, OpenCASCADE, CMake — это другой стек и его нет в проекте.
|
||||
- ❌ Не делать 3D-геометрию, резьбу, спирали, вырезы и экспорт STEP в MVP.
|
||||
- ❌ Не менять схему SQLite и не добавлять миграции.
|
||||
- ❌ Не добавлять другие типы изделий (гайки, шайбы) — только болт 7805-70.
|
||||
- ❌ Не добавлять обязательные зависимости, логирование, настройки, темы.
|
||||
- ❌ Не выдумывать размеры, длины, массу и `b`, которых нет в источнике.
|
||||
- ❌ Не дублировать логику `db.py` — использовать `Product` и `upsert_product`.
|
||||
|
||||
## ФОРМАТ ОТВЕТА
|
||||
|
||||
Код каждого файла — отдельным блоком с полным путём:
|
||||
|
||||
```python
|
||||
# File: src/standard_products/gost_7805.py
|
||||
...
|
||||
```
|
||||
|
||||
Затем — короткая инструкция (3–5 команд): установка, тесты, линтеры,
|
||||
пример запуска. В конце — отчёт: какие файлы изменены и почему.
|
||||
|
||||
## КРИТЕРИИ ПРИЁМКИ
|
||||
|
||||
1. `pytest` — зелёный, включая новые тесты.
|
||||
2. `ruff check .` и `mypy src` — без ошибок.
|
||||
3. `stdparts seed-bolts` создаёт 45 изделий; повторный запуск — те же 45.
|
||||
4. `stdparts search "ГОСТ 7805-70"` находит загруженные болты.
|
||||
5. `stdparts bolts --size M16 --length 80` печатает корректный JSON.
|
||||
6. Существующие команды и тесты не сломаны.
|
||||
7. Новых обязательных зависимостей нет.
|
||||
|
||||
## ПРИЛОЖЕНИЕ (вне MVP): STEP-экспорт
|
||||
|
||||
Если позже понадобится STEP, это отдельная задача и отдельный модуль
|
||||
`step_export.py` с опциональной зависимостью (`cadquery`/`OCP`), не
|
||||
входящей в базовую установку. Геометрия строится из тех же `BoltSize`:
|
||||
шестигранная призма `S × k`, цилиндр `d/2 × L`, фаска 0.3×45° на торце.
|
||||
Резьба не моделируется. До появления зависимости модуль не добавлять.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Схема данных
|
||||
|
||||
## Таблица `products`
|
||||
|
||||
| Поле | Тип | Ограничения | Описание |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | INTEGER | PK, AUTOINCREMENT | внутренний идентификатор |
|
||||
| `designation` | TEXT | NOT NULL, UNIQUE | обозначение по стандарту, например `Болт M10x50 ГОСТ 7798-70` |
|
||||
| `name` | TEXT | NOT NULL | наименование изделия |
|
||||
| `standard` | TEXT | | стандарт: ГОСТ, ISO, DIN и т. п. |
|
||||
| `category` | TEXT | | категория: крепёж, подшипники, ... |
|
||||
| `material` | TEXT | | материал |
|
||||
| `unit` | TEXT | NOT NULL, DEFAULT `шт` | единица измерения |
|
||||
| `mass_kg` | REAL | >= 0 | масса в килограммах |
|
||||
| `attributes` | TEXT (JSON) | NOT NULL, DEFAULT `{}` | произвольные характеристики |
|
||||
| `created_at` | TEXT | NOT NULL | дата создания (UTC) |
|
||||
| `updated_at` | TEXT | NOT NULL | дата обновления (UTC) |
|
||||
|
||||
Индексы: `standard`, `category`, `material`.
|
||||
|
||||
## Идентификация
|
||||
|
||||
Ключ записи — `designation`. Поэтому импорт работает как upsert:
|
||||
повторный импорт записи с тем же обозначением обновляет её,
|
||||
а не создаёт дубликат.
|
||||
|
||||
## JSON-формат импорта/экспорта
|
||||
|
||||
```json
|
||||
{
|
||||
"products": [
|
||||
{
|
||||
"designation": "Болт M10x50 ГОСТ 7798-70",
|
||||
"name": "Болт с шестигранной головкой",
|
||||
"standard": "ГОСТ 7798-70",
|
||||
"category": "Крепёж",
|
||||
"material": "Сталь 20",
|
||||
"unit": "шт",
|
||||
"mass_kg": 0.045,
|
||||
"attributes": {"резьба": "M10"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Допустим и просто список объектов без обёртки `products`.
|
||||
Поле `id` при импорте игнорируется.
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Deterministic>true</Deterministic>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
# StandardProducts — desktop-приложение (C# / .NET 8 / WPF)
|
||||
|
||||
Каталог стандартных изделий для Windows 11: таблицы ГОСТ, поиск, импорт/экспорт JSON.
|
||||
Хранилище — SQLite, та же схема, что в Python-версии проекта.
|
||||
|
||||
## Требования
|
||||
|
||||
- Windows 10/11 (для запуска)
|
||||
- .NET SDK 8.0+ (для сборки)
|
||||
- Visual Studio 2022 не обязательна: достаточно `dotnet` CLI
|
||||
|
||||
## Сборка и запуск
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet build
|
||||
dotnet run --project src/StandardProducts.App
|
||||
```
|
||||
|
||||
## Публикация
|
||||
|
||||
Self-contained один файл (не требует .NET у пользователя):
|
||||
|
||||
```bash
|
||||
dotnet publish src/StandardProducts.App \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
-p:PublishSingleFile=true \
|
||||
-o publish
|
||||
```
|
||||
|
||||
Результат — `dotnet/publish/StandardProducts.exe` (~150 МБ, включает WPF и SQLite).
|
||||
Для MSIX используйте Windows Application Packaging Project или `MakeAppx`; для
|
||||
внутреннего распространения достаточно exe.
|
||||
|
||||
## Возможности
|
||||
|
||||
- таблица изделий с поиском по обозначению, наименованию, стандарту и атрибутам;
|
||||
- загрузка семейства болтов ГОСТ 7805-70 (5 размеров × 9 длин = 45 изделий);
|
||||
- импорт/экспорт JSON (формат совместим с Python-версией);
|
||||
- удаление изделий, просмотр атрибутов выбранной позиции;
|
||||
- регистронезависимый поиск по кириллице (SQLite-функция `ulower`).
|
||||
|
||||
База создаётся автоматически:
|
||||
`%LOCALAPPDATA%\StandardProducts\catalog.db`.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
dotnet/
|
||||
StandardProducts.sln
|
||||
src/StandardProducts.Core/ # модели, SQLite, ГОСТ 7805-70, JSON — net8.0
|
||||
src/StandardProducts.App/ # WPF-интерфейс — net8.0-windows
|
||||
tests/StandardProducts.Core.Tests/ # xunit
|
||||
```
|
||||
|
||||
Ядро (`Core`) не зависит от WPF и собирается/тестируется на любой ОС:
|
||||
|
||||
```bash
|
||||
dotnet test tests/StandardProducts.Core.Tests
|
||||
```
|
||||
|
||||
## 3D / STEP
|
||||
|
||||
В текущей версии не реализованы и не нужны. Если появятся:
|
||||
отдельная нативная библиотека на OpenCASCADE (C++), вызываемая из C#
|
||||
через P/Invoke/C++/CLI; UI и каталог при этом не меняются.
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{33C46318-4A03-4E87-8161-D7DE7389818C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardProducts.Core", "src\StandardProducts.Core\StandardProducts.Core.csproj", "{40DE1BD3-CD25-46E0-833D-1E30AB58D6FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardProducts.App", "src\StandardProducts.App\StandardProducts.App.csproj", "{2D399870-3207-4B43-85EC-D30970310BE1}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{27C9581B-1699-43CF-A22C-75DD00DF9939}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardProducts.Core.Tests", "tests\StandardProducts.Core.Tests\StandardProducts.Core.Tests.csproj", "{786EEFC0-382C-4661-B558-E553A93784BF}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{40DE1BD3-CD25-46E0-833D-1E30AB58D6FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{40DE1BD3-CD25-46E0-833D-1E30AB58D6FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40DE1BD3-CD25-46E0-833D-1E30AB58D6FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40DE1BD3-CD25-46E0-833D-1E30AB58D6FE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2D399870-3207-4B43-85EC-D30970310BE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2D399870-3207-4B43-85EC-D30970310BE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2D399870-3207-4B43-85EC-D30970310BE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2D399870-3207-4B43-85EC-D30970310BE1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{786EEFC0-382C-4661-B558-E553A93784BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{786EEFC0-382C-4661-B558-E553A93784BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{786EEFC0-382C-4661-B558-E553A93784BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{786EEFC0-382C-4661-B558-E553A93784BF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{40DE1BD3-CD25-46E0-833D-1E30AB58D6FE} = {33C46318-4A03-4E87-8161-D7DE7389818C}
|
||||
{2D399870-3207-4B43-85EC-D30970310BE1} = {33C46318-4A03-4E87-8161-D7DE7389818C}
|
||||
{786EEFC0-382C-4661-B558-E553A93784BF} = {27C9581B-1699-43CF-A22C-75DD00DF9939}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Application x:Class="StandardProducts.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources />
|
||||
</Application>
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace StandardProducts.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<Window x:Class="StandardProducts.App.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Библиотека стандартных изделий"
|
||||
Height="720" Width="1100"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<WrapPanel Grid.Row="0" Orientation="Horizontal">
|
||||
<TextBlock Text="Поиск:" VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBox x:Name="SearchBox"
|
||||
Width="280"
|
||||
Margin="0,0,10,0"
|
||||
ToolTip="Обозначение, наименование, стандарт или атрибут" />
|
||||
<Button Content="ГОСТ 7805-70"
|
||||
Padding="12,4"
|
||||
Margin="0,0,8,0"
|
||||
Click="SeedGost_Click"
|
||||
ToolTip="Загрузить болты ГОСТ 7805-70" />
|
||||
<Button Content="Импорт JSON"
|
||||
Padding="12,4"
|
||||
Margin="0,0,8,0"
|
||||
Click="Import_Click" />
|
||||
<Button Content="Экспорт JSON"
|
||||
Padding="12,4"
|
||||
Margin="0,0,8,0"
|
||||
Click="Export_Click" />
|
||||
<Button Content="Удалить"
|
||||
Padding="12,4"
|
||||
Click="Delete_Click" />
|
||||
</WrapPanel>
|
||||
|
||||
<DataGrid Grid.Row="1"
|
||||
x:Name="ProductsGrid"
|
||||
Margin="0,8,0,8"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="ProductsGrid_SelectionChanged"
|
||||
CanUserAddRows="False"
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Обозначение" Binding="{Binding Designation}" Width="2*" />
|
||||
<DataGridTextColumn Header="Наименование" Binding="{Binding Name}" Width="2*" />
|
||||
<DataGridTextColumn Header="Стандарт" Binding="{Binding Standard}" Width="*" />
|
||||
<DataGridTextColumn Header="Категория" Binding="{Binding Category}" Width="*" />
|
||||
<DataGridTextColumn Header="Материал" Binding="{Binding Material}" Width="*" />
|
||||
<DataGridTextColumn Header="Масса, кг"
|
||||
Binding="{Binding MassKg, StringFormat={}{0:0.###}}"
|
||||
Width="90" />
|
||||
<DataGridTextColumn Header="Ед." Binding="{Binding Unit}" Width="50" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<TextBox Grid.Row="2"
|
||||
x:Name="AttributesText"
|
||||
Height="80"
|
||||
Margin="0,0,0,8"
|
||||
IsReadOnly="True"
|
||||
TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
FontFamily="Consolas" />
|
||||
|
||||
<StatusBar Grid.Row="3">
|
||||
<StatusBarItem>
|
||||
<TextBlock x:Name="StatusText" Text="Готово" />
|
||||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Win32;
|
||||
using StandardProducts.Core.Data;
|
||||
using StandardProducts.Core.Gost;
|
||||
using StandardProducts.Core.IO;
|
||||
using StandardProducts.Core.Models;
|
||||
|
||||
namespace StandardProducts.App;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly CatalogDatabase _database;
|
||||
private readonly ProductRepository _repository;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var databasePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"StandardProducts",
|
||||
"catalog.db");
|
||||
|
||||
_database = new CatalogDatabase(databasePath);
|
||||
_repository = new ProductRepository(_database);
|
||||
|
||||
SearchBox.TextChanged += SearchBox_TextChanged;
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
try
|
||||
{
|
||||
var products = _repository.Search(SearchBox.Text, limit: 1000);
|
||||
ProductsGrid.ItemsSource = products;
|
||||
StatusText.Text = $"Найдено: {products.Count} · База: {_database.DatabasePath}";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) => RefreshList();
|
||||
|
||||
private void SeedGost_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = Gost7805.Seed(_repository);
|
||||
RefreshList();
|
||||
StatusText.Text = $"Загружено изделий ГОСТ 7805-70: {count}";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void Import_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Импорт каталога",
|
||||
Filter = "JSON (*.json)|*.json|Все файлы (*.*)|*.*",
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog(this) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var count = JsonCatalog.Import(_repository, dialog.FileName);
|
||||
RefreshList();
|
||||
StatusText.Text = $"Импортировано записей: {count}";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void Export_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Экспорт каталога",
|
||||
Filter = "JSON (*.json)|*.json",
|
||||
FileName = "catalog.json",
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog(this) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var count = JsonCatalog.Export(_repository.List(), dialog.FileName);
|
||||
StatusText.Text = $"Экспортировано записей: {count} → {dialog.FileName}";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void Delete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ProductsGrid.SelectedItem is not Product product)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Выберите изделие в таблице.",
|
||||
"Удаление",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var answer = MessageBox.Show(
|
||||
this,
|
||||
$"Удалить «{product.Designation}»?",
|
||||
"Подтверждение",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question);
|
||||
|
||||
if (answer != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_repository.Delete(product.Designation);
|
||||
RefreshList();
|
||||
StatusText.Text = $"Удалено: {product.Designation}";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ShowError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProductsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
AttributesText.Text = ProductsGrid.SelectedItem is Product product
|
||||
? string.Join(
|
||||
Environment.NewLine,
|
||||
product.Attributes.Select(pair => $"{pair.Key}: {pair.Value}"))
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private void ShowError(Exception exception) =>
|
||||
MessageBox.Show(this, exception.Message, "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
_database.Dispose();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<RootNamespace>StandardProducts.App</RootNamespace>
|
||||
<AssemblyName>StandardProducts</AssemblyName>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon />
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StandardProducts.Core\StandardProducts.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace StandardProducts.Core.Data;
|
||||
|
||||
/// <summary>Владелец соединения с базой каталога и её схемой.</summary>
|
||||
public sealed class CatalogDatabase : IDisposable
|
||||
{
|
||||
private const string Schema = """
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
designation TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
standard TEXT,
|
||||
category TEXT,
|
||||
material TEXT,
|
||||
unit TEXT NOT NULL DEFAULT 'шт',
|
||||
mass_kg REAL,
|
||||
attributes TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_standard ON products(standard);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_material ON products(material);
|
||||
""";
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public CatalogDatabase(string databasePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(databasePath))
|
||||
{
|
||||
throw new ArgumentException("Путь к базе не задан", nameof(databasePath));
|
||||
}
|
||||
|
||||
DatabasePath = Path.GetFullPath(databasePath);
|
||||
var directory = Path.GetDirectoryName(DatabasePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = DatabasePath,
|
||||
}.ToString();
|
||||
|
||||
_connection = new SqliteConnection(connectionString);
|
||||
_connection.Open();
|
||||
|
||||
// SQLite lower()/NOCASE работают только для ASCII, поэтому регистронезависимый
|
||||
// поиск по кириллице делаем через пользовательскую функцию.
|
||||
_connection.CreateFunction<string?, string?>("ulower", value => value?.ToLowerInvariant());
|
||||
|
||||
Execute("PRAGMA foreign_keys = ON;");
|
||||
Execute(Schema);
|
||||
}
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
public SqliteConnection Connection => _connection;
|
||||
|
||||
private void Execute(string sql)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Dispose() => _connection.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using StandardProducts.Core.Models;
|
||||
|
||||
namespace StandardProducts.Core.Data;
|
||||
|
||||
/// <summary>CRUD и поиск по каталогу изделий.</summary>
|
||||
public sealed class ProductRepository
|
||||
{
|
||||
private const string SelectAll = "SELECT * FROM products";
|
||||
|
||||
private static readonly JsonSerializerOptions AttributeJsonOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly CatalogDatabase _database;
|
||||
|
||||
public ProductRepository(CatalogDatabase database)
|
||||
{
|
||||
_database = database ?? throw new ArgumentNullException(nameof(database));
|
||||
}
|
||||
|
||||
public long Add(Product product)
|
||||
{
|
||||
ProductValidator.Validate(product);
|
||||
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO products (designation, name, standard, category, material, unit, mass_kg, attributes)
|
||||
VALUES ($designation, $name, $standard, $category, $material, $unit, $massKg, $attributes);
|
||||
""";
|
||||
AddParameters(command, product);
|
||||
|
||||
try
|
||||
{
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Изделие с обозначением «{product.Designation.Trim()}» уже существует", exception);
|
||||
}
|
||||
|
||||
using var lastId = _database.Connection.CreateCommand();
|
||||
lastId.CommandText = "SELECT last_insert_rowid();";
|
||||
return Convert.ToInt64(lastId.ExecuteScalar(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public long Upsert(Product product)
|
||||
{
|
||||
ProductValidator.Validate(product);
|
||||
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO products (designation, name, standard, category, material, unit, mass_kg, attributes)
|
||||
VALUES ($designation, $name, $standard, $category, $material, $unit, $massKg, $attributes)
|
||||
ON CONFLICT(designation) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
standard = excluded.standard,
|
||||
category = excluded.category,
|
||||
material = excluded.material,
|
||||
unit = excluded.unit,
|
||||
mass_kg = excluded.mass_kg,
|
||||
attributes = excluded.attributes,
|
||||
updated_at = datetime('now');
|
||||
""";
|
||||
AddParameters(command, product);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
using var select = _database.Connection.CreateCommand();
|
||||
select.CommandText = "SELECT id FROM products WHERE designation = $designation;";
|
||||
select.Parameters.AddWithValue("$designation", product.Designation.Trim());
|
||||
var result = select.ExecuteScalar();
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("Не удалось прочитать изделие после сохранения");
|
||||
}
|
||||
|
||||
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public Product? Get(string designation)
|
||||
{
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
command.CommandText = $"{SelectAll} WHERE designation = $designation LIMIT 1;";
|
||||
command.Parameters.AddWithValue("$designation", designation.Trim());
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
return reader.Read() ? ReadProduct(reader) : null;
|
||||
}
|
||||
|
||||
public bool Delete(string designation)
|
||||
{
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM products WHERE designation = $designation;";
|
||||
command.Parameters.AddWithValue("$designation", designation.Trim());
|
||||
return command.ExecuteNonQuery() > 0;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Product> List(int? limit = null)
|
||||
{
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
if (limit is null)
|
||||
{
|
||||
command.CommandText = $"{SelectAll} ORDER BY designation;";
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = $"{SelectAll} ORDER BY designation LIMIT $limit;";
|
||||
command.Parameters.AddWithValue("$limit", limit.Value);
|
||||
}
|
||||
|
||||
return ReadAll(command);
|
||||
}
|
||||
|
||||
public IReadOnlyList<Product> Search(
|
||||
string? query = null,
|
||||
string? standard = null,
|
||||
string? category = null,
|
||||
string? material = null,
|
||||
int limit = 50)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "limit должен быть больше нуля");
|
||||
}
|
||||
|
||||
var conditions = new List<string>();
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
conditions.Add(
|
||||
"(ulower(designation) LIKE $query OR ulower(name) LIKE $query " +
|
||||
"OR ulower(attributes) LIKE $query OR ulower(standard) LIKE $query)");
|
||||
command.Parameters.AddWithValue("$query", $"%{query.Trim().ToLowerInvariant()}%");
|
||||
}
|
||||
|
||||
AddExactFilter(command, conditions, "standard", standard);
|
||||
AddExactFilter(command, conditions, "category", category);
|
||||
AddExactFilter(command, conditions, "material", material);
|
||||
|
||||
var where = conditions.Count == 0 ? string.Empty : " WHERE " + string.Join(" AND ", conditions);
|
||||
command.CommandText = $"{SelectAll}{where} ORDER BY designation LIMIT $limit;";
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
return ReadAll(command);
|
||||
}
|
||||
|
||||
public int Count()
|
||||
{
|
||||
using var command = _database.Connection.CreateCommand();
|
||||
command.CommandText = "SELECT COUNT(*) FROM products;";
|
||||
return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void AddExactFilter(
|
||||
SqliteCommand command, ICollection<string> conditions, string column, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
conditions.Add($"{column} = ${column}");
|
||||
command.Parameters.AddWithValue($"${column}", value.Trim());
|
||||
}
|
||||
|
||||
private static void AddParameters(SqliteCommand command, Product product)
|
||||
{
|
||||
command.Parameters.AddWithValue("$designation", product.Designation.Trim());
|
||||
command.Parameters.AddWithValue("$name", product.Name.Trim());
|
||||
command.Parameters.AddWithValue("$standard", (object?)product.Standard ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$category", (object?)product.Category ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$material", (object?)product.Material ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue(
|
||||
"$unit", string.IsNullOrWhiteSpace(product.Unit) ? "шт" : product.Unit.Trim());
|
||||
command.Parameters.AddWithValue("$massKg", (object?)product.MassKg ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue(
|
||||
"$attributes",
|
||||
JsonSerializer.Serialize(
|
||||
product.Attributes ?? new Dictionary<string, string>(), AttributeJsonOptions));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Product> ReadAll(SqliteCommand command)
|
||||
{
|
||||
var products = new List<Product>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
products.Add(ReadProduct(reader));
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
private static Product ReadProduct(SqliteDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetInt64(reader.GetOrdinal("id")),
|
||||
Designation = reader.GetString(reader.GetOrdinal("designation")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Standard = GetNullableString(reader, "standard"),
|
||||
Category = GetNullableString(reader, "category"),
|
||||
Material = GetNullableString(reader, "material"),
|
||||
Unit = reader.GetString(reader.GetOrdinal("unit")),
|
||||
MassKg = ReadNullableDouble(reader, "mass_kg"),
|
||||
Attributes = ParseAttributes(reader.GetString(reader.GetOrdinal("attributes"))),
|
||||
CreatedAt = GetNullableString(reader, "created_at"),
|
||||
UpdatedAt = GetNullableString(reader, "updated_at"),
|
||||
};
|
||||
|
||||
private static string? GetNullableString(SqliteDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
|
||||
private static double? ReadNullableDouble(SqliteDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetDouble(ordinal);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseAttributes(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
|
||||
if (parsed is null)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
return parsed.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value.ValueKind == JsonValueKind.String
|
||||
? pair.Value.GetString() ?? string.Empty
|
||||
: pair.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Globalization;
|
||||
using StandardProducts.Core.Data;
|
||||
using StandardProducts.Core.Models;
|
||||
|
||||
namespace StandardProducts.Core.Gost;
|
||||
|
||||
/// <summary>Размер болта с шестигранной головкой по ГОСТ 7805-70.</summary>
|
||||
public sealed record BoltSize(double D, double P, double S, double K, double E, double? B = null)
|
||||
{
|
||||
public string Label => "M" + D.ToString("0", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Параметрическое семейство болтов ГОСТ 7805-70 (класс точности А).</summary>
|
||||
public static class Gost7805
|
||||
{
|
||||
private static readonly BoltSize[] OrderedSizes =
|
||||
[
|
||||
new BoltSize(D: 6, P: 1.0, S: 10, K: 4.0, E: 11.05),
|
||||
new BoltSize(D: 8, P: 1.25, S: 13, K: 5.3, E: 14.38),
|
||||
new BoltSize(D: 10, P: 1.5, S: 16, K: 6.4, E: 17.77),
|
||||
new BoltSize(D: 12, P: 1.75, S: 18, K: 7.5, E: 19.85),
|
||||
new BoltSize(D: 16, P: 2.0, S: 24, K: 10.0, E: 26.75),
|
||||
];
|
||||
|
||||
public static IReadOnlyDictionary<string, BoltSize> Sizes { get; } =
|
||||
OrderedSizes.ToDictionary(size => size.Label, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static IReadOnlyList<int> AllowedLengthsMm { get; } = [20, 25, 30, 35, 40, 50, 60, 70, 80];
|
||||
|
||||
public static BoltSize GetSize(string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
throw new ArgumentException("Размер не задан", nameof(label));
|
||||
}
|
||||
|
||||
if (!Sizes.TryGetValue(label.Trim(), out var size))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Неизвестный размер «{label.Trim()}». Допустимо: {string.Join(", ", Sizes.Keys)}",
|
||||
nameof(label));
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
public static void ValidateLength(int length)
|
||||
{
|
||||
if (!AllowedLengthsMm.Contains(length))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(length),
|
||||
length,
|
||||
$"Недопустимая длина. Допустимо: {string.Join(", ", AllowedLengthsMm)} мм");
|
||||
}
|
||||
}
|
||||
|
||||
public static string Designation(BoltSize size, int length)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(size);
|
||||
ValidateLength(length);
|
||||
return $"Болт {size.Label}x{length} ГОСТ 7805-70";
|
||||
}
|
||||
|
||||
public static Product BuildProduct(BoltSize size, int length)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(size);
|
||||
ValidateLength(length);
|
||||
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["диаметр"] = Format(size.D),
|
||||
["шаг_резьбы"] = Format(size.P),
|
||||
["размер_под_ключ"] = Format(size.S),
|
||||
["высота_головки"] = Format(size.K),
|
||||
["размер_по_углам"] = Format(size.E),
|
||||
["длина"] = length.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
if (size.B is double threadLength)
|
||||
{
|
||||
attributes["длина_резьбы"] = Format(threadLength);
|
||||
}
|
||||
|
||||
return new Product
|
||||
{
|
||||
Designation = Designation(size, length),
|
||||
Name = "Болт с шестигранной головкой",
|
||||
Standard = "ГОСТ 7805-70",
|
||||
Category = "Крепёж",
|
||||
Material = null,
|
||||
Unit = "шт",
|
||||
MassKg = null,
|
||||
Attributes = attributes,
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<Product> EnumerateProducts()
|
||||
{
|
||||
foreach (var size in OrderedSizes)
|
||||
{
|
||||
foreach (var length in AllowedLengthsMm)
|
||||
{
|
||||
yield return BuildProduct(size, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Загрузить всё семейство M×L в каталог. Идемпотентно.</summary>
|
||||
public static int Seed(ProductRepository repository)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var count = 0;
|
||||
foreach (var product in EnumerateProducts())
|
||||
{
|
||||
repository.Upsert(product);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string Format(double value) =>
|
||||
value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using StandardProducts.Core.Data;
|
||||
using StandardProducts.Core.Models;
|
||||
|
||||
namespace StandardProducts.Core.IO;
|
||||
|
||||
/// <summary>Импорт и экспорт каталога в JSON, совместимый с Python-версией.</summary>
|
||||
public static class JsonCatalog
|
||||
{
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
public static int Import(ProductRepository repository, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var root = document.RootElement;
|
||||
|
||||
JsonElement items;
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
items = root;
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("products", out var products))
|
||||
{
|
||||
items = products;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Ожидается массив изделий или объект с полем \"products\"");
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var element in items.EnumerateArray())
|
||||
{
|
||||
var item = element.Deserialize<ImportItem>(ReadOptions)
|
||||
?? throw new InvalidDataException("Пустая запись в JSON");
|
||||
repository.Upsert(item.ToProduct());
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static int Export(IEnumerable<Product> products, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(products);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var list = products.Select(ExportItem.From).ToList();
|
||||
var destination = Path.GetFullPath(path);
|
||||
var directory = Path.GetDirectoryName(destination);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var payload = new { products = list };
|
||||
File.WriteAllText(
|
||||
destination,
|
||||
JsonSerializer.Serialize(payload, WriteOptions) + Environment.NewLine);
|
||||
|
||||
return list.Count;
|
||||
}
|
||||
|
||||
private sealed record ImportItem
|
||||
{
|
||||
public string? Designation { get; init; }
|
||||
|
||||
public string? Name { get; init; }
|
||||
|
||||
public string? Standard { get; init; }
|
||||
|
||||
public string? Category { get; init; }
|
||||
|
||||
public string? Material { get; init; }
|
||||
|
||||
public string? Unit { get; init; }
|
||||
|
||||
public double? MassKg { get; init; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Attributes { get; init; }
|
||||
|
||||
public Product ToProduct()
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (Attributes is not null)
|
||||
{
|
||||
foreach (var (key, value) in Attributes)
|
||||
{
|
||||
attributes[key] = value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() ?? string.Empty
|
||||
: value.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
return new Product
|
||||
{
|
||||
Designation = Designation?.Trim() ?? string.Empty,
|
||||
Name = Name?.Trim() ?? string.Empty,
|
||||
Standard = Standard,
|
||||
Category = Category,
|
||||
Material = Material,
|
||||
Unit = string.IsNullOrWhiteSpace(Unit) ? "шт" : Unit,
|
||||
MassKg = MassKg,
|
||||
Attributes = attributes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ExportItem
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public string Designation { get; init; } = string.Empty;
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string? Standard { get; init; }
|
||||
|
||||
public string? Category { get; init; }
|
||||
|
||||
public string? Material { get; init; }
|
||||
|
||||
public string Unit { get; init; } = "шт";
|
||||
|
||||
public double? MassKg { get; init; }
|
||||
|
||||
public Dictionary<string, string> Attributes { get; init; } = new();
|
||||
|
||||
public static ExportItem From(Product product) => new()
|
||||
{
|
||||
Id = product.Id,
|
||||
Designation = product.Designation,
|
||||
Name = product.Name,
|
||||
Standard = product.Standard,
|
||||
Category = product.Category,
|
||||
Material = product.Material,
|
||||
Unit = product.Unit,
|
||||
MassKg = product.MassKg,
|
||||
Attributes = product.Attributes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StandardProducts.Core.Models;
|
||||
|
||||
/// <summary>Стандартное изделие каталога.</summary>
|
||||
public sealed record Product
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>Обозначение по стандарту, например «Болт M10x40 ГОСТ 7805-70».</summary>
|
||||
public string Designation { get; init; } = string.Empty;
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string? Standard { get; init; }
|
||||
|
||||
public string? Category { get; init; }
|
||||
|
||||
public string? Material { get; init; }
|
||||
|
||||
public string Unit { get; init; } = "шт";
|
||||
|
||||
public double? MassKg { get; init; }
|
||||
|
||||
public Dictionary<string, string> Attributes { get; init; } = new();
|
||||
|
||||
public string? CreatedAt { get; init; }
|
||||
|
||||
public string? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StandardProducts.Core.Models;
|
||||
|
||||
public static class ProductValidator
|
||||
{
|
||||
public static void Validate(Product product)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(product);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(product.Designation))
|
||||
{
|
||||
throw new ArgumentException("Поле designation обязательно", nameof(product));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(product.Name))
|
||||
{
|
||||
throw new ArgumentException("Поле name обязательно", nameof(product));
|
||||
}
|
||||
|
||||
if (product.MassKg is < 0)
|
||||
{
|
||||
throw new ArgumentException("mass_kg не может быть отрицательной", nameof(product));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>StandardProducts.Core</RootNamespace>
|
||||
<AssemblyName>StandardProducts.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.33" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
using StandardProducts.Core.Data;
|
||||
using StandardProducts.Core.Gost;
|
||||
|
||||
namespace StandardProducts.Core.Tests;
|
||||
|
||||
public sealed class Gost7805Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ContainsExactlyFiveSizes()
|
||||
{
|
||||
Assert.Equal(5, Gost7805.Sizes.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("M6", 6, 1.0, 10, 4.0, 11.05)]
|
||||
[InlineData("M8", 8, 1.25, 13, 5.3, 14.38)]
|
||||
[InlineData("M10", 10, 1.5, 16, 6.4, 17.77)]
|
||||
[InlineData("M12", 12, 1.75, 18, 7.5, 19.85)]
|
||||
[InlineData("M16", 16, 2.0, 24, 10.0, 26.75)]
|
||||
public void SizeDataMatchesGost(string label, double d, double p, double s, double k, double e)
|
||||
{
|
||||
var size = Gost7805.GetSize(label);
|
||||
|
||||
Assert.Equal(d, size.D, 3);
|
||||
Assert.Equal(p, size.P, 3);
|
||||
Assert.Equal(s, size.S, 3);
|
||||
Assert.Equal(k, size.K, 3);
|
||||
Assert.Equal(e, size.E, 3);
|
||||
Assert.Null(size.B);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesignationUsesIntegerDiameter()
|
||||
{
|
||||
var size = Gost7805.GetSize("M10");
|
||||
|
||||
Assert.Equal("Болт M10x40 ГОСТ 7805-70", Gost7805.Designation(size, 40));
|
||||
Assert.DoesNotContain("M10.0", Gost7805.Designation(size, 40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownSizeIsRejected()
|
||||
{
|
||||
var error = Assert.Throws<ArgumentException>(() => Gost7805.GetSize("M7"));
|
||||
Assert.Contains("M6", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidLengthIsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Gost7805.ValidateLength(45));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProductFillsGostAttributes()
|
||||
{
|
||||
var product = Gost7805.BuildProduct(Gost7805.GetSize("M16"), 80);
|
||||
|
||||
Assert.Equal("Болт M16x80 ГОСТ 7805-70", product.Designation);
|
||||
Assert.Equal("Болт с шестигранной головкой", product.Name);
|
||||
Assert.Equal("ГОСТ 7805-70", product.Standard);
|
||||
Assert.Equal("Крепёж", product.Category);
|
||||
Assert.Equal("шт", product.Unit);
|
||||
Assert.Null(product.MassKg);
|
||||
Assert.Null(product.Material);
|
||||
|
||||
Assert.Equal("16", product.Attributes["диаметр"]);
|
||||
Assert.Equal("2", product.Attributes["шаг_резьбы"]);
|
||||
Assert.Equal("24", product.Attributes["размер_под_ключ"]);
|
||||
Assert.Equal("10", product.Attributes["высота_головки"]);
|
||||
Assert.Equal("26.75", product.Attributes["размер_по_углам"]);
|
||||
Assert.Equal("80", product.Attributes["длина"]);
|
||||
Assert.False(product.Attributes.ContainsKey("длина_резьбы"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedIsIdempotentAndCreatesFortyFiveProducts()
|
||||
{
|
||||
var path = TestDatabase.NewPath();
|
||||
using var database = new CatalogDatabase(path);
|
||||
var repository = new ProductRepository(database);
|
||||
|
||||
Assert.Equal(45, Gost7805.Seed(repository));
|
||||
Assert.Equal(45, repository.Count());
|
||||
|
||||
Assert.Equal(45, Gost7805.Seed(repository));
|
||||
Assert.Equal(45, repository.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeededProductsAreSearchableByStandard()
|
||||
{
|
||||
var path = TestDatabase.NewPath();
|
||||
using var database = new CatalogDatabase(path);
|
||||
var repository = new ProductRepository(database);
|
||||
Gost7805.Seed(repository);
|
||||
|
||||
var found = repository.Search("ГОСТ 7805-70", limit: 100);
|
||||
|
||||
Assert.Equal(45, found.Count);
|
||||
Assert.All(found, product => Assert.Equal("ГОСТ 7805-70", product.Standard));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using StandardProducts.Core.Data;
|
||||
using StandardProducts.Core.IO;
|
||||
using StandardProducts.Core.Models;
|
||||
|
||||
namespace StandardProducts.Core.Tests;
|
||||
|
||||
public sealed class ProductRepositoryTests
|
||||
{
|
||||
private static Product CreateProduct() => new()
|
||||
{
|
||||
Designation = "Болт M10x50 ГОСТ 7798-70",
|
||||
Name = "Болт с шестигранной головкой",
|
||||
Standard = "ГОСТ 7798-70",
|
||||
Category = "Крепёж",
|
||||
Material = "Сталь 20",
|
||||
MassKg = 0.045,
|
||||
Attributes = new Dictionary<string, string>
|
||||
{
|
||||
["резьба"] = "M10",
|
||||
["покрытие"] = "цинк",
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AddAndGetProduct()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
|
||||
var id = repository.Add(CreateProduct());
|
||||
|
||||
Assert.True(id > 0);
|
||||
var stored = repository.Get("Болт M10x50 ГОСТ 7798-70");
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("Болт с шестигранной головкой", stored.Name);
|
||||
Assert.Equal(0.045, stored.MassKg!.Value, 3);
|
||||
Assert.Equal("M10", stored.Attributes["резьба"]);
|
||||
Assert.Equal("цинк", stored.Attributes["покрытие"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateDesignationIsRejected()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
repository.Add(CreateProduct());
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(
|
||||
() => repository.Add(CreateProduct() with { Name = "Другой болт" }));
|
||||
|
||||
Assert.Contains("уже существует", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationRejectsEmptyFieldsAndNegativeMass()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => repository.Add(CreateProduct() with { Designation = " " }));
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => repository.Add(CreateProduct() with { Name = " " }));
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => repository.Add(CreateProduct() with { MassKg = -1 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertUpdatesExistingRowAndKeepsId()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
|
||||
var id = repository.Add(CreateProduct());
|
||||
var upsertedId = repository.Upsert(CreateProduct() with { Name = "Болт обновлённый" });
|
||||
|
||||
Assert.Equal(id, upsertedId);
|
||||
Assert.Equal(1, repository.Count());
|
||||
Assert.Equal("Болт обновлённый", repository.Get("Болт M10x50 ГОСТ 7798-70")!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchIsCaseInsensitiveForCyrillic()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
repository.Add(CreateProduct());
|
||||
repository.Add(CreateProduct() with
|
||||
{
|
||||
Designation = "Гайка M10 ГОСТ 5915-70",
|
||||
Name = "Гайка шестигранная",
|
||||
Standard = "ГОСТ 5915-70",
|
||||
Material = "Сталь 10",
|
||||
Attributes = new Dictionary<string, string> { ["резьба"] = "M10" },
|
||||
});
|
||||
|
||||
var byText = repository.Search("болт");
|
||||
Assert.Single(byText);
|
||||
Assert.Equal("Болт M10x50 ГОСТ 7798-70", byText[0].Designation);
|
||||
|
||||
var byAttribute = repository.Search("ЦИНК");
|
||||
Assert.Single(byAttribute);
|
||||
|
||||
var byStandard = repository.Search(standard: "ГОСТ 5915-70");
|
||||
Assert.Single(byStandard);
|
||||
|
||||
var byCategory = repository.Search(category: "Крепёж", limit: 1);
|
||||
Assert.Single(byCategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListRespectsLimitAndDeleteRemovesProduct()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
for (var index = 0; index < 3; index++)
|
||||
{
|
||||
repository.Add(CreateProduct() with { Designation = $"Изделие-{index}", Name = $"Изделие {index}" });
|
||||
}
|
||||
|
||||
Assert.Equal(2, repository.List(limit: 2).Count);
|
||||
Assert.Equal(3, repository.List().Count);
|
||||
|
||||
Assert.True(repository.Delete("Изделие-1"));
|
||||
Assert.False(repository.Delete("Изделие-1"));
|
||||
Assert.Equal(2, repository.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonExportAndImportRoundTrip()
|
||||
{
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
repository.Add(CreateProduct());
|
||||
|
||||
var jsonPath = Path.Combine(
|
||||
Path.GetTempPath(), "standard-products-tests", Guid.NewGuid().ToString("N") + ".json");
|
||||
Assert.Equal(1, JsonCatalog.Export(repository.List(), jsonPath));
|
||||
Assert.Contains("\"mass_kg\"", File.ReadAllText(jsonPath));
|
||||
|
||||
using var secondDatabase = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var secondRepository = new ProductRepository(secondDatabase);
|
||||
Assert.Equal(1, JsonCatalog.Import(secondRepository, jsonPath));
|
||||
|
||||
var imported = secondRepository.Get("Болт M10x50 ГОСТ 7798-70");
|
||||
Assert.NotNull(imported);
|
||||
Assert.Equal("M10", imported.Attributes["резьба"]);
|
||||
Assert.Equal(0.045, imported.MassKg!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonImportReadsSnakeCaseAndArrayRoot()
|
||||
{
|
||||
var jsonPath = Path.Combine(
|
||||
Path.GetTempPath(), "standard-products-tests", Guid.NewGuid().ToString("N") + ".json");
|
||||
File.WriteAllText(
|
||||
jsonPath,
|
||||
"""
|
||||
[
|
||||
{
|
||||
"designation": "Гайка M10 ГОСТ 5915-70",
|
||||
"name": "Гайка шестигранная",
|
||||
"mass_kg": 0.012,
|
||||
"attributes": { "резьба": "M10" }
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
using var database = new CatalogDatabase(TestDatabase.NewPath());
|
||||
var repository = new ProductRepository(database);
|
||||
|
||||
Assert.Equal(1, JsonCatalog.Import(repository, jsonPath));
|
||||
var imported = repository.Get("Гайка M10 ГОСТ 5915-70");
|
||||
Assert.NotNull(imported);
|
||||
Assert.Equal(0.012, imported.MassKg!.Value, 3);
|
||||
Assert.Equal("M10", imported.Attributes["резьба"]);
|
||||
Assert.Equal("шт", imported.Unit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\StandardProducts.Core\StandardProducts.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
using StandardProducts.Core.Data;
|
||||
|
||||
namespace StandardProducts.Core.Tests;
|
||||
|
||||
internal static class TestDatabase
|
||||
{
|
||||
public static string NewPath() =>
|
||||
Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"standard-products-tests",
|
||||
Guid.NewGuid().ToString("N") + ".db");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"products": [
|
||||
{
|
||||
"designation": "Болт M10x50 ГОСТ 7798-70",
|
||||
"name": "Болт с шестигранной головкой",
|
||||
"standard": "ГОСТ 7798-70",
|
||||
"category": "Крепёж",
|
||||
"material": "Сталь 20",
|
||||
"unit": "шт",
|
||||
"mass_kg": 0.045,
|
||||
"attributes": {
|
||||
"резьба": "M10",
|
||||
"длина": "50",
|
||||
"покрытие": "цинк"
|
||||
}
|
||||
},
|
||||
{
|
||||
"designation": "Гайка M10 ГОСТ 5915-70",
|
||||
"name": "Гайка шестигранная",
|
||||
"standard": "ГОСТ 5915-70",
|
||||
"category": "Крепёж",
|
||||
"material": "Сталь 10",
|
||||
"unit": "шт",
|
||||
"mass_kg": 0.012,
|
||||
"attributes": {
|
||||
"резьба": "M10",
|
||||
"покрытие": "цинк"
|
||||
}
|
||||
},
|
||||
{
|
||||
"designation": "Шайба 10 ГОСТ 11371-78",
|
||||
"name": "Шайба плоская",
|
||||
"standard": "ГОСТ 11371-78",
|
||||
"category": "Крепёж",
|
||||
"material": "Сталь 08кп",
|
||||
"unit": "шт",
|
||||
"mass_kg": 0.004,
|
||||
"attributes": {
|
||||
"диаметр": "10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"designation": "Подшипник 6205 ГОСТ 8338-75",
|
||||
"name": "Подшипник шариковый радиальный однорядный",
|
||||
"standard": "ГОСТ 8338-75",
|
||||
"category": "Подшипники",
|
||||
"material": "ШХ15",
|
||||
"unit": "шт",
|
||||
"mass_kg": 0.13,
|
||||
"attributes": {
|
||||
"внутренний_диаметр": "25",
|
||||
"наружный_диаметр": "52",
|
||||
"ширина": "15"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "standard-products"
|
||||
version = "0.1.0"
|
||||
description = "Библиотека стандартных изделий (ГОСТ/ISO/DIN) на SQLite"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.5",
|
||||
"mypy>=1.10",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
stdparts = "standard_products.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
files = ["src"]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Библиотека стандартных изделий: каталог на SQLite."""
|
||||
|
||||
from .db import (
|
||||
DEFAULT_DB_PATH,
|
||||
Product,
|
||||
add_product,
|
||||
connect,
|
||||
count_products,
|
||||
delete_product,
|
||||
export_json,
|
||||
get_product,
|
||||
import_json,
|
||||
init_db,
|
||||
list_products,
|
||||
search_products,
|
||||
update_product,
|
||||
upsert_product,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_DB_PATH",
|
||||
"Product",
|
||||
"add_product",
|
||||
"connect",
|
||||
"count_products",
|
||||
"delete_product",
|
||||
"export_json",
|
||||
"get_product",
|
||||
"import_json",
|
||||
"init_db",
|
||||
"list_products",
|
||||
"search_products",
|
||||
"update_product",
|
||||
"upsert_product",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,196 @@
|
||||
"""CLI библиотеки стандартных изделий."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .db import (
|
||||
DEFAULT_DB_PATH,
|
||||
Product,
|
||||
add_product,
|
||||
connect,
|
||||
count_products,
|
||||
delete_product,
|
||||
export_json,
|
||||
get_product,
|
||||
import_json,
|
||||
init_db,
|
||||
list_products,
|
||||
search_products,
|
||||
)
|
||||
|
||||
|
||||
def _parse_attributes(values: list[str]) -> dict[str, str]:
|
||||
attributes: dict[str, str] = {}
|
||||
for value in values:
|
||||
key, separator, raw = value.partition("=")
|
||||
if not separator or not key.strip():
|
||||
raise SystemExit(f"Некорректный атрибут {value!r}: ожидается КЛЮЧ=ЗНАЧЕНИЕ")
|
||||
attributes[key.strip()] = raw.strip()
|
||||
return attributes
|
||||
|
||||
|
||||
def _print_table(products: list[Product]) -> None:
|
||||
if not products:
|
||||
print("Ничего не найдено")
|
||||
return
|
||||
header = f"{'ОБОЗНАЧЕНИЕ':<40} {'НАИМЕНОВАНИЕ':<40} {'КАТЕГОРИЯ':<18} {'МАССА, КГ':>10}"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for product in products:
|
||||
mass = "" if product.mass_kg is None else f"{product.mass_kg:g}"
|
||||
print(
|
||||
f"{product.designation[:40]:<40} "
|
||||
f"{product.name[:40]:<40} "
|
||||
f"{(product.category or '')[:18]:<18} "
|
||||
f"{mass:>10}"
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="stdparts",
|
||||
description="Каталог стандартных изделий на SQLite",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=str(DEFAULT_DB_PATH),
|
||||
help=f"путь к базе (по умолчанию {DEFAULT_DB_PATH})",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("init", help="создать базу и схему")
|
||||
|
||||
add = subparsers.add_parser("add", help="добавить изделие")
|
||||
add.add_argument("designation", help="обозначение по стандарту")
|
||||
add.add_argument("--name", required=True, help="наименование")
|
||||
add.add_argument("--standard", help="стандарт (ГОСТ, ISO, DIN...)")
|
||||
add.add_argument("--category", help="категория (крепёж, подшипники...)")
|
||||
add.add_argument("--material", help="материал")
|
||||
add.add_argument("--unit", default="шт", help="единица измерения (по умолчанию шт)")
|
||||
add.add_argument("--mass", type=float, dest="mass_kg", help="масса, кг")
|
||||
add.add_argument(
|
||||
"--attr",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="КЛЮЧ=ЗНАЧЕНИЕ",
|
||||
help="дополнительный атрибут, можно указывать несколько раз",
|
||||
)
|
||||
|
||||
show = subparsers.add_parser("show", help="показать изделие")
|
||||
show.add_argument("designation")
|
||||
|
||||
delete = subparsers.add_parser("delete", help="удалить изделие")
|
||||
delete.add_argument("designation")
|
||||
|
||||
listing = subparsers.add_parser("list", help="список изделий")
|
||||
listing.add_argument("--limit", type=int, default=None)
|
||||
|
||||
search = subparsers.add_parser("search", help="поиск изделий")
|
||||
search.add_argument("query", nargs="?", default=None)
|
||||
search.add_argument("--standard")
|
||||
search.add_argument("--category")
|
||||
search.add_argument("--material")
|
||||
search.add_argument("--limit", type=int, default=50)
|
||||
|
||||
import_cmd = subparsers.add_parser("import", help="импорт из JSON")
|
||||
import_cmd.add_argument("path")
|
||||
|
||||
export_cmd = subparsers.add_parser("export", help="экспорт в JSON")
|
||||
export_cmd.add_argument("path")
|
||||
|
||||
subparsers.add_parser("stats", help="статистика каталога")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
db_path = Path(args.db)
|
||||
|
||||
if args.command == "init":
|
||||
created = init_db(db_path)
|
||||
print(f"База готова: {created}")
|
||||
return 0
|
||||
|
||||
if not db_path.exists():
|
||||
init_db(db_path)
|
||||
|
||||
with connect(db_path) as connection:
|
||||
if args.command == "add":
|
||||
attributes: dict[str, Any] = _parse_attributes(args.attr)
|
||||
new_product = Product(
|
||||
designation=args.designation,
|
||||
name=args.name,
|
||||
standard=args.standard,
|
||||
category=args.category,
|
||||
material=args.material,
|
||||
unit=args.unit,
|
||||
mass_kg=args.mass_kg,
|
||||
attributes=attributes,
|
||||
)
|
||||
try:
|
||||
product_id = add_product(connection, new_product)
|
||||
except ValueError as error:
|
||||
print(f"Ошибка: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Добавлено изделие #{product_id}: {new_product.designation}")
|
||||
return 0
|
||||
|
||||
if args.command == "show":
|
||||
product = get_product(connection, args.designation)
|
||||
if product is None:
|
||||
print(f"Изделие {args.designation!r} не найдено", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(product.to_dict(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
if args.command == "delete":
|
||||
if delete_product(connection, args.designation):
|
||||
print(f"Удалено: {args.designation}")
|
||||
return 0
|
||||
print(f"Изделие {args.designation!r} не найдено", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.command == "list":
|
||||
_print_table(list_products(connection, limit=args.limit))
|
||||
return 0
|
||||
|
||||
if args.command == "search":
|
||||
_print_table(
|
||||
search_products(
|
||||
connection,
|
||||
args.query,
|
||||
standard=args.standard,
|
||||
category=args.category,
|
||||
material=args.material,
|
||||
limit=args.limit,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "import":
|
||||
imported = import_json(connection, args.path)
|
||||
print(f"Импортировано записей: {imported}")
|
||||
return 0
|
||||
|
||||
if args.command == "export":
|
||||
exported = export_json(connection, args.path)
|
||||
print(f"Экспортировано записей: {exported}")
|
||||
return 0
|
||||
|
||||
if args.command == "stats":
|
||||
print(f"Всего изделий: {count_products(connection)}")
|
||||
return 0
|
||||
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Хранилище каталога стандартных изделий на SQLite.
|
||||
|
||||
Модуль не зависит от внешних библиотек и работает на стандартной
|
||||
библиотеке Python 3.10+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_DB_PATH = Path("standard_products.db")
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
designation TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
standard TEXT,
|
||||
category TEXT,
|
||||
material TEXT,
|
||||
unit TEXT NOT NULL DEFAULT 'шт',
|
||||
mass_kg REAL,
|
||||
attributes TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_standard ON products(standard);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_material ON products(material);
|
||||
"""
|
||||
|
||||
PRODUCT_COLUMNS = (
|
||||
"designation",
|
||||
"name",
|
||||
"standard",
|
||||
"category",
|
||||
"material",
|
||||
"unit",
|
||||
"mass_kg",
|
||||
"attributes",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Product:
|
||||
"""Стандартное изделие.
|
||||
|
||||
designation — обозначение по стандарту (например, «Болт M10x50 ГОСТ 7798-70»),
|
||||
attributes — произвольные характеристики (резьба, покрытие, допуск и т. п.).
|
||||
"""
|
||||
|
||||
designation: str
|
||||
name: str
|
||||
standard: str | None = None
|
||||
category: str | None = None
|
||||
material: str | None = None
|
||||
unit: str = "шт"
|
||||
mass_kg: float | None = None
|
||||
attributes: dict[str, Any] = field(default_factory=dict)
|
||||
id: int | None = None
|
||||
|
||||
def to_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"designation": self.designation,
|
||||
"name": self.name,
|
||||
"standard": self.standard,
|
||||
"category": self.category,
|
||||
"material": self.material,
|
||||
"unit": self.unit,
|
||||
"mass_kg": self.mass_kg,
|
||||
"attributes": json.dumps(self.attributes, ensure_ascii=False, sort_keys=True),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: sqlite3.Row) -> Product:
|
||||
raw_attributes = row["attributes"] or "{}"
|
||||
return cls(
|
||||
id=row["id"],
|
||||
designation=row["designation"],
|
||||
name=row["name"],
|
||||
standard=row["standard"],
|
||||
category=row["category"],
|
||||
material=row["material"],
|
||||
unit=row["unit"],
|
||||
mass_kg=row["mass_kg"],
|
||||
attributes=json.loads(raw_attributes),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"designation": self.designation,
|
||||
"name": self.name,
|
||||
"standard": self.standard,
|
||||
"category": self.category,
|
||||
"material": self.material,
|
||||
"unit": self.unit,
|
||||
"mass_kg": self.mass_kg,
|
||||
"attributes": self.attributes,
|
||||
}
|
||||
|
||||
|
||||
def _ulower(value: Any) -> Any:
|
||||
return value.lower() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def connect(path: str | Path = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
||||
"""Открыть соединение с базой и включить внешние ключи."""
|
||||
connection = sqlite3.connect(str(path))
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.create_function("ulower", 1, _ulower)
|
||||
return connection
|
||||
|
||||
|
||||
def init_db(path: str | Path = DEFAULT_DB_PATH) -> Path:
|
||||
"""Создать файл базы и схему, если их ещё нет. Возвращает путь к базе."""
|
||||
db_path = Path(path)
|
||||
if db_path.parent and not db_path.parent.exists():
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with connect(db_path) as connection:
|
||||
connection.executescript(SCHEMA)
|
||||
return db_path
|
||||
|
||||
|
||||
def _validate(product: Product) -> None:
|
||||
if not product.designation or not product.designation.strip():
|
||||
raise ValueError("Поле designation обязательно")
|
||||
if not product.name or not product.name.strip():
|
||||
raise ValueError("Поле name обязательно")
|
||||
if product.mass_kg is not None and product.mass_kg < 0:
|
||||
raise ValueError("mass_kg не может быть отрицательной")
|
||||
|
||||
|
||||
def add_product(connection: sqlite3.Connection, product: Product) -> int:
|
||||
"""Добавить изделие. Бросает ValueError, если обозначение уже занято."""
|
||||
_validate(product)
|
||||
row = product.to_row()
|
||||
placeholders = ", ".join(f":{column}" for column in PRODUCT_COLUMNS)
|
||||
columns = ", ".join(PRODUCT_COLUMNS)
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
f"INSERT INTO products ({columns}) VALUES ({placeholders})", row
|
||||
)
|
||||
except sqlite3.IntegrityError as error:
|
||||
raise ValueError(
|
||||
f"Изделие с обозначением {product.designation!r} уже существует"
|
||||
) from error
|
||||
connection.commit()
|
||||
if cursor.lastrowid is None:
|
||||
raise RuntimeError("SQLite не вернул идентификатор вставленной строки")
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def upsert_product(connection: sqlite3.Connection, product: Product) -> int:
|
||||
"""Добавить изделие или обновить существующее по обозначению."""
|
||||
_validate(product)
|
||||
row = product.to_row()
|
||||
placeholders = ", ".join(f":{column}" for column in PRODUCT_COLUMNS)
|
||||
columns = ", ".join(PRODUCT_COLUMNS)
|
||||
updates = ", ".join(
|
||||
f"{column} = excluded.{column}" for column in PRODUCT_COLUMNS if column != "designation"
|
||||
)
|
||||
connection.execute(
|
||||
f"""
|
||||
INSERT INTO products ({columns}) VALUES ({placeholders})
|
||||
ON CONFLICT(designation) DO UPDATE SET
|
||||
{updates},
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
row,
|
||||
)
|
||||
connection.commit()
|
||||
found = connection.execute(
|
||||
"SELECT id FROM products WHERE designation = ?", (product.designation,)
|
||||
).fetchone()
|
||||
if found is None:
|
||||
raise RuntimeError("Не удалось прочитать изделие после сохранения")
|
||||
return int(found["id"])
|
||||
|
||||
|
||||
def get_product(connection: sqlite3.Connection, designation: str) -> Product | None:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM products WHERE designation = ?", (designation,)
|
||||
).fetchone()
|
||||
return Product.from_row(row) if row is not None else None
|
||||
|
||||
|
||||
def update_product(
|
||||
connection: sqlite3.Connection, designation: str, **fields: Any
|
||||
) -> bool:
|
||||
"""Обновить поля изделия. Возвращает True, если запись найдена."""
|
||||
allowed = {
|
||||
"name",
|
||||
"standard",
|
||||
"category",
|
||||
"material",
|
||||
"unit",
|
||||
"mass_kg",
|
||||
"attributes",
|
||||
}
|
||||
unknown = set(fields) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"Неизвестные поля: {', '.join(sorted(unknown))}")
|
||||
if not fields:
|
||||
return get_product(connection, designation) is not None
|
||||
|
||||
values = dict(fields)
|
||||
if "attributes" in values and not isinstance(values["attributes"], str):
|
||||
values["attributes"] = json.dumps(
|
||||
values["attributes"], ensure_ascii=False, sort_keys=True
|
||||
)
|
||||
|
||||
assignments = ", ".join(f"{column} = :{column}" for column in values)
|
||||
values["designation"] = designation
|
||||
cursor = connection.execute(
|
||||
f"UPDATE products SET {assignments}, updated_at = datetime('now') "
|
||||
"WHERE designation = :designation",
|
||||
values,
|
||||
)
|
||||
connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def delete_product(connection: sqlite3.Connection, designation: str) -> bool:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM products WHERE designation = ?", (designation,)
|
||||
)
|
||||
connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def list_products(
|
||||
connection: sqlite3.Connection,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Product]:
|
||||
sql = "SELECT * FROM products ORDER BY designation"
|
||||
params: list[Any] = []
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
return [Product.from_row(row) for row in connection.execute(sql, params)]
|
||||
|
||||
|
||||
def search_products(
|
||||
connection: sqlite3.Connection,
|
||||
query: str | None = None,
|
||||
*,
|
||||
standard: str | None = None,
|
||||
category: str | None = None,
|
||||
material: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[Product]:
|
||||
"""Поиск по обозначению/названию/атрибутам и точным фильтрам."""
|
||||
conditions: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if query:
|
||||
like = f"%{query.lower()}%"
|
||||
conditions.append(
|
||||
"(ulower(designation) LIKE ? OR ulower(name) LIKE ? "
|
||||
"OR ulower(attributes) LIKE ? OR ulower(standard) LIKE ?)"
|
||||
)
|
||||
params.extend([like, like, like, like])
|
||||
for column, value in (
|
||||
("standard", standard),
|
||||
("category", category),
|
||||
("material", material),
|
||||
):
|
||||
if value:
|
||||
conditions.append(f"{column} = ?")
|
||||
params.append(value)
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.append(limit)
|
||||
sql = f"SELECT * FROM products {where} ORDER BY designation LIMIT ?"
|
||||
return [Product.from_row(row) for row in connection.execute(sql, params)]
|
||||
|
||||
|
||||
def count_products(connection: sqlite3.Connection) -> int:
|
||||
row = connection.execute("SELECT COUNT(*) AS total FROM products").fetchone()
|
||||
return int(row["total"]) if row is not None else 0
|
||||
|
||||
|
||||
def import_json(
|
||||
connection: sqlite3.Connection, source: str | Path | Iterable[dict[str, Any]]
|
||||
) -> int:
|
||||
"""Импортировать изделия из JSON-файла (список или {"products": [...]})."""
|
||||
if isinstance(source, (str, Path)):
|
||||
payload = json.loads(Path(source).read_text(encoding="utf-8"))
|
||||
else:
|
||||
payload = list(source)
|
||||
|
||||
if isinstance(payload, dict):
|
||||
payload = payload.get("products", [])
|
||||
|
||||
imported = 0
|
||||
for item in payload:
|
||||
item = dict(item)
|
||||
item.pop("id", None)
|
||||
upsert_product(connection, Product(**item))
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
def export_json(connection: sqlite3.Connection, destination: str | Path) -> int:
|
||||
"""Выгрузить весь каталог в JSON-файл. Возвращает число записей."""
|
||||
products = [product.to_dict() for product in list_products(connection)]
|
||||
path = Path(destination)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"products": products}, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return len(products)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from standard_products.cli import main
|
||||
|
||||
|
||||
def test_cli_full_cycle(tmp_path: Path, capsys) -> None:
|
||||
db = str(tmp_path / "cli.db")
|
||||
|
||||
assert main(["--db", db, "init"]) == 0
|
||||
assert "База готова" in capsys.readouterr().out
|
||||
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"--db",
|
||||
db,
|
||||
"add",
|
||||
"Болт M10x50 ГОСТ 7798-70",
|
||||
"--name",
|
||||
"Болт с шестигранной головкой",
|
||||
"--standard",
|
||||
"ГОСТ 7798-70",
|
||||
"--category",
|
||||
"Крепёж",
|
||||
"--mass",
|
||||
"0.045",
|
||||
"--attr",
|
||||
"резьба=M10",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert "Добавлено изделие" in capsys.readouterr().out
|
||||
|
||||
assert main(["--db", db, "show", "Болт M10x50 ГОСТ 7798-70"]) == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["attributes"] == {"резьба": "M10"}
|
||||
|
||||
assert main(["--db", db, "search", "болт"]) == 0
|
||||
assert "Болт" in capsys.readouterr().out
|
||||
|
||||
assert main(["--db", db, "stats"]) == 0
|
||||
assert "Всего изделий: 1" in capsys.readouterr().out
|
||||
|
||||
assert main(["--db", db, "delete", "Болт M10x50 ГОСТ 7798-70"]) == 0
|
||||
assert "Удалено" in capsys.readouterr().out
|
||||
|
||||
assert main(["--db", db, "show", "Болт M10x50 ГОСТ 7798-70"]) == 1
|
||||
|
||||
|
||||
def test_cli_rejects_duplicate(tmp_path: Path, capsys) -> None:
|
||||
db = str(tmp_path / "cli.db")
|
||||
args = ["--db", db, "add", "Гайка M10", "--name", "Гайка"]
|
||||
|
||||
assert main(args) == 0
|
||||
assert main(args) == 1
|
||||
assert "уже существует" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cli_rejects_malformed_attribute(tmp_path: Path) -> None:
|
||||
db = str(tmp_path / "cli.db")
|
||||
try:
|
||||
main(["--db", db, "add", "Гайка M10", "--name", "Гайка", "--attr", "без равно"])
|
||||
except SystemExit as error:
|
||||
assert "КЛЮЧ=ЗНАЧЕНИЕ" in str(error)
|
||||
else: # pragma: no cover
|
||||
raise AssertionError("ожидался SystemExit")
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from standard_products.db import (
|
||||
Product,
|
||||
add_product,
|
||||
connect,
|
||||
count_products,
|
||||
delete_product,
|
||||
export_json,
|
||||
get_product,
|
||||
import_json,
|
||||
init_db,
|
||||
list_products,
|
||||
search_products,
|
||||
update_product,
|
||||
upsert_product,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def connection(tmp_path: Path):
|
||||
db_path = init_db(tmp_path / "catalog.db")
|
||||
with connect(db_path) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def make_product(**overrides: object) -> Product:
|
||||
data: dict[str, object] = {
|
||||
"designation": "Болт M10x50 ГОСТ 7798-70",
|
||||
"name": "Болт с шестигранной головкой",
|
||||
"standard": "ГОСТ 7798-70",
|
||||
"category": "Крепёж",
|
||||
"material": "Сталь 20",
|
||||
"mass_kg": 0.045,
|
||||
"attributes": {"резьба": "M10", "покрытие": "цинк"},
|
||||
}
|
||||
data.update(overrides)
|
||||
return Product(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_init_db_creates_file(tmp_path: Path) -> None:
|
||||
db_path = init_db(tmp_path / "nested" / "catalog.db")
|
||||
assert db_path.exists()
|
||||
with connect(db_path) as conn:
|
||||
assert count_products(conn) == 0
|
||||
|
||||
|
||||
def test_add_and_get_product(connection) -> None:
|
||||
product_id = add_product(connection, make_product())
|
||||
assert product_id > 0
|
||||
|
||||
stored = get_product(connection, "Болт M10x50 ГОСТ 7798-70")
|
||||
assert stored is not None
|
||||
assert stored.name == "Болт с шестигранной головкой"
|
||||
assert stored.attributes == {"резьба": "M10", "покрытие": "цинк"}
|
||||
assert stored.mass_kg == pytest.approx(0.045)
|
||||
|
||||
|
||||
def test_duplicate_designation_is_rejected(connection) -> None:
|
||||
add_product(connection, make_product())
|
||||
with pytest.raises(ValueError, match="уже существует"):
|
||||
add_product(connection, make_product(name="Другой болт"))
|
||||
|
||||
|
||||
def test_upsert_updates_existing_row(connection) -> None:
|
||||
product_id = add_product(connection, make_product())
|
||||
updated_id = upsert_product(connection, make_product(name="Болт обновлённый"))
|
||||
|
||||
assert updated_id == product_id
|
||||
stored = get_product(connection, "Болт M10x50 ГОСТ 7798-70")
|
||||
assert stored is not None
|
||||
assert stored.name == "Болт обновлённый"
|
||||
assert count_products(connection) == 1
|
||||
|
||||
|
||||
def test_validation(connection) -> None:
|
||||
with pytest.raises(ValueError, match="designation"):
|
||||
add_product(connection, make_product(designation=" "))
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
add_product(connection, make_product(name=""))
|
||||
with pytest.raises(ValueError, match="mass_kg"):
|
||||
add_product(connection, make_product(mass_kg=-1))
|
||||
|
||||
|
||||
def test_search_by_query_and_filters(connection) -> None:
|
||||
add_product(connection, make_product())
|
||||
add_product(
|
||||
connection,
|
||||
make_product(
|
||||
designation="Гайка M10 ГОСТ 5915-70",
|
||||
name="Гайка шестигранная",
|
||||
standard="ГОСТ 5915-70",
|
||||
material="Сталь 10",
|
||||
attributes={},
|
||||
),
|
||||
)
|
||||
|
||||
by_text = search_products(connection, "гайка")
|
||||
assert [p.designation for p in by_text] == ["Гайка M10 ГОСТ 5915-70"]
|
||||
|
||||
by_attribute = search_products(connection, "цинк")
|
||||
assert [p.designation for p in by_attribute] == ["Болт M10x50 ГОСТ 7798-70"]
|
||||
|
||||
by_standard = search_products(connection, standard="ГОСТ 5915-70")
|
||||
assert len(by_standard) == 1
|
||||
|
||||
by_category = search_products(connection, category="Крепёж", limit=1)
|
||||
assert len(by_category) == 1
|
||||
|
||||
|
||||
def test_update_product(connection) -> None:
|
||||
add_product(connection, make_product())
|
||||
assert update_product(connection, "Болт M10x50 ГОСТ 7798-70", material="Сталь 35")
|
||||
|
||||
stored = get_product(connection, "Болт M10x50 ГОСТ 7798-70")
|
||||
assert stored is not None
|
||||
assert stored.material == "Сталь 35"
|
||||
assert update_product(connection, "нет такого", name="x") is False
|
||||
|
||||
|
||||
def test_update_rejects_unknown_fields(connection) -> None:
|
||||
add_product(connection, make_product())
|
||||
with pytest.raises(ValueError, match="Неизвестные поля"):
|
||||
update_product(connection, "Болт M10x50 ГОСТ 7798-70", diameter=10)
|
||||
|
||||
|
||||
def test_delete_product(connection) -> None:
|
||||
add_product(connection, make_product())
|
||||
assert delete_product(connection, "Болт M10x50 ГОСТ 7798-70") is True
|
||||
assert delete_product(connection, "Болт M10x50 ГОСТ 7798-70") is False
|
||||
assert count_products(connection) == 0
|
||||
|
||||
|
||||
def test_list_with_limit(connection) -> None:
|
||||
for index in range(3):
|
||||
add_product(connection, make_product(designation=f"Изделие-{index}"))
|
||||
assert len(list_products(connection, limit=2)) == 2
|
||||
assert len(list_products(connection)) == 3
|
||||
|
||||
|
||||
def test_import_export_roundtrip(connection, tmp_path: Path) -> None:
|
||||
add_product(connection, make_product())
|
||||
add_product(connection, make_product(designation="Шайба 10 ГОСТ 11371-78", name="Шайба"))
|
||||
|
||||
exported_path = tmp_path / "export.json"
|
||||
assert export_json(connection, exported_path) == 2
|
||||
|
||||
payload = json.loads(exported_path.read_text(encoding="utf-8"))
|
||||
assert len(payload["products"]) == 2
|
||||
|
||||
init_db(tmp_path / "other.db")
|
||||
with connect(tmp_path / "other.db") as other:
|
||||
assert import_json(other, exported_path) == 2
|
||||
assert count_products(other) == 2
|
||||
assert get_product(other, "Шайба 10 ГОСТ 11371-78") is not None
|
||||
|
||||
|
||||
def test_import_accepts_inline_list(connection) -> None:
|
||||
imported = import_json(
|
||||
connection,
|
||||
[{"designation": "Подшипник 6205", "name": "Подшипник шариковый"}],
|
||||
)
|
||||
assert imported == 1
|
||||
assert count_products(connection) == 1
|
||||
Reference in new issue
Block a user