Начальный коммит: библиотека стандартных изделий (Python CLI + .NET WPF)

This commit is contained in:
OpenClaw Coder committed 2026-11-04 01:02:18 +10:00
commit 7a5efc540f
33 files changed
+2630

No files matched your search

+31
View File
@@ -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/
+101
View File
@@ -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/ # документация, схема, промты
```
+172
View File
@@ -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° на торце.
Резьба не моделируется. До появления зависимости модуль не добавлять.
+47
View File
@@ -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` при импорте игнорируется.
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
+66
View File
@@ -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 и каталог при этом не меняются.
+43
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
}
}
+6
View File
@@ -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;
+57
View File
@@ -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"
}
}
]
}
+42
View File
@@ -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"]
+37
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+196
View File
@@ -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())
+322
View File
@@ -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)
+70
View File
@@ -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")
+169
View File
@@ -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