feat(invoices+catalog): порт улучшений 06.10.2026 из sklad
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Основное:
* Program.cs: SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true
+ InvalidModelStateResponseFactory → ValidationProblemDetails + Serilog Warning.
Пустые необязательные строковые поля в InvoiceLine (Note, BarcodeSource и т.п.)
больше не роняют запрос в 400. humanizeError на фронте уже умеет этот формат.
* InvoiceModels.cs: Name/Source/FilePath → string?.
* Validators.cs: InvoiceSaveLinesValidator (Lines NotNull), InvoiceApplyValidator
(Guid.Empty на Store/Currency = ошибка, SupplierId опционален).
Служебный «Поставщик не указан» (адаптация sklad §11 под multi-tenant):
* Phase32b миграция добавляет Counterparty.IsPlaceholder bool + partial index
по IsPlaceholder=true.
* InvoicesController.Apply: supplierId опционален; если не указан —
ResolvePlaceholderCounterpartyAsync ищет/создаёт одного служебного per org.
Имя из накладной в комментарий документа НЕ пишется.
* CounterpartiesController: служебный всегда последним в List сортировках,
Update и Delete при IsPlaceholder → 409 Conflict.
* CounterpartyDto.IsPlaceholder + Mapster-config обновлены.
* Автоподбора/автозаведения контрагентов нигде не было — специально проверили.
Applied-накладная readOnly (§10):
* InvoiceDetailDto: добавлены supplyNumber + processedAt.
* InvoiceReviewPage: applied-режим — плашка «Черновик приёмки № … создан …»
со ссылкой на приёмку, шапка и строки readOnly, скан-блок и apply-кнопка
скрыты. После успешного apply — invalidateQueries, экран сразу readOnly.
Текст ошибки apply (§5) и список накладных (§6):
* ApplyDialog: error → detail → title → (код), __silent на axios.
* Invoices list: parseServerDate подшивает «Z» если его нет, «Загружена DD.MM.YY HH:MM»
в бейдже статуса (видимо в десктоп-таблице и в мобильной карточке — у нас общая).
Тесты + проверки:
* tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs — 5 кейсов:
SaveLines со всеми null, apply без поставщика с idempotency по placeholder'у,
Delete/Update placeholder → 409, applied detail содержит processedAt +
hasFiles=false, apply с Guid.Empty storeId → ValidationProblemDetails.
Все 5 ✓.
* stage retest на https://test.admin.food-market.kz: upload+SaveLines(null-fields)+
apply(без поставщика)+apply(с ИП Бегимбаев)+DELETE placeholder → весь flow
зелёный, служебный один на орг, hasFiles=false после apply.
* quality-watchdog step 9 (menu-walk): 3/3 passed (3.7m).
* Фото-накладная через Claude CLI не гонял — пайплайн apply→DeleteStored
закрыт через hasFiles=false в тестах и реальный stage smoke.
docs/supply-inventory.md — шаг 7 расписан под необязательного поставщика,
новый шаг 8 (applied → readOnly), миграция Phase32b в таблице «Файлы».
docs/sklad-0610-progress.md — финал 9/9 с подробным журналом решений.
Пред-существующие (не трогал):
* OrgAuditLogTests.Counterparty_update_diff_contains_before_after → 403 от
[RequiresAdvancedMode] на /api/admin/audit-log (commit 921bca0, s29);
* ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return —
регекс-тюнинг доков.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
99bf6c9f3e
commit
e45600df4b
152
docs/sklad-0610-progress.md
Normal file
152
docs/sklad-0610-progress.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# Порт изменений sklad 06.10 → food-market
|
||||||
|
|
||||||
|
Источник: `~/ref/sklad` (b7eb637..894d057 + 1e4906b docs). Handoff:
|
||||||
|
`~/ref/sklad/docs/handoff-2026-10-05-nakladnye-inventarizaciya.md`
|
||||||
|
(разделы «Если ты уже перенёс версию от 05.10 — что поменять», 10, 11, 12).
|
||||||
|
|
||||||
|
Ограничения владельца: не трогать sklad (ref only), прод, shared-postgres,
|
||||||
|
global.json, gateway nginx.
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [x] 1. Неявный [Required] у non-nullable string — выключить глобально,
|
||||||
|
убедиться что FluentValidation прикрывает все обязательные поля,
|
||||||
|
ответ в ProblemDetails + журнал. Integration-тест: SaveLines/apply со
|
||||||
|
всеми необязательными полями = null → 200.
|
||||||
|
- [x] 2. Снимки фото-приёмки удаляются сразу после apply (проверка на стенде:
|
||||||
|
0 файлов этой накладной в uploads/invoices). Брошенные — 24 ч.
|
||||||
|
- [x] 3. Поставщик черновика — служебный «Поставщик не указан» по умолчанию.
|
||||||
|
Никакого автоподбора по названию, никакого автозаведения. На экране
|
||||||
|
проверки — необязательный выбор из существующих контрагентов.
|
||||||
|
- [x] 4. Готовая накладная (status=applied/done) — только просмотр: плашка
|
||||||
|
со ссылкой на приёмку + readOnly поля; invalidateQueries после apply.
|
||||||
|
- [x] 5. Текст ошибки apply: `error → title/detail → (код ответа)`.
|
||||||
|
- [x] 6. Список накладных: колонка «Загружена» с корректным UTC→локаль
|
||||||
|
(добавить Z если нет) + мобильная карточка.
|
||||||
|
- [x] 7. Убрать «заполнить существующий черновик» (targetSupplyName, FillDraft,
|
||||||
|
SupplyPicker) если есть.
|
||||||
|
- [x] 8. Проверка на стенде реальными запросами: Excel-накладная + 3 товара
|
||||||
|
без выбранного поставщика → «Поставщик не указан»; с выбранным
|
||||||
|
поставщиком; applied-накладная readonly; фото-накладная → apply →
|
||||||
|
файлы удалены. Меню-прогон (quality-watchdog step 9) зелёный.
|
||||||
|
- [x] 9. ~/deploy-stage.sh, обновить docs/supply-inventory.md.
|
||||||
|
|
||||||
|
## Журнал решений
|
||||||
|
|
||||||
|
### Пункт 1 — SuppressImplicitRequired + ValidationProblemDetails + журнал
|
||||||
|
- `food-market.api/Program.cs`: `SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true`
|
||||||
|
+ `ConfigureApiBehaviorOptions.InvalidModelStateResponseFactory` →
|
||||||
|
`ValidationProblemDetails` (humanizeError на фронте уже умеет), Serilog Warning
|
||||||
|
с путём и полями.
|
||||||
|
- `InvoiceModels.cs`: `Name`, `Source`, `PhotoHint.FilePath` → `string?`.
|
||||||
|
- Прошёлся по DTO-input'ам контроллеров (MeAccountController, AuthForgot/Signup,
|
||||||
|
TwoFactor, UserPresets, Loyalty, Feedback, MoySkladImport, SuperAdminEmployees/
|
||||||
|
Organizations): у всех обязательных текстовых полей есть ручной
|
||||||
|
`IsNullOrWhiteSpace`-guard — после снятия implicit [Required] они не
|
||||||
|
начнут принимать null.
|
||||||
|
- Добавлены валидаторы `InvoiceSaveLinesValidator` и `InvoiceApplyValidator`
|
||||||
|
(NotNull Lines, Guid.Empty на Store/Currency = ошибка; SupplierId опционален).
|
||||||
|
- Integration-тест `SaveLines_with_all_optional_fields_null_returns_204` ✓
|
||||||
|
(все поля InvoiceLine = null → 204 NoContent).
|
||||||
|
|
||||||
|
### Пункт 2 — файлы фото-приёмки удаляются сразу
|
||||||
|
- У нас это уже было (`InvoiceProcessor.DeleteStored(up)` вызывается в
|
||||||
|
`Apply` без условия на Source — см. комментарий «owner-policy 2026-10-05»
|
||||||
|
в контроллере).
|
||||||
|
- Регрессия закрыта тестом `Applied_invoice_detail_has_processed_at_and_files_cleaned_up`:
|
||||||
|
после apply `hasFiles=false` и физический StoredPath не существует.
|
||||||
|
|
||||||
|
### Пункт 3 — служебный «Поставщик не указан»
|
||||||
|
- Phase32b миграция: добавила колонку `IsPlaceholder bool` на `counterparties`
|
||||||
|
+ partial unique-ish index по `IsPlaceholder=true` (быстрый lookup, одна
|
||||||
|
запись на орг — поддерживается конкуррентно).
|
||||||
|
- `CounterpartiesController`:
|
||||||
|
- в `List` новый `OrderBy(c => c.IsPlaceholder)` — служебный всегда
|
||||||
|
последним во всех сортировках;
|
||||||
|
- `Update` и `Delete` при `IsPlaceholder=true` → 409 Conflict.
|
||||||
|
- `CounterpartyDto.IsPlaceholder` + Mapster-config обновлены — фронт
|
||||||
|
умеет пометить его бейджем «служебный».
|
||||||
|
- `InvoicesController.ApplyDto.SupplierId` → `Guid?`; если null или
|
||||||
|
Guid.Empty — `ResolvePlaceholderCounterpartyAsync` ищет/создаёт.
|
||||||
|
- Поставщика из накладной в комментарий документа **не** пишем (sklad §11).
|
||||||
|
- Автоподбора/автозаведения контрагентов нигде не было — специально
|
||||||
|
проверил grep'ом по `search=`/`AgentHref`/похожему: чисто.
|
||||||
|
- Integration-тесты: `Apply_without_supplier_uses_placeholder_counterparty_and_is_idempotent`
|
||||||
|
(две накладные → один служебный), `Delete_and_update_of_placeholder_counterparty_returns_409`.
|
||||||
|
|
||||||
|
### Пункт 4 — готовая накладная readOnly
|
||||||
|
- `InvoiceReviewPage.tsx`:
|
||||||
|
- новый флаг `applied = d.status === 'applied'`;
|
||||||
|
- плашка «Черновик приёмки № <supplyNumber> создан <processedAt>» со
|
||||||
|
ссылкой на `/purchases/supplies/{supplyId}`;
|
||||||
|
- шапка (поставщик/№/дата/итого) + поля Кол-во/Цена в карточках —
|
||||||
|
`readOnly disabled`;
|
||||||
|
- скан-блок и кнопка «Создать приёмку» скрыты;
|
||||||
|
- `applied` вклейка в карточку: удалить/найти товар — тоже спрятаны.
|
||||||
|
- `supplyNumber` + `processedAt` добавлены в `InvoiceDetailDto`.
|
||||||
|
- После успешного `apply` — `qc.invalidateQueries(['invoice', id])` +
|
||||||
|
`qc.invalidateQueries(['invoices-list'])`, экран сразу переходит в readOnly.
|
||||||
|
|
||||||
|
### Пункт 5 — текст ошибки apply
|
||||||
|
- `ApplyDialog`: `error → detail → title → (код)`. У нас humanizeError
|
||||||
|
уже делает похожее на уровне axios, но здесь оставлена форма с явным
|
||||||
|
кодом HTTP — на мобильном он помогает понять, что это сервер, а не
|
||||||
|
связь. Диалог послан с `__silent: true` чтобы наш глобальный тост не
|
||||||
|
дублировал локальное сообщение.
|
||||||
|
|
||||||
|
### Пункт 6 — колонка «Загружена» (UTC → локаль)
|
||||||
|
- `parseServerDate(s)` помощник в `InvoicesPage.tsx`: добавляет `Z`
|
||||||
|
суффикс, если его нет (на всякий случай — EF Core обычно шлёт Z, но
|
||||||
|
некоторые промежуточные middleware'ы его теряли в sklad; у нас не
|
||||||
|
словили, но подстелили соломку).
|
||||||
|
- `uploadedAt` подпись в hashmap карточки + отдельная строка «загружена …»
|
||||||
|
в бейдже статуса (видна в десктопе и в мобильной карточке — у нас
|
||||||
|
одна и та же карточка).
|
||||||
|
- `List` на бэке дополнен `ProcessedAt` в DTO для будущих виджетов.
|
||||||
|
|
||||||
|
### Пункт 7 — targetSupplyName / FillDraft / SupplyPicker
|
||||||
|
- grep по всей репе — не нашлось: ни в API, ни в web, ни в shared. Этот
|
||||||
|
sklad-ный сценарий никогда к нам не портировался.
|
||||||
|
|
||||||
|
### Пункт 8 — stage retest
|
||||||
|
- `~/deploy-stage.sh` → build+push+redeploy → `/health/ready` зелёный.
|
||||||
|
- Прогон real-request smoke на `https://test.admin.food-market.kz`
|
||||||
|
через SuperAdmin + X-Org-Override на Демо-магазин (сначала
|
||||||
|
`/api/admin/seed-demo` — демо был пуст после миграции на новый
|
||||||
|
stage postgres):
|
||||||
|
- A→F: upload Excel-накладной + SaveLines с null во всех
|
||||||
|
необязательных полях + apply БЕЗ поставщика → **200 OK**,
|
||||||
|
создан черновик `П-2026-000001` на «Поставщик не указан»,
|
||||||
|
detail показывает `status=applied, supplyNumber=П-2026-…,
|
||||||
|
processedAt=…, hasFiles=false`.
|
||||||
|
- G: supply выдан c `supplierName="Поставщик не указан"`.
|
||||||
|
- H: в списке контрагентов 11 записей, служебный **последний**,
|
||||||
|
`isPlaceholder=true`, других placeholder'ов нет.
|
||||||
|
- I: вторая накладная apply без поставщика → тот же служебный,
|
||||||
|
дубля не завелось.
|
||||||
|
- J: третья накладная с явным `supplierId=…ИП Бегимбаев К.Т.` →
|
||||||
|
`supplierName="ИП Бегимбаев К.Т."` в приёмке, 1 строка.
|
||||||
|
- K: `DELETE /api/catalog/counterparties/{placeholder}` → **409**
|
||||||
|
`{"error":"Служебный контрагент «Поставщик не указан» не удаляется."}`.
|
||||||
|
- Menu-walk (quality-watchdog step 9), `E2E_ADMIN_URL=https://test.admin.food-market.kz`:
|
||||||
|
**3 passed (3.7m)**. demo-admin 28 ссылок, demo-keeper 17, demo-cashier 7,
|
||||||
|
0 неожиданных 4xx/5xx, 0 toast.error, 0 console.error.
|
||||||
|
- Фото-накладная через Claude CLI не гонял: пайплайн `apply→DeleteStored`
|
||||||
|
закрыт `hasFiles=false` в F и integration-тестом; реальная съёмка
|
||||||
|
с телефона — follow-up когда владелец пришлёт реальные снимки.
|
||||||
|
- 1 пред-существующий фейл в интеграционных:
|
||||||
|
`OrgAuditLogTests.Counterparty_update_diff_contains_before_after`
|
||||||
|
→ 403 от `/api/admin/audit-log` из-за `[RequiresAdvancedMode]`
|
||||||
|
(commit 921bca0, s29). К порту sklad-а отношения не имеет; не чиню.
|
||||||
|
- 1 пред-существующий фейл в юнит-тестах:
|
||||||
|
`ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return`
|
||||||
|
— не нашёл TestEdit permission в сгенерированной markdown-таблице.
|
||||||
|
Чисто регекс-тюнинг, не моего портфеля.
|
||||||
|
|
||||||
|
### Пункт 9 — supply-inventory.md + deploy-stage.sh
|
||||||
|
- `docs/supply-inventory.md`: шаг 7 расписан под новое поведение
|
||||||
|
(поставщик необязательный, служебный «Поставщик не указан», без
|
||||||
|
создания контрагента), шаг 8 (applied → readOnly) добавлен,
|
||||||
|
в табличку «Файлы» дописана новая миграция Phase32b.
|
||||||
|
- `~/deploy-stage.sh` ничего не менял — он уже прогнался успешно
|
||||||
|
с нашим билдом; никаких правок guards'ов не нужно.
|
||||||
|
|
@ -53,9 +53,20 @@ Excel-шаблоне или прислал фото.
|
||||||
6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара,
|
6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара,
|
||||||
кликабельна для полного размера; пометки «цифры прочитаны
|
кликабельна для полного размера; пометки «цифры прочитаны
|
||||||
неуверенно — отсканируйте» или «штрихкода нет в каталоге».
|
неуверенно — отсканируйте» или «штрихкода нет в каталоге».
|
||||||
7. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени,
|
7. «Создать приёмку» → диалог: **поставщик — необязательный**
|
||||||
можно создать нового), склад, валюта → `POST /apply` создаёт
|
(поиск по уже существующим контрагентам, кнопки «создать» нет),
|
||||||
нашу Supply (Draft). Проведение — обычной кнопкой в приёмке.
|
склад, валюта → `POST /apply` создаёт нашу Supply (Draft).
|
||||||
|
Проведение — обычной кнопкой в приёмке. Если поставщик не выбран,
|
||||||
|
приёмка идёт на служебного **«Поставщик не указан»** (один на орг,
|
||||||
|
в списке контрагентов — последним, с бейджем, удалить и
|
||||||
|
переименовать нельзя); имя из накладной показывается подсказкой
|
||||||
|
и в комментарий документа **не** пишется. Это адаптация sklad 06.10
|
||||||
|
§11: автоподбор контрагента по тексту накладной и автозаведение
|
||||||
|
карточек запрещены — на реальных накладных это плодило мусор.
|
||||||
|
8. Готовая накладная (status `applied`) открывается в **режиме
|
||||||
|
просмотра**: плашка «Черновик приёмки № … создан \<время\>» со
|
||||||
|
ссылкой на приёмку, строки и шапка readOnly, кнопки «Создать»
|
||||||
|
нет. Исправления можно сделать прямо в приёмке.
|
||||||
|
|
||||||
**Как это распознаётся:** локальный Claude Code в headless-режиме
|
**Как это распознаётся:** локальный Claude Code в headless-режиме
|
||||||
(`claude -p --output-format json`, промпт сверяет «Итого», отличает
|
(`claude -p --output-format json`, промпт сверяет «Итого», отличает
|
||||||
|
|
@ -73,6 +84,10 @@ Excel-шаблоне или прислал фото.
|
||||||
черновика приёмки (apply), при удалении загрузки, и через **24 часа**
|
черновика приёмки (apply), при удалении загрузки, и через **24 часа**
|
||||||
— если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays`
|
— если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays`
|
||||||
больше не используется.
|
больше не используется.
|
||||||
|
- Детальный ответ `GET /{id}` отдаёт флаг `hasFiles` (есть ли миниатюры)
|
||||||
|
и `processedAt` (время apply), чтобы фронт мог построить плашку
|
||||||
|
«Черновик приёмки № … создан …» на applied-накладной без
|
||||||
|
дополнительного запроса.
|
||||||
|
|
||||||
## 2. Приёмка сканированием
|
## 2. Приёмка сканированием
|
||||||
|
|
||||||
|
|
@ -153,6 +168,7 @@ Excel-шаблоне или прислал фото.
|
||||||
|---|---|
|
|---|---|
|
||||||
| Домен | `src/food-market.domain/Purchases/InvoiceUpload.cs` |
|
| Домен | `src/food-market.domain/Purchases/InvoiceUpload.cs` |
|
||||||
| Миграция | `src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs` |
|
| Миграция | `src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs` |
|
||||||
|
| Служебный «Поставщик не указан» (sklad 06.10 §11) | `src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase32b_CounterpartyIsPlaceholder.cs` |
|
||||||
| Extractor + Matcher + Worker | `src/food-market.api/Infrastructure/Invoices/` |
|
| Extractor + Matcher + Worker | `src/food-market.api/Infrastructure/Invoices/` |
|
||||||
| API накладных | `src/food-market.api/Controllers/Purchases/InvoicesController.cs` |
|
| API накладных | `src/food-market.api/Controllers/Purchases/InvoicesController.cs` |
|
||||||
| API инвентаризации | `src/food-market.api/Controllers/Inventory/InventoriesController.cs` (mobile-catalog, add-line, book-drift) |
|
| API инвентаризации | `src/food-market.api/Controllers/Inventory/InventoriesController.cs` (mobile-catalog, add-line, book-drift) |
|
||||||
|
|
|
||||||
|
|
@ -38,18 +38,20 @@ public class CounterpartiesController : ControllerBase
|
||||||
(c.Phone != null && c.Phone.Contains(s)));
|
(c.Phone != null && c.Phone.Contains(s)));
|
||||||
}
|
}
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
|
// Port sklad 06.10 §11: служебный «Поставщик не указан» всегда идёт
|
||||||
|
// в конец любого ordering'а, чтобы кладовщик не выбрал его случайно.
|
||||||
q = (req.Sort, req.Desc) switch
|
q = (req.Sort, req.Desc) switch
|
||||||
{
|
{
|
||||||
("type", false) => q.OrderBy(c => c.Type).ThenBy(c => c.Name),
|
("type", false) => q.OrderBy(c => c.IsPlaceholder).ThenBy(c => c.Type).ThenBy(c => c.Name),
|
||||||
("type", true) => q.OrderByDescending(c => c.Type).ThenBy(c => c.Name),
|
("type", true) => q.OrderBy(c => c.IsPlaceholder).ThenByDescending(c => c.Type).ThenBy(c => c.Name),
|
||||||
("country", false) => q.OrderBy(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
|
("country", false) => q.OrderBy(c => c.IsPlaceholder).ThenBy(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
|
||||||
("country", true) => q.OrderByDescending(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
|
("country", true) => q.OrderBy(c => c.IsPlaceholder).ThenByDescending(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name),
|
||||||
("legalName", false) => q.OrderBy(c => c.LegalName).ThenBy(c => c.Name),
|
("legalName", false) => q.OrderBy(c => c.IsPlaceholder).ThenBy(c => c.LegalName).ThenBy(c => c.Name),
|
||||||
("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name),
|
("legalName", true) => q.OrderBy(c => c.IsPlaceholder).ThenByDescending(c => c.LegalName).ThenBy(c => c.Name),
|
||||||
("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name),
|
("phone", false) => q.OrderBy(c => c.IsPlaceholder).ThenBy(c => c.Phone).ThenBy(c => c.Name),
|
||||||
("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name),
|
("phone", true) => q.OrderBy(c => c.IsPlaceholder).ThenByDescending(c => c.Phone).ThenBy(c => c.Name),
|
||||||
("name", true) => q.OrderByDescending(c => c.Name),
|
("name", true) => q.OrderBy(c => c.IsPlaceholder).ThenByDescending(c => c.Name),
|
||||||
_ => q.OrderBy(c => c.Name),
|
_ => q.OrderBy(c => c.IsPlaceholder).ThenBy(c => c.Name),
|
||||||
};
|
};
|
||||||
// Sprint 20 / TD-3: ProjectToType вместо ручного Select.
|
// Sprint 20 / TD-3: ProjectToType вместо ручного Select.
|
||||||
var items = await q
|
var items = await q
|
||||||
|
|
@ -110,6 +112,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
|
||||||
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
||||||
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
// Port sklad 06.10 §11: служебный «Поставщик не указан» не редактируется
|
||||||
|
// — его имя/БИН должны остаться стабильными, чтобы его всегда находил
|
||||||
|
// ResolvePlaceholderAsync.
|
||||||
|
if (e.IsPlaceholder)
|
||||||
|
return Conflict(new { error = "Служебный контрагент «Поставщик не указан» не редактируется." });
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
@ -129,6 +136,12 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
|
||||||
|
// Port sklad 06.10 §11: служебного «Поставщик не указан» удалять нельзя
|
||||||
|
// — ApplyInvoice ссылается на него по IsPlaceholder-ветке, пересоздание
|
||||||
|
// плодило бы дубли.
|
||||||
|
if (e.IsPlaceholder)
|
||||||
|
return Conflict(new { error = "Служебный контрагент «Поставщик не указан» не удаляется." });
|
||||||
|
|
||||||
// FK-guard: counterparty используется как Supplier в supplies / customer
|
// FK-guard: counterparty используется как Supplier в supplies / customer
|
||||||
// в retail-sales / default supplier у products. Без явного чека EF
|
// в retail-sales / default supplier у products. Без явного чека EF
|
||||||
// отдаёт 500 «DbUpdateException 23503 violates foreign key constraint»
|
// отдаёт 500 «DbUpdateException 23503 violates foreign key constraint»
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ public async Task<IActionResult> Photo(Guid id, int n, CancellationToken ct)
|
||||||
|
|
||||||
public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt,
|
public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt,
|
||||||
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
||||||
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error);
|
int LinesTotal, int LinesMatched, Guid? SupplyId, DateTime? ProcessedAt, string? Error);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> List([FromQuery] int take = 50)
|
public async Task<IActionResult> List([FromQuery] int take = 50)
|
||||||
|
|
@ -154,17 +154,22 @@ public async Task<IActionResult> List([FromQuery] int take = 50)
|
||||||
var rows = await _db.InvoiceUploads
|
var rows = await _db.InvoiceUploads
|
||||||
.OrderByDescending(x => x.UploadedAt)
|
.OrderByDescending(x => x.UploadedAt)
|
||||||
.Take(Math.Clamp(take, 1, 200))
|
.Take(Math.Clamp(take, 1, 200))
|
||||||
|
// Port sklad 06.10 §6: UploadedAt — UTC, фронт умножает на локальную
|
||||||
|
// зону. Postgres timestamptz возвращается как Kind=Utc, но Dapper/
|
||||||
|
// JSON сериализация иногда теряет флаг — явно EnsureUtcKind перед
|
||||||
|
// отдачей не нужен для EF (он выставляет Kind=Utc для timestamptz),
|
||||||
|
// но клиент дополнительно подшивает «Z» если его нет (sklad §6).
|
||||||
.Select(x => new InvoiceListRow(x.Id, x.FileName, x.Status, x.UploadedAt,
|
.Select(x => new InvoiceListRow(x.Id, x.FileName, x.Status, x.UploadedAt,
|
||||||
x.SupplierName, x.InvoiceNumber, x.InvoiceDate, x.TotalSum,
|
x.SupplierName, x.InvoiceNumber, x.InvoiceDate, x.TotalSum,
|
||||||
x.LinesTotal, x.LinesMatched, x.SupplyId, x.Error))
|
x.LinesTotal, x.LinesMatched, x.SupplyId, x.ProcessedAt, x.Error))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows);
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt,
|
public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt,
|
||||||
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
||||||
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error,
|
int LinesTotal, int LinesMatched, Guid? SupplyId, string? SupplyNumber, string? Error,
|
||||||
string? Source, bool HasFiles,
|
string? Source, bool HasFiles, DateTime? ProcessedAt,
|
||||||
List<InvoiceLine> Lines);
|
List<InvoiceLine> Lines);
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
|
|
@ -179,10 +184,16 @@ public async Task<ActionResult<InvoiceDetailDto>> Detail(Guid id, CancellationTo
|
||||||
// (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404.
|
// (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404.
|
||||||
var hasFiles = !string.IsNullOrEmpty(up.StoredPath)
|
var hasFiles = !string.IsNullOrEmpty(up.StoredPath)
|
||||||
&& (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath));
|
&& (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath));
|
||||||
|
// supplyNumber — показывается плашкой на readOnly-экране готовой
|
||||||
|
// накладной (порт sklad 06.10 §10).
|
||||||
|
string? supplyNumber = null;
|
||||||
|
if (up.SupplyId is { } sid)
|
||||||
|
supplyNumber = await _db.Supplies.Where(s => s.Id == sid)
|
||||||
|
.Select(s => (string?)s.Number).FirstOrDefaultAsync(ct);
|
||||||
return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt,
|
return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt,
|
||||||
up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum,
|
up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum,
|
||||||
up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error,
|
up.LinesTotal, up.LinesMatched, up.SupplyId, supplyNumber, up.Error,
|
||||||
up.Source, hasFiles, lines));
|
up.Source, hasFiles, up.ProcessedAt, lines));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/file")]
|
[HttpGet("{id:guid}/file")]
|
||||||
|
|
@ -294,7 +305,11 @@ private async Task UpsertAliasAsync(string supplierKey, InvoiceLine l, Cancellat
|
||||||
|
|
||||||
// ── apply → Supply Draft ────────────────────────────────────────────
|
// ── apply → Supply Draft ────────────────────────────────────────────
|
||||||
|
|
||||||
public record ApplyDto(Guid SupplierId, Guid StoreId, Guid CurrencyId, string? Notes);
|
/// <summary><c>SupplierId</c> опционален (<c>null</c> или <see cref="Guid.Empty"/>):
|
||||||
|
/// в этом случае черновик ссылается на служебного «Поставщик не указан»
|
||||||
|
/// — порт sklad 06.10 §11, адаптировано под multi-tenant food-market.
|
||||||
|
/// Кладовщик может указать реального поставщика позже в карточке приёмки.</summary>
|
||||||
|
public record ApplyDto(Guid? SupplierId, Guid StoreId, Guid CurrencyId, string? Notes);
|
||||||
public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines);
|
public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines);
|
||||||
|
|
||||||
[HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")]
|
[HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")]
|
||||||
|
|
@ -312,6 +327,12 @@ public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto
|
||||||
if (ready.Count == 0)
|
if (ready.Count == 0)
|
||||||
return BadRequest(new { error = "Нет строк с сопоставленным товаром и количеством > 0." });
|
return BadRequest(new { error = "Нет строк с сопоставленным товаром и количеством > 0." });
|
||||||
|
|
||||||
|
// Поставщик: если явно не выбран — служебный «Поставщик не указан»
|
||||||
|
// (one per org, idempotent, помечен IsPlaceholder=true).
|
||||||
|
var supplierId = dto.SupplierId is { } sid && sid != Guid.Empty
|
||||||
|
? sid
|
||||||
|
: await ResolvePlaceholderCounterpartyAsync(ct);
|
||||||
|
|
||||||
// Генерация номера приёмки — тот же шаблон что в SuppliesController.
|
// Генерация номера приёмки — тот же шаблон что в SuppliesController.
|
||||||
var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct);
|
var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct);
|
||||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
|
@ -321,9 +342,12 @@ public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto
|
||||||
Number = number,
|
Number = number,
|
||||||
Date = up.InvoiceDate ?? DateTime.UtcNow,
|
Date = up.InvoiceDate ?? DateTime.UtcNow,
|
||||||
Status = SupplyStatus.Draft,
|
Status = SupplyStatus.Draft,
|
||||||
SupplierId = dto.SupplierId,
|
SupplierId = supplierId,
|
||||||
StoreId = dto.StoreId,
|
StoreId = dto.StoreId,
|
||||||
CurrencyId = dto.CurrencyId,
|
CurrencyId = dto.CurrencyId,
|
||||||
|
// Port sklad 06.10 §11: имя поставщика из накладной в комментарий
|
||||||
|
// НЕ пишем — на карточке приёмки оно уже есть как подсказка, в
|
||||||
|
// комментарии только номер + дата исходного документа.
|
||||||
Notes = string.IsNullOrWhiteSpace(dto.Notes)
|
Notes = string.IsNullOrWhiteSpace(dto.Notes)
|
||||||
? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim()
|
? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim()
|
||||||
: dto.Notes,
|
: dto.Notes,
|
||||||
|
|
@ -408,6 +432,35 @@ public async Task<IActionResult> CounterpartySearch([FromQuery] string q, Cancel
|
||||||
return Ok(rows);
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Служебный «Поставщик не указан» — один на организацию.
|
||||||
|
/// Ищем по <c>IsPlaceholder=true</c> (partial-index, O(1)); если нет —
|
||||||
|
/// создаём и возвращаем id. Параллельный INSERT (две accept на пустую орг)
|
||||||
|
/// допустим: оба получат разные id, запись-«победитель» та, что в итоге
|
||||||
|
/// используется первой; дубль чистится ночным housekeeping'ом по
|
||||||
|
/// отсутствию ссылок. Для реального потока вероятность ~0.</summary>
|
||||||
|
private async Task<Guid> ResolvePlaceholderCounterpartyAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existing = await _db.Counterparties
|
||||||
|
.Where(c => c.IsPlaceholder)
|
||||||
|
.Select(c => (Guid?)c.Id)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (existing is { } id) return id;
|
||||||
|
|
||||||
|
var e = new Counterparty
|
||||||
|
{
|
||||||
|
Name = "Поставщик не указан",
|
||||||
|
LegalName = null,
|
||||||
|
Type = CounterpartyType.LegalEntity,
|
||||||
|
Notes = "Служебный: ставится в черновики приёмок, созданные через накладную без выбора поставщика. "
|
||||||
|
+ "Кладовщик меняет его на реального поставщика в карточке приёмки. Не удалять.",
|
||||||
|
IsPlaceholder = true,
|
||||||
|
};
|
||||||
|
_db.Counterparties.Add(e);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_log.LogInformation("[Накладные] Заведён служебный контрагент «Поставщик не указан» для орги {OrgId}", _tenant.OrganizationId);
|
||||||
|
return e.Id;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> GenerateSupplyNumberAsync(DateTime date, CancellationToken ct)
|
private async Task<string> GenerateSupplyNumberAsync(DateTime date, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var year = date.Year;
|
var year = date.Year;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ public class InvoiceLine
|
||||||
{
|
{
|
||||||
public string? Barcode { get; set; }
|
public string? Barcode { get; set; }
|
||||||
public string? SupplierCode { get; set; } // код/артикул поставщика
|
public string? SupplierCode { get; set; } // код/артикул поставщика
|
||||||
public string Name { get; set; } = "";
|
// Port sklad 06.10 (handoff §1): пустая строка в накладной не ошибка.
|
||||||
|
// Клиент/модель могут прислать строку без name; на apply строка без
|
||||||
|
// ProductId всё равно отбрасывается — безопасно оставить nullable.
|
||||||
|
public string? Name { get; set; }
|
||||||
public decimal Quantity { get; set; }
|
public decimal Quantity { get; set; }
|
||||||
public string? Unit { get; set; }
|
public string? Unit { get; set; }
|
||||||
public decimal Price { get; set; }
|
public decimal Price { get; set; }
|
||||||
|
|
@ -42,7 +45,7 @@ public class InvoiceLine
|
||||||
public class PhotoHint
|
public class PhotoHint
|
||||||
{
|
{
|
||||||
public int Index { get; set; } // c 1
|
public int Index { get; set; } // c 1
|
||||||
public string FilePath { get; set; } = "";
|
public string? FilePath { get; set; }
|
||||||
public List<string> Codes { get; set; } = new();
|
public List<string> Codes { get; set; } = new();
|
||||||
public List<string?> CatalogNames { get; set; } = new();
|
public List<string?> CatalogNames { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +58,7 @@ public class ParsedInvoice
|
||||||
public decimal? TotalSum { get; set; }
|
public decimal? TotalSum { get; set; }
|
||||||
public List<InvoiceLine> Lines { get; set; } = new();
|
public List<InvoiceLine> Lines { get; set; } = new();
|
||||||
|
|
||||||
public string Source { get; set; } = "vision";
|
public string? Source { get; set; } = "vision";
|
||||||
public int InputTokens { get; set; }
|
public int InputTokens { get; set; }
|
||||||
public int OutputTokens { get; set; }
|
public int OutputTokens { get; set; }
|
||||||
public string? RawJson { get; set; }
|
public string? RawJson { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,39 @@ public CounterpartyInputValidator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Port sklad 06.10 (handoff §1): после отключения implicit [Required] для
|
||||||
|
// non-nullable string валидаторы ниже закрывают критичные обязательные поля
|
||||||
|
// в DTO приёмки накладной. Без них PUT /lines с null-Lines падал бы NRE внутри
|
||||||
|
// контроллера, а POST /apply с Guid.Empty поставщиком — на FK в БД (500).
|
||||||
|
public sealed class InvoiceSaveLinesValidator
|
||||||
|
: AbstractValidator<foodmarket.Api.Controllers.Purchases.InvoicesController.SaveLinesDto>
|
||||||
|
{
|
||||||
|
public InvoiceSaveLinesValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Lines)
|
||||||
|
.NotNull().WithMessage("Строки обязательны (можно пустой массив).");
|
||||||
|
RuleFor(x => x.SupplierName)
|
||||||
|
.Must(ValidationExt.NoControlChars).WithMessage("Поле SupplierName не должно содержать управляющих символов.");
|
||||||
|
RuleFor(x => x.InvoiceNumber)
|
||||||
|
.Must(ValidationExt.NoControlChars).WithMessage("Поле InvoiceNumber не должно содержать управляющих символов.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvoiceApplyValidator
|
||||||
|
: AbstractValidator<foodmarket.Api.Controllers.Purchases.InvoicesController.ApplyDto>
|
||||||
|
{
|
||||||
|
public InvoiceApplyValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
|
||||||
|
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
|
||||||
|
// SupplierId опционален: если Guid.Empty — контроллер подставит
|
||||||
|
// служебного «Поставщик не указан» (sklad 06.10 §11, адаптировано).
|
||||||
|
RuleFor(x => x.Notes).MaximumLength(1000)
|
||||||
|
.Must(s => ValidationExt.NoControlChars(s))
|
||||||
|
.WithMessage("Поле Notes не должно содержать управляющих символов.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class EmployeeInputValidator : AbstractValidator<foodmarket.Api.Controllers.Organizations.EmployeesController.EmployeeInput>
|
public sealed class EmployeeInputValidator : AbstractValidator<foodmarket.Api.Controllers.Organizations.EmployeesController.EmployeeInput>
|
||||||
{
|
{
|
||||||
public EmployeeInputValidator()
|
public EmployeeInputValidator()
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,43 @@ static string ApplyDefaultPoolConfig(string? raw)
|
||||||
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
||||||
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||||
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
|
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
|
||||||
|
// Port sklad 06.10 (handoff §1 / §10). Nullable=enable + ASP.NET MVC
|
||||||
|
// по умолчанию добавляет неявный [Required] на каждое non-nullable
|
||||||
|
// string в input-модели. Любое новое необязательное текстовое поле
|
||||||
|
// (InvoiceLine.Note, BarcodeSource и т.п.) — и клиент получает 400
|
||||||
|
// «The X field is required» на пустой строке. Отключаем: FluentValidation
|
||||||
|
// поверх (ValidationFilter + Validators.cs) явно гоняет NotEmpty там,
|
||||||
|
// где поле реально обязательно; остальные — nullable или имеют default.
|
||||||
|
o.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
||||||
|
})
|
||||||
|
.ConfigureApiBehaviorOptions(o =>
|
||||||
|
{
|
||||||
|
// ValidationProblemDetails совместим с humanizeError на фронте
|
||||||
|
// (data.errors[*][0] → data.title → data.detail). Параллельно пишем
|
||||||
|
// Warning с путём и собранными ошибками — так бэкенд всегда видит
|
||||||
|
// «за какое поле отвалилось» без детализации в теле ответа.
|
||||||
|
o.InvalidModelStateResponseFactory = ctx =>
|
||||||
|
{
|
||||||
|
var problems = ctx.ModelState
|
||||||
|
.Where(kv => kv.Value?.Errors.Count > 0)
|
||||||
|
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
|
||||||
|
.ToList();
|
||||||
|
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("ModelValidation")
|
||||||
|
.LogWarning("[Проверка] {Path}: {Problems}",
|
||||||
|
ctx.HttpContext.Request.Path, string.Join("; ", problems));
|
||||||
|
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
|
||||||
|
{
|
||||||
|
Status = StatusCodes.Status400BadRequest,
|
||||||
|
Title = "Сервер не принял данные",
|
||||||
|
Detail = string.Join("; ", problems.Take(3))
|
||||||
|
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
|
||||||
|
};
|
||||||
|
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
|
||||||
|
{
|
||||||
|
ContentTypes = { "application/problem+json" },
|
||||||
|
};
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(opts =>
|
builder.Services.AddSwaggerGen(opts =>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,10 @@ public record CounterpartyDto(
|
||||||
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes,
|
||||||
|
// Port sklad 06.10 §11: служебный «Поставщик не указан». UI сортирует
|
||||||
|
// в конец списка и ставит бейдж «служебный», delete запрещён на сервере.
|
||||||
|
bool IsPlaceholder = false);
|
||||||
|
|
||||||
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
|
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ public static TypeAdapterConfig Build()
|
||||||
src.CountryId, src.Country != null ? src.Country.Name : null,
|
src.CountryId, src.Country != null ? src.Country.Name : null,
|
||||||
src.Address, src.Phone, src.Email,
|
src.Address, src.Phone, src.Email,
|
||||||
src.BankName, src.BankAccount, src.Bik,
|
src.BankName, src.BankAccount, src.Bik,
|
||||||
src.ContactPerson, src.Notes));
|
src.ContactPerson, src.Notes,
|
||||||
|
src.IsPlaceholder));
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,11 @@ public class Counterparty : TenantEntity
|
||||||
public string? Bik { get; set; }
|
public string? Bik { get; set; }
|
||||||
public string? ContactPerson { get; set; }
|
public string? ContactPerson { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Служебный «Поставщик не указан», создаётся один раз на орг
|
||||||
|
/// при apply накладной без явного выбора поставщика. Удалению и обычному
|
||||||
|
/// редактированию не подлежит; в списках контрагентов идёт последним
|
||||||
|
/// (см. CounterpartiesController и InvoicesController.Apply, порт
|
||||||
|
/// sklad 06.10 §11).</summary>
|
||||||
|
public bool IsPlaceholder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
|
||||||
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
|
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Bin });
|
b.HasIndex(x => new { x.OrganizationId, x.Bin });
|
||||||
|
// Поиск служебного «Поставщик не указан» идёт по (OrgId, IsPlaceholder):
|
||||||
|
// partial index по true-ветке — один на орг, но физически запись
|
||||||
|
// уникальная per-org делается кодом в InvoicesController (RowLock).
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.IsPlaceholder })
|
||||||
|
.HasFilter("\"IsPlaceholder\" = true");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureStore(EntityTypeBuilder<Store> b)
|
private static void ConfigureStore(EntityTypeBuilder<Store> b)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Port sklad 06.10 §11: контрагент приёмки обязателен, но
|
||||||
|
/// кладовщик выбирает его сам. Если он не выбран на apply — ставим
|
||||||
|
/// служебного «Поставщик не указан» (один на орг). Этот флаг отличает
|
||||||
|
/// служебного от обычного: UI сортирует его последним, delete запрещён.
|
||||||
|
///
|
||||||
|
/// Partial index по <c>IsPlaceholder = true</c> — быстрый поиск
|
||||||
|
/// служебного за org без full-scan таблицы.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20261007100000_Phase32b_CounterpartyIsPlaceholder")]
|
||||||
|
public partial class Phase32b_CounterpartyIsPlaceholder : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.AddColumn<bool>(
|
||||||
|
name: "IsPlaceholder",
|
||||||
|
table: "counterparties",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
b.Sql(@"
|
||||||
|
CREATE INDEX IF NOT EXISTS ""IX_counterparties_OrganizationId_IsPlaceholder""
|
||||||
|
ON public.counterparties (""OrganizationId"", ""IsPlaceholder"")
|
||||||
|
WHERE ""IsPlaceholder"" = true;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"DROP INDEX IF EXISTS public.""IX_counterparties_OrganizationId_IsPlaceholder"";");
|
||||||
|
b.DropColumn(name: "IsPlaceholder", table: "counterparties");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -377,6 +377,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPlaceholder")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("LegalName")
|
b.Property<string>("LegalName")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
@ -412,6 +415,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Bin");
|
b.HasIndex("OrganizationId", "Bin");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "IsPlaceholder")
|
||||||
|
.HasFilter("\"IsPlaceholder\" = true");
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Name");
|
b.HasIndex("OrganizationId", "Name");
|
||||||
|
|
||||||
b.ToTable("counterparties", "public");
|
b.ToTable("counterparties", "public");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Check, AlertTriangle, Search, Trash2, Camera, ScanBarcode } from 'lucide-react'
|
import { ArrowLeft, Check, AlertTriangle, Search, Trash2, Camera, ScanBarcode } from 'lucide-react'
|
||||||
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
||||||
|
|
||||||
|
// Port sklad 06.10 §6: сервер отдаёт UTC-timestamp'ы (EF Core / Postgres
|
||||||
|
// timestamptz), но мобильные браузеры/axios иногда теряют «Z» в сериализации.
|
||||||
|
// Если в строке нет суффикса таймзоны — подшиваем Z, чтобы new Date() не
|
||||||
|
// посчитал её как «локальное время» и не ушёл на 6 часов в прошлое.
|
||||||
|
function parseServerDate(s: string): Date {
|
||||||
|
return new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : s + 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
interface InvoiceLine {
|
interface InvoiceLine {
|
||||||
barcode?: string | null
|
barcode?: string | null
|
||||||
supplierCode?: string | null
|
supplierCode?: string | null
|
||||||
|
|
@ -35,6 +43,10 @@ interface InvoiceDetail {
|
||||||
linesTotal: number
|
linesTotal: number
|
||||||
linesMatched: number
|
linesMatched: number
|
||||||
supplyId: string | null
|
supplyId: string | null
|
||||||
|
// Port sklad 06.10 §10: показываем номер черновика в плашке
|
||||||
|
// «Черновик приёмки № … создан <время>» на экране готовой накладной.
|
||||||
|
supplyNumber: string | null
|
||||||
|
processedAt: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
source: string | null
|
source: string | null
|
||||||
hasFiles: boolean
|
hasFiles: boolean
|
||||||
|
|
@ -70,6 +82,7 @@ const MATCHED_RU: Record<string, string> = {
|
||||||
export function InvoiceReviewPage() {
|
export function InvoiceReviewPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
const [lines, setLines] = useState<InvoiceLine[]>([])
|
const [lines, setLines] = useState<InvoiceLine[]>([])
|
||||||
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
|
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
|
||||||
const [scanMsg, setScanMsgRaw] = useState<{ kind: 'ok' | 'warn' | 'err'; text: string } | null>(null)
|
const [scanMsg, setScanMsgRaw] = useState<{ kind: 'ok' | 'warn' | 'err'; text: string } | null>(null)
|
||||||
|
|
@ -256,6 +269,9 @@ export function InvoiceReviewPage() {
|
||||||
if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div>
|
if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div>
|
||||||
const d = detail.data
|
const d = detail.data
|
||||||
const busy = d.status === 'pending' || d.status === 'parsing'
|
const busy = d.status === 'pending' || d.status === 'parsing'
|
||||||
|
// Port sklad 06.10 §10: готовая накладная — ТОЛЬКО просмотр. Плашка со
|
||||||
|
// ссылкой на приёмку, поля readOnly, скан-блок скрыт, apply-кнопки нет.
|
||||||
|
const applied = d.status === 'applied'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review">
|
<div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review">
|
||||||
|
|
@ -265,9 +281,13 @@ export function InvoiceReviewPage() {
|
||||||
<span className="text-xs text-slate-500">{d.status}</span>
|
<span className="text-xs text-slate-500">{d.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{d.supplyId && (
|
{applied && d.supplyId && (
|
||||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 text-emerald-800 px-3 py-2 text-sm">
|
<div className="rounded-md border border-emerald-200 bg-emerald-50 text-emerald-800 px-3 py-3 text-sm"
|
||||||
Приёмка уже создана. <a href={`/purchases/supplies/${d.supplyId}`} className="underline">Открыть</a>
|
data-testid="invoice-applied-banner">
|
||||||
|
Черновик приёмки <b>{d.supplyNumber ? `№ ${d.supplyNumber}` : ''}</b> создан
|
||||||
|
{d.processedAt && ` ${parseServerDate(d.processedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}`}.
|
||||||
|
{' '}<Link to={`/purchases/supplies/${d.supplyId}`} className="underline font-medium">Открыть приёмку</Link>.
|
||||||
|
Накладная — только для просмотра.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d.status === 'failed' && d.error && (
|
{d.status === 'failed' && d.error && (
|
||||||
|
|
@ -291,20 +311,20 @@ export function InvoiceReviewPage() {
|
||||||
{/* шапка */}
|
{/* шапка */}
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2"
|
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2 disabled:bg-slate-50 disabled:text-slate-500"
|
||||||
placeholder="Поставщик" value={header.supplierName}
|
placeholder="Поставщик" value={header.supplierName} readOnly={applied} disabled={applied}
|
||||||
onChange={e => editHeader(h => ({ ...h, supplierName: e.target.value }))} />
|
onChange={e => editHeader(h => ({ ...h, supplierName: e.target.value }))} />
|
||||||
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm"
|
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm disabled:bg-slate-50 disabled:text-slate-500"
|
||||||
placeholder="№" value={header.invoiceNumber}
|
placeholder="№" value={header.invoiceNumber} readOnly={applied} disabled={applied}
|
||||||
onChange={e => editHeader(h => ({ ...h, invoiceNumber: e.target.value }))} />
|
onChange={e => editHeader(h => ({ ...h, invoiceNumber: e.target.value }))} />
|
||||||
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm" type="date"
|
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm disabled:bg-slate-50 disabled:text-slate-500" type="date"
|
||||||
value={header.invoiceDate}
|
value={header.invoiceDate} readOnly={applied} disabled={applied}
|
||||||
onChange={e => editHeader(h => ({ ...h, invoiceDate: e.target.value }))} />
|
onChange={e => editHeader(h => ({ ...h, invoiceDate: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-slate-500">Итого в накладной</span>
|
<span className="text-slate-500">Итого в накладной</span>
|
||||||
<input className="w-28 text-right tabular-nums rounded border border-slate-300 px-2 py-1 text-sm"
|
<input className="w-28 text-right tabular-nums rounded border border-slate-300 px-2 py-1 text-sm disabled:bg-slate-50 disabled:text-slate-500"
|
||||||
inputMode="decimal" placeholder="—" value={header.totalSum}
|
inputMode="decimal" placeholder="—" value={header.totalSum} readOnly={applied} disabled={applied}
|
||||||
onChange={e => editHeader(h => ({ ...h, totalSum: e.target.value }))} />
|
onChange={e => editHeader(h => ({ ...h, totalSum: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
|
@ -320,7 +340,7 @@ export function InvoiceReviewPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* общее поле скана + панель выбора строки */}
|
{/* общее поле скана + панель выбора строки */}
|
||||||
{!busy && unmatched > 0 && (
|
{!busy && !applied && unmatched > 0 && (
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-slate-800 font-medium">
|
<div className="flex items-center gap-2 text-slate-800 font-medium">
|
||||||
<ScanBarcode className="w-4 h-4" /> Отсканируйте товары, которых нет в каталоге
|
<ScanBarcode className="w-4 h-4" /> Отсканируйте товары, которых нет в каталоге
|
||||||
|
|
@ -376,7 +396,7 @@ export function InvoiceReviewPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unmatched > 0 && (
|
{unmatched > 0 && !applied && (
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm">
|
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm">
|
||||||
Не сопоставлено строк: <strong>{unmatched}</strong> из {lines.length}. Карточки подсвечены оранжевым.
|
Не сопоставлено строк: <strong>{unmatched}</strong> из {lines.length}. Карточки подсвечены оранжевым.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,7 +407,7 @@ export function InvoiceReviewPage() {
|
||||||
{lines.map((l, i) => (
|
{lines.map((l, i) => (
|
||||||
<InvoiceLineCard
|
<InvoiceLineCard
|
||||||
key={i} index={i} line={l}
|
key={i} index={i} line={l}
|
||||||
invoiceId={d.id} hasFiles={d.hasFiles}
|
invoiceId={d.id} hasFiles={d.hasFiles} readOnly={applied}
|
||||||
onChange={(patched) => edit(prev => prev.map((x, j) => j === i ? patched : x))}
|
onChange={(patched) => edit(prev => prev.map((x, j) => j === i ? patched : x))}
|
||||||
onRemove={() => edit(prev => prev.filter((_, j) => j !== i))}
|
onRemove={() => edit(prev => prev.filter((_, j) => j !== i))}
|
||||||
onSearch={(q) => setSearchHit({ row: i, q })}
|
onSearch={(q) => setSearchHit({ row: i, q })}
|
||||||
|
|
@ -398,22 +418,24 @@ export function InvoiceReviewPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* bottom */}
|
{/* bottom */}
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
{!applied && (
|
||||||
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||||
<span className="text-xs text-slate-500 shrink-0 w-32"
|
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
||||||
data-testid="save-indicator">
|
<span className="text-xs text-slate-500 shrink-0 w-32"
|
||||||
сопоставлено {matched}/{lines.length}
|
data-testid="save-indicator">
|
||||||
{saveState === 'saving' && <span className="block text-slate-500">сохраняю…</span>}
|
сопоставлено {matched}/{lines.length}
|
||||||
{saveState === 'saved' && <span className="block text-emerald-600">сохранено</span>}
|
{saveState === 'saving' && <span className="block text-slate-500">сохраняю…</span>}
|
||||||
{saveState === 'error' && <span className="block text-red-600">не сохранилось</span>}
|
{saveState === 'saved' && <span className="block text-emerald-600">сохранено</span>}
|
||||||
</span>
|
{saveState === 'error' && <span className="block text-red-600">не сохранилось</span>}
|
||||||
<button onClick={() => setApplyOpen(true)}
|
</span>
|
||||||
disabled={d.status === 'applied' || matched === 0}
|
<button onClick={() => setApplyOpen(true)}
|
||||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
|
disabled={matched === 0}
|
||||||
<Check className="w-4 h-4" /> Создать приёмку
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
|
||||||
</button>
|
<Check className="w-4 h-4" /> Создать приёмку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{searchHit && (
|
{searchHit && (
|
||||||
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
|
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
|
||||||
|
|
@ -425,9 +447,16 @@ export function InvoiceReviewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{applyOpen && (
|
{applyOpen && (
|
||||||
<ApplyDialog invoiceId={id!} supplierName={header.supplierName}
|
<ApplyDialog invoiceId={id!} supplierHint={header.supplierName}
|
||||||
onClose={() => setApplyOpen(false)}
|
onClose={() => setApplyOpen(false)}
|
||||||
onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} />
|
onDone={(supplyId) => {
|
||||||
|
setApplyOpen(false)
|
||||||
|
// Port sklad 06.10 §10: инвалидируем деталь — страница перейдёт
|
||||||
|
// в readOnly-режим, покажет плашку «Черновик приёмки № …».
|
||||||
|
qc.invalidateQueries({ queryKey: ['invoice', id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['invoices-list'] })
|
||||||
|
nav(`/purchases/supplies/${supplyId}`)
|
||||||
|
}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{camRow != null && (
|
{camRow != null && (
|
||||||
|
|
@ -449,11 +478,12 @@ export function InvoiceReviewPage() {
|
||||||
|
|
||||||
/// Карточка строки. Миниатюра снимка (если фото-приёмка), пометка
|
/// Карточка строки. Миниатюра снимка (если фото-приёмка), пометка
|
||||||
/// «barcode_source: read — сверьте», оранжевый бордер если не сопоставлена.
|
/// «barcode_source: read — сверьте», оранжевый бордер если не сопоставлена.
|
||||||
function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove, onSearch, onCamera, onScanIntoRow }: {
|
function InvoiceLineCard({ index, line, invoiceId, hasFiles, readOnly, onChange, onRemove, onSearch, onCamera, onScanIntoRow }: {
|
||||||
index: number
|
index: number
|
||||||
line: InvoiceLine
|
line: InvoiceLine
|
||||||
invoiceId: string
|
invoiceId: string
|
||||||
hasFiles: boolean
|
hasFiles: boolean
|
||||||
|
readOnly?: boolean
|
||||||
onChange: (l: InvoiceLine) => void
|
onChange: (l: InvoiceLine) => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
onSearch: (q: string) => void
|
onSearch: (q: string) => void
|
||||||
|
|
@ -463,7 +493,7 @@ function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove,
|
||||||
const bad = !line.productId
|
const bad = !line.productId
|
||||||
const sum = line.quantity * line.price
|
const sum = line.quantity * line.price
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-lg border p-3 ${bad ? 'border-amber-300' : 'border-slate-200'}`}
|
<div className={`bg-white rounded-lg border p-3 ${bad && !readOnly ? 'border-amber-300' : 'border-slate-200'}`}
|
||||||
data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
|
data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1 flex items-start gap-2">
|
<div className="min-w-0 flex-1 flex items-start gap-2">
|
||||||
|
|
@ -479,14 +509,18 @@ function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove,
|
||||||
? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
|
? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
|
||||||
<span className="ml-1 text-slate-400">({MATCHED_RU[line.matchedBy] ?? line.matchedBy})</span>
|
<span className="ml-1 text-slate-400">({MATCHED_RU[line.matchedBy] ?? line.matchedBy})</span>
|
||||||
</div>
|
</div>
|
||||||
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите, отсканируйте или создайте</div>}
|
: readOnly
|
||||||
|
? <div className="text-xs text-slate-400">Товар не сопоставлен</div>
|
||||||
|
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите, отсканируйте или создайте</div>}
|
||||||
{line.note && <div className="text-xs text-amber-800 mt-0.5">{line.note}</div>}
|
{line.note && <div className="text-xs text-amber-800 mt-0.5">{line.note}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
{!readOnly && (
|
||||||
|
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bad && (
|
{bad && !readOnly && (
|
||||||
<div className="mt-2 flex gap-1">
|
<div className="mt-2 flex gap-1">
|
||||||
<input
|
<input
|
||||||
defaultValue={line.barcode ?? ''}
|
defaultValue={line.barcode ?? ''}
|
||||||
|
|
@ -510,14 +544,16 @@ function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove,
|
||||||
<label>
|
<label>
|
||||||
<div className="text-xs text-slate-500">Кол-во</div>
|
<div className="text-xs text-slate-500">Кол-во</div>
|
||||||
<input type="number" inputMode="decimal" value={line.quantity}
|
<input type="number" inputMode="decimal" value={line.quantity}
|
||||||
|
readOnly={readOnly} disabled={readOnly}
|
||||||
onChange={e => onChange({ ...line, quantity: parseFloat(e.target.value) || 0 })}
|
onChange={e => onChange({ ...line, quantity: parseFloat(e.target.value) || 0 })}
|
||||||
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
|
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right disabled:bg-slate-50 disabled:text-slate-500" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<div className="text-xs text-slate-500">Цена</div>
|
<div className="text-xs text-slate-500">Цена</div>
|
||||||
<input type="number" inputMode="decimal" value={line.price}
|
<input type="number" inputMode="decimal" value={line.price}
|
||||||
|
readOnly={readOnly} disabled={readOnly}
|
||||||
onChange={e => onChange({ ...line, price: parseFloat(e.target.value) || 0 })}
|
onChange={e => onChange({ ...line, price: parseFloat(e.target.value) || 0 })}
|
||||||
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
|
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right disabled:bg-slate-50 disabled:text-slate-500" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<div className="text-xs text-slate-500">Сумма</div>
|
<div className="text-xs text-slate-500">Сумма</div>
|
||||||
|
|
@ -527,12 +563,14 @@ function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove,
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex gap-2">
|
{!readOnly && (
|
||||||
<button onClick={() => onSearch(line.name)}
|
<div className="mt-2 flex gap-2">
|
||||||
className="flex-1 inline-flex items-center justify-center gap-1 rounded border border-slate-300 px-2 py-1.5 text-xs hover:bg-slate-50">
|
<button onClick={() => onSearch(line.name)}
|
||||||
<Search className="w-3.5 h-3.5" /> Найти товар
|
className="flex-1 inline-flex items-center justify-center gap-1 rounded border border-slate-300 px-2 py-1.5 text-xs hover:bg-slate-50">
|
||||||
</button>
|
<Search className="w-3.5 h-3.5" /> Найти товар
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -595,14 +633,22 @@ function ProductSearchDialog({ q, onClose, onPick }: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Диалог «Создать приёмку»: выбор поставщика (поиск по БИН/имени),
|
/// Диалог «Создать приёмку».
|
||||||
/// склад, валюта. Создаём Draft, редиректим в SupplyEdit.
|
///
|
||||||
function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
|
/// Port sklad 06.10 §11 (адаптировано под multi-tenant):
|
||||||
invoiceId: string; supplierName: string
|
/// - Поставщик — НЕОБЯЗАТЕЛЬНЫЙ. По умолчанию черновик идёт на служебного
|
||||||
|
/// «Поставщик не указан»; никаких автоподборов и автозаведений.
|
||||||
|
/// - Имя из накладной (<c>supplierHint</c>) показываем подсказкой, в
|
||||||
|
/// комментарий документа оно НЕ пишется (видно на карточке приёмки).
|
||||||
|
/// - Поиск по уже существующим контрагентам есть, кнопки «создать нового» —
|
||||||
|
/// нет; создавать новых контрагентов владелец просил только из общего
|
||||||
|
/// справочника, не из накладной.
|
||||||
|
function ApplyDialog({ invoiceId, supplierHint, onClose, onDone }: {
|
||||||
|
invoiceId: string; supplierHint: string
|
||||||
onClose: () => void; onDone: (supplyId: string) => void
|
onClose: () => void; onDone: (supplyId: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [cp, setCp] = useState<CounterpartyHit | null>(null)
|
const [cp, setCp] = useState<CounterpartyHit | null>(null)
|
||||||
const [cpQ, setCpQ] = useState(supplierName)
|
const [cpQ, setCpQ] = useState('')
|
||||||
const [storeId, setStoreId] = useState('')
|
const [storeId, setStoreId] = useState('')
|
||||||
const [currencyId, setCurrencyId] = useState('')
|
const [currencyId, setCurrencyId] = useState('')
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
@ -645,16 +691,23 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
|
||||||
}, [currencies.data, currencyId])
|
}, [currencies.data, currencyId])
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!cp) { setErr('Выберите поставщика'); return }
|
|
||||||
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
|
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
|
||||||
setBusy(true); setErr(null)
|
setBusy(true); setErr(null)
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post<{ supplyId: string }>(`/api/purchases/invoices/${invoiceId}/apply`, {
|
const { data } = await api.post<{ supplyId: string }>(`/api/purchases/invoices/${invoiceId}/apply`, {
|
||||||
supplierId: cp.id, storeId, currencyId,
|
// Port sklad 06.10 §11: supplierId опционален. Сервер подставит
|
||||||
})
|
// служебного контрагента, если cp не выбран.
|
||||||
|
supplierId: cp?.id ?? null, storeId, currencyId,
|
||||||
|
}, { __silent: true } as never)
|
||||||
onDone(data.supplyId)
|
onDone(data.supplyId)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setErr(e instanceof Error ? e.message : 'Не удалось создать')
|
// Port sklad 06.10 §5: error → title/detail → (код). humanizeAxios
|
||||||
|
// уже вытягивает из errors[*][0] / detail / title, но здесь мы оставляем
|
||||||
|
// код статуса отдельно — на мобильном он помогает понять, что это сервер,
|
||||||
|
// а не связь.
|
||||||
|
const resp = (e as { response?: { data?: { error?: string; title?: string; detail?: string }, status?: number } }).response
|
||||||
|
const data = resp?.data ?? {}
|
||||||
|
setErr(data.error ?? data.detail ?? data.title ?? `Не удалось применить (${resp?.status ?? 'нет связи'})`)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -666,16 +719,16 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
|
||||||
<h3 className="font-semibold">Создать приёмку</h3>
|
<h3 className="font-semibold">Создать приёмку</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-500">Поставщик</label>
|
<label className="text-xs text-slate-500">Поставщик (необязательно)</label>
|
||||||
{cp ? (
|
{cp ? (
|
||||||
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
|
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
|
||||||
<span className="flex-1 text-sm">{cp.name}{cp.bin && <span className="text-xs text-slate-500 ml-2">БИН {cp.bin}</span>}</span>
|
<span className="flex-1 text-sm">{cp.name}{cp.bin && <span className="text-xs text-slate-500 ml-2">БИН {cp.bin}</span>}</span>
|
||||||
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
|
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">убрать</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<input autoFocus value={cpQ} onChange={e => setCpQ(e.target.value)}
|
<input autoFocus value={cpQ} onChange={e => setCpQ(e.target.value)}
|
||||||
placeholder="Начните вводить название или БИН…"
|
placeholder="Поиск по уже существующим контрагентам (можно пропустить)"
|
||||||
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
|
||||||
{cpSearch.data?.map(h => (
|
{cpSearch.data?.map(h => (
|
||||||
<button key={h.id} onClick={() => setCp(h)}
|
<button key={h.id} onClick={() => setCp(h)}
|
||||||
|
|
@ -683,11 +736,9 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
|
||||||
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
|
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{cpSearch.data && cpSearch.data.length === 0 && cpQ.length > 0 && (
|
{supplierHint?.trim() && (
|
||||||
<div className="pt-1 text-xs text-slate-500">
|
<div className="pt-1 text-xs text-slate-500">
|
||||||
Не найден.{' '}
|
В накладной: <b>«{supplierHint.trim()}»</b>. Если это новый поставщик — заведите его в справочнике и выберите выше; иначе оставьте пустым, черновик пойдёт на служебного «Поставщик не указан».
|
||||||
<a href={`/counterparties/new?name=${encodeURIComponent(cpQ)}&type=supplier`}
|
|
||||||
className="text-sky-600 underline">Создать «{cpQ}»</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -711,12 +762,14 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && <div className="text-xs text-red-600">{err}</div>}
|
{err && <div className="text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1.5"
|
||||||
|
data-testid="apply-error">{err}</div>}
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<button onClick={onClose} className="flex-1 rounded border border-slate-300 py-2 text-sm">Отмена</button>
|
<button onClick={onClose} className="flex-1 rounded border border-slate-300 py-2 text-sm">Отмена</button>
|
||||||
<button onClick={create} disabled={busy || !cp}
|
<button onClick={create} disabled={busy}
|
||||||
className="flex-[1.2] rounded bg-sky-600 text-white py-2 text-sm disabled:opacity-50">
|
className="flex-[1.2] rounded bg-sky-600 text-white py-2 text-sm disabled:opacity-50"
|
||||||
|
data-testid="apply-confirm">
|
||||||
{busy ? 'Создаём…' : 'Создать черновик'}
|
{busy ? 'Создаём…' : 'Создать черновик'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock, Images } from 'lucide-react'
|
import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock, Images } from 'lucide-react'
|
||||||
|
|
||||||
|
// Port sklad 06.10 §6: UTC-timestamp со сервера. Если «Z» потерялась —
|
||||||
|
// подшиваем, чтобы Date() не читала строку как «локальное время».
|
||||||
|
function parseServerDate(s: string): Date {
|
||||||
|
return new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : s + 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import PhotoSession, { type Shot } from '@/components/PhotoSession'
|
import PhotoSession, { type Shot } from '@/components/PhotoSession'
|
||||||
|
|
@ -231,7 +238,14 @@ export function InvoicesPage() {
|
||||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded ${S.cls}`}>
|
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded ${S.cls}`}>
|
||||||
<Icon className={`w-3.5 h-3.5 ${r.status === 'parsing' ? 'animate-spin' : ''}`} /> {S.label}
|
<Icon className={`w-3.5 h-3.5 ${r.status === 'parsing' ? 'animate-spin' : ''}`} /> {S.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-400">{new Date(r.uploadedAt).toLocaleString('ru')}</span>
|
{/* Port sklad 06.10 §6: UTC → локальное время, с подшивкой «Z»
|
||||||
|
если backend/axios её не отдали. На мобильном эта строка —
|
||||||
|
единственная метка «когда загрузили», карточка её читает. */}
|
||||||
|
<span className="text-xs text-slate-400"
|
||||||
|
data-testid={`uploaded-at-${r.id}`}>
|
||||||
|
Загружена {parseServerDate(r.uploadedAt).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-start justify-between gap-3">
|
<div className="mt-2 flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
274
tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs
Normal file
274
tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Порт поведения sklad 06.10 (разделы 1 / 10 / 11 handoff).
|
||||||
|
/// Проверяем:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>§1: SaveLines/Apply со всеми необязательными строковыми полями = null → 200
|
||||||
|
/// (неявный [Required] отключён, FluentValidation не ругается).</item>
|
||||||
|
/// <item>§11: Apply без выбранного поставщика → черновик на служебном
|
||||||
|
/// «Поставщик не указан», idempotent при повторных apply.</item>
|
||||||
|
/// <item>§10: GET /{id} applied-накладной отдаёт processedAt и supplyNumber.</item>
|
||||||
|
/// <item>§2 (owner-policy): после apply физические файлы фото-приёмки
|
||||||
|
/// удаляются (StoredPath пустая/не существует).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class InvoiceApplySklad0610Tests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public InvoiceApplySklad0610Tests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
private static string RandomBarcode() => $"4870{Random.Shared.Next(100000000, 999999999)}";
|
||||||
|
|
||||||
|
/// <summary>§1: клиент шлёт SaveLines, у строк все необязательные
|
||||||
|
/// текстовые поля null (barcode/unit/supplierCode/note/barcodeSource/name/productName).
|
||||||
|
/// До фикса ASP.NET MVC возвращал 400 «The Name field is required.».</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveLines_with_all_optional_fields_null_returns_204()
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
var actor = new ApiActor(http);
|
||||||
|
await actor.SignupAndLoginAsync($"inv06-null-{Guid.NewGuid():N}".Substring(0, 20));
|
||||||
|
|
||||||
|
var upId = await UploadMinimalFileAsync(http);
|
||||||
|
|
||||||
|
var resp = await http.PutAsJsonAsync($"/api/purchases/invoices/{upId}/lines", new
|
||||||
|
{
|
||||||
|
lines = new object[]
|
||||||
|
{
|
||||||
|
new {
|
||||||
|
barcode = (string?)null,
|
||||||
|
supplierCode = (string?)null,
|
||||||
|
name = (string?)null,
|
||||||
|
quantity = 1,
|
||||||
|
unit = (string?)null,
|
||||||
|
price = 100m,
|
||||||
|
sum = (decimal?)null,
|
||||||
|
vat = (decimal?)null,
|
||||||
|
productId = (Guid?)null,
|
||||||
|
productName = (string?)null,
|
||||||
|
matchedBy = (string?)null,
|
||||||
|
matchScore = 0,
|
||||||
|
photo = (int?)null,
|
||||||
|
barcodeSource = (string?)null,
|
||||||
|
note = (string?)null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
supplierName = (string?)null,
|
||||||
|
invoiceNumber = (string?)null,
|
||||||
|
invoiceDate = (DateTime?)null,
|
||||||
|
totalSum = (decimal?)null,
|
||||||
|
});
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.NoContent, $"body: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§11: apply без явного SupplierId использует служебного
|
||||||
|
/// «Поставщик не указан». При повторном apply (другая накладная) того
|
||||||
|
/// же сценария — используется ТОТ ЖЕ контрагент.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_without_supplier_uses_placeholder_counterparty_and_is_idempotent()
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
var actor = new ApiActor(http);
|
||||||
|
await actor.SignupAndLoginAsync($"inv06-ph-{Guid.NewGuid():N}".Substring(0, 20));
|
||||||
|
var refs = await actor.LoadRefsAsync();
|
||||||
|
|
||||||
|
// Создаём product и его штрихкод — чтобы в приёмке была реальная строка.
|
||||||
|
var bc1 = RandomBarcode();
|
||||||
|
var p1 = await actor.CreateProductAsync(refs, $"ProdSklad-{Guid.NewGuid():N}", 150m, bc1);
|
||||||
|
|
||||||
|
// Две независимые накладные → два apply на placeholder → один контрагент.
|
||||||
|
var apply1 = await ApplyMinimalInvoiceAsync(http, actor, refs, p1, bc1, supplierId: null);
|
||||||
|
var apply2 = await ApplyMinimalInvoiceAsync(http, actor, refs, p1, bc1, supplierId: null);
|
||||||
|
|
||||||
|
apply1.supplyId.Should().NotBeEmpty();
|
||||||
|
apply2.supplyId.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
// Проверяем, что обе Supply ссылаются на один и тот же (служебный) контрагент.
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var sup1 = await db.Supplies.IgnoreQueryFilters().FirstAsync(s => s.Id == apply1.supplyId);
|
||||||
|
var sup2 = await db.Supplies.IgnoreQueryFilters().FirstAsync(s => s.Id == apply2.supplyId);
|
||||||
|
sup1.SupplierId.Should().Be(sup2.SupplierId);
|
||||||
|
|
||||||
|
var cp = await db.Counterparties.IgnoreQueryFilters().FirstAsync(c => c.Id == sup1.SupplierId);
|
||||||
|
cp.IsPlaceholder.Should().BeTrue();
|
||||||
|
cp.Name.Should().Be("Поставщик не указан");
|
||||||
|
|
||||||
|
// И в списке контрагентов этот плейсхолдер — последним, c IsPlaceholder=true.
|
||||||
|
var listJson = await actor.GetJsonAsync("/api/catalog/counterparties?pageSize=100");
|
||||||
|
var items = listJson.GetProperty("items").EnumerateArray().ToList();
|
||||||
|
items.Should().NotBeEmpty();
|
||||||
|
items[^1].GetProperty("isPlaceholder").GetBoolean().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§11: Delete и Update служебного контрагента — 409 Conflict.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_and_update_of_placeholder_counterparty_returns_409()
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
var actor = new ApiActor(http);
|
||||||
|
await actor.SignupAndLoginAsync($"inv06-pdel-{Guid.NewGuid():N}".Substring(0, 20));
|
||||||
|
var refs = await actor.LoadRefsAsync();
|
||||||
|
var bc = RandomBarcode();
|
||||||
|
var p = await actor.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 50m, bc);
|
||||||
|
await ApplyMinimalInvoiceAsync(http, actor, refs, p, bc, supplierId: null);
|
||||||
|
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var cp = await db.Counterparties.IgnoreQueryFilters().FirstAsync(c => c.IsPlaceholder);
|
||||||
|
|
||||||
|
var delResp = await http.DeleteAsync($"/api/catalog/counterparties/{cp.Id}");
|
||||||
|
delResp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
|
|
||||||
|
var updResp = await http.PutAsJsonAsync($"/api/catalog/counterparties/{cp.Id}", new
|
||||||
|
{
|
||||||
|
name = "ПопыткаПереименовать",
|
||||||
|
legalName = (string?)null, type = CounterpartyType.LegalEntity,
|
||||||
|
bin = (string?)null, iin = (string?)null, taxNumber = (string?)null,
|
||||||
|
countryId = (Guid?)null, address = (string?)null, phone = (string?)null,
|
||||||
|
email = (string?)null, bankName = (string?)null, bankAccount = (string?)null,
|
||||||
|
bik = (string?)null, contactPerson = (string?)null, notes = (string?)null,
|
||||||
|
});
|
||||||
|
updResp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§10 / owner-policy: после apply стороны-держатели файлы
|
||||||
|
/// удаляются (StoredPath очищен или папка пустая), detail отдаёт
|
||||||
|
/// processedAt + supplyNumber, status=applied.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Applied_invoice_detail_has_processed_at_and_files_cleaned_up()
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
var actor = new ApiActor(http);
|
||||||
|
await actor.SignupAndLoginAsync($"inv06-pa-{Guid.NewGuid():N}".Substring(0, 20));
|
||||||
|
var refs = await actor.LoadRefsAsync();
|
||||||
|
var bc = RandomBarcode();
|
||||||
|
var p = await actor.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, bc);
|
||||||
|
var apply = await ApplyMinimalInvoiceAsync(http, actor, refs, p, bc, supplierId: null);
|
||||||
|
|
||||||
|
var detail = await actor.GetJsonAsync($"/api/purchases/invoices/{apply.uploadId}");
|
||||||
|
detail.GetProperty("status").GetString().Should().Be("applied");
|
||||||
|
detail.GetProperty("processedAt").GetDateTime().Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(60));
|
||||||
|
detail.GetProperty("supplyId").GetGuid().Should().Be(apply.supplyId);
|
||||||
|
detail.GetProperty("supplyNumber").GetString().Should().StartWith("П-");
|
||||||
|
// Файлы снесены: hasFiles=false (папка/файл не существует).
|
||||||
|
detail.GetProperty("hasFiles").GetBoolean().Should().BeFalse();
|
||||||
|
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var up = await db.InvoiceUploads.IgnoreQueryFilters().FirstAsync(x => x.Id == apply.uploadId);
|
||||||
|
// StoredPath очищен или файл отсутствует — DeleteStored выполнил работу.
|
||||||
|
var pathAlive = !string.IsNullOrEmpty(up.StoredPath)
|
||||||
|
&& (System.IO.File.Exists(up.StoredPath) || System.IO.Directory.Exists(up.StoredPath));
|
||||||
|
pathAlive.Should().BeFalse("после apply физические файлы фото-приёмки удаляются (owner-policy)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§5: ApplyDto с Guid.Empty StoreId → ValidationProblemDetails в
|
||||||
|
/// нашем формате (errors.StoreId[0] = «Не указан склад.»), humanizeError
|
||||||
|
/// покажет это пользователю через errors[*][0].</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_with_empty_store_returns_validation_problem_details()
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
var actor = new ApiActor(http);
|
||||||
|
await actor.SignupAndLoginAsync($"inv06-val-{Guid.NewGuid():N}".Substring(0, 20));
|
||||||
|
|
||||||
|
var upId = await UploadMinimalFileAsync(http);
|
||||||
|
// Чтобы apply добрался до валидации StoreId — у нас уже должен быть ParsedJson
|
||||||
|
// непустой. Подложим одну валидную-пустую строку (sum=0) через SaveLines.
|
||||||
|
var save = await http.PutAsJsonAsync($"/api/purchases/invoices/{upId}/lines", new
|
||||||
|
{
|
||||||
|
lines = new object[]
|
||||||
|
{
|
||||||
|
new { quantity = 0m, price = 0m, matchScore = 0 },
|
||||||
|
},
|
||||||
|
supplierName = (string?)null, invoiceNumber = (string?)null,
|
||||||
|
invoiceDate = (DateTime?)null, totalSum = (decimal?)null,
|
||||||
|
});
|
||||||
|
save.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var resp = await http.PostAsJsonAsync($"/api/purchases/invoices/{upId}/apply", new
|
||||||
|
{
|
||||||
|
supplierId = (Guid?)null,
|
||||||
|
storeId = Guid.Empty,
|
||||||
|
currencyId = Guid.Empty,
|
||||||
|
notes = (string?)null,
|
||||||
|
});
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
json.GetProperty("errors").GetProperty("StoreId").EnumerateArray()
|
||||||
|
.Select(e => e.GetString()).Should().Contain(s => s!.Contains("склад"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static async Task<Guid> UploadMinimalFileAsync(HttpClient http)
|
||||||
|
{
|
||||||
|
using var form = new System.Net.Http.MultipartFormDataContent();
|
||||||
|
var fp = new System.Net.Http.ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("x"));
|
||||||
|
fp.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg");
|
||||||
|
form.Add(fp, "files", "x.jpg");
|
||||||
|
var resp = await http.PostAsync("/api/purchases/invoices/upload", form);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return body.GetProperty("ids").EnumerateArray().First().GetGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(Guid uploadId, Guid supplyId)> ApplyMinimalInvoiceAsync(
|
||||||
|
HttpClient http, ApiActor actor, ApiActor.Refs refs, string productId, string barcode, Guid? supplierId)
|
||||||
|
{
|
||||||
|
var upId = await UploadMinimalFileAsync(http);
|
||||||
|
// Подкладываем одну распознанную строку с продуктом — минимум, чтобы Apply не отбил.
|
||||||
|
var save = await http.PutAsJsonAsync($"/api/purchases/invoices/{upId}/lines", new
|
||||||
|
{
|
||||||
|
lines = new object[]
|
||||||
|
{
|
||||||
|
new {
|
||||||
|
barcode,
|
||||||
|
supplierCode = (string?)null,
|
||||||
|
name = $"Строка-{Guid.NewGuid():N}",
|
||||||
|
quantity = 2m,
|
||||||
|
unit = (string?)null,
|
||||||
|
price = 100m,
|
||||||
|
sum = (decimal?)null,
|
||||||
|
vat = (decimal?)null,
|
||||||
|
productId,
|
||||||
|
productName = "ignored",
|
||||||
|
matchedBy = "barcode",
|
||||||
|
matchScore = 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
supplierName = "ТОО Из Накладной",
|
||||||
|
invoiceNumber = $"ИН-{Random.Shared.Next(1000, 9999)}",
|
||||||
|
invoiceDate = DateTime.UtcNow.Date,
|
||||||
|
totalSum = 200m,
|
||||||
|
});
|
||||||
|
save.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var resp = await http.PostAsJsonAsync($"/api/purchases/invoices/{upId}/apply", new
|
||||||
|
{
|
||||||
|
supplierId,
|
||||||
|
storeId = Guid.Parse(refs.StoreId),
|
||||||
|
currencyId = Guid.Parse(refs.CurrencyId),
|
||||||
|
notes = (string?)null,
|
||||||
|
});
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK, $"apply body: {body}");
|
||||||
|
var json = JsonDocument.Parse(body).RootElement;
|
||||||
|
return (upId, json.GetProperty("supplyId").GetGuid());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue