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

Основное:

* 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:
nns 2026-10-07 19:50:03 +05:00
parent 99bf6c9f3e
commit e45600df4b
16 changed files with 804 additions and 92 deletions

152
docs/sklad-0610-progress.md Normal file
View 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'ов не нужно.

View file

@ -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) |

View file

@ -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»

View file

@ -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;

View file

@ -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; }

View file

@ -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()

View file

@ -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 =>

View file

@ -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);

View file

@ -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;
} }

View file

@ -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; }
} }

View file

@ -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)

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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,6 +418,7 @@ export function InvoiceReviewPage() {
</div> </div>
{/* bottom */} {/* bottom */}
{!applied && (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3"> <div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-5xl mx-auto flex items-center gap-2"> <div className="max-w-5xl mx-auto flex items-center gap-2">
<span className="text-xs text-slate-500 shrink-0 w-32" <span className="text-xs text-slate-500 shrink-0 w-32"
@ -408,12 +429,13 @@ export function InvoiceReviewPage() {
{saveState === 'error' && <span className="block text-red-600">не сохранилось</span>} {saveState === 'error' && <span className="block text-red-600">не сохранилось</span>}
</span> </span>
<button onClick={() => setApplyOpen(true)} <button onClick={() => setApplyOpen(true)}
disabled={d.status === 'applied' || matched === 0} disabled={matched === 0}
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"> 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">
<Check className="w-4 h-4" /> Создать приёмку <Check className="w-4 h-4" /> Создать приёмку
</button> </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>
: readOnly
? <div className="text-xs text-slate-400">Товар не сопоставлен</div>
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите, отсканируйте или создайте</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>
{!readOnly && (
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button> <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>
{!readOnly && (
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<button onClick={() => onSearch(line.name)} <button onClick={() => onSearch(line.name)}
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"> 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">
<Search className="w-3.5 h-3.5" /> Найти товар <Search className="w-3.5 h-3.5" /> Найти товар
</button> </button>
</div> </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>

View file

@ -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">

View 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());
}
}