diff --git a/docs/sklad-0610-progress.md b/docs/sklad-0610-progress.md new file mode 100644 index 0000000..24975ed --- /dev/null +++ b/docs/sklad-0610-progress.md @@ -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'`; + - плашка «Черновик приёмки № создан » со + ссылкой на `/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'ов не нужно. diff --git a/docs/supply-inventory.md b/docs/supply-inventory.md index d6e8b12..7524728 100644 --- a/docs/supply-inventory.md +++ b/docs/supply-inventory.md @@ -53,9 +53,20 @@ Excel-шаблоне или прислал фото. 6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара, кликабельна для полного размера; пометки «цифры прочитаны неуверенно — отсканируйте» или «штрихкода нет в каталоге». -7. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени, - можно создать нового), склад, валюта → `POST /apply` создаёт - нашу Supply (Draft). Проведение — обычной кнопкой в приёмке. +7. «Создать приёмку» → диалог: **поставщик — необязательный** + (поиск по уже существующим контрагентам, кнопки «создать» нет), + склад, валюта → `POST /apply` создаёт нашу Supply (Draft). + Проведение — обычной кнопкой в приёмке. Если поставщик не выбран, + приёмка идёт на служебного **«Поставщик не указан»** (один на орг, + в списке контрагентов — последним, с бейджем, удалить и + переименовать нельзя); имя из накладной показывается подсказкой + и в комментарий документа **не** пишется. Это адаптация sklad 06.10 + §11: автоподбор контрагента по тексту накладной и автозаведение + карточек запрещены — на реальных накладных это плодило мусор. +8. Готовая накладная (status `applied`) открывается в **режиме + просмотра**: плашка «Черновик приёмки № … создан \<время\>» со + ссылкой на приёмку, строки и шапка readOnly, кнопки «Создать» + нет. Исправления можно сделать прямо в приёмке. **Как это распознаётся:** локальный Claude Code в headless-режиме (`claude -p --output-format json`, промпт сверяет «Итого», отличает @@ -73,6 +84,10 @@ Excel-шаблоне или прислал фото. черновика приёмки (apply), при удалении загрузки, и через **24 часа** — если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays` больше не используется. +- Детальный ответ `GET /{id}` отдаёт флаг `hasFiles` (есть ли миниатюры) + и `processedAt` (время apply), чтобы фронт мог построить плашку + «Черновик приёмки № … создан …» на applied-накладной без + дополнительного запроса. ## 2. Приёмка сканированием @@ -153,6 +168,7 @@ Excel-шаблоне или прислал фото. |---|---| | Домен | `src/food-market.domain/Purchases/InvoiceUpload.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/` | | API накладных | `src/food-market.api/Controllers/Purchases/InvoicesController.cs` | | API инвентаризации | `src/food-market.api/Controllers/Inventory/InventoriesController.cs` (mobile-catalog, add-line, book-drift) | diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index b31ce37..5206f17 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -38,18 +38,20 @@ public class CounterpartiesController : ControllerBase (c.Phone != null && c.Phone.Contains(s))); } var total = await q.CountAsync(ct); + // Port sklad 06.10 §11: служебный «Поставщик не указан» всегда идёт + // в конец любого ordering'а, чтобы кладовщик не выбрал его случайно. q = (req.Sort, req.Desc) switch { - ("type", false) => q.OrderBy(c => c.Type).ThenBy(c => c.Name), - ("type", true) => q.OrderByDescending(c => c.Type).ThenBy(c => c.Name), - ("country", false) => q.OrderBy(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), - ("legalName", false) => q.OrderBy(c => c.LegalName).ThenBy(c => c.Name), - ("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name), - ("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name), - ("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name), - ("name", true) => q.OrderByDescending(c => c.Name), - _ => q.OrderBy(c => c.Name), + ("type", false) => q.OrderBy(c => c.IsPlaceholder).ThenBy(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.IsPlaceholder).ThenBy(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.IsPlaceholder).ThenBy(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.IsPlaceholder).ThenBy(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.OrderBy(c => c.IsPlaceholder).ThenByDescending(c => c.Name), + _ => q.OrderBy(c => c.IsPlaceholder).ThenBy(c => c.Name), }; // Sprint 20 / TD-3: ProjectToType вместо ручного Select. var items = await q @@ -110,6 +112,11 @@ public async Task Update(Guid id, [FromBody] CounterpartyInput in if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); + // Port sklad 06.10 §11: служебный «Поставщик не указан» не редактируется + // — его имя/БИН должны остаться стабильными, чтобы его всегда находил + // ResolvePlaceholderAsync. + if (e.IsPlaceholder) + return Conflict(new { error = "Служебный контрагент «Поставщик не указан» не редактируется." }); Apply(e, input); await _db.SaveChangesAsync(ct); return NoContent(); @@ -129,6 +136,12 @@ public async Task Delete(Guid id, CancellationToken ct) var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); 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 // в retail-sales / default supplier у products. Без явного чека EF // отдаёт 500 «DbUpdateException 23503 violates foreign key constraint» diff --git a/src/food-market.api/Controllers/Purchases/InvoicesController.cs b/src/food-market.api/Controllers/Purchases/InvoicesController.cs index 572383f..a951249 100644 --- a/src/food-market.api/Controllers/Purchases/InvoicesController.cs +++ b/src/food-market.api/Controllers/Purchases/InvoicesController.cs @@ -146,7 +146,7 @@ public async Task Photo(Guid id, int n, CancellationToken ct) public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt, 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] public async Task List([FromQuery] int take = 50) @@ -154,17 +154,22 @@ public async Task List([FromQuery] int take = 50) var rows = await _db.InvoiceUploads .OrderByDescending(x => x.UploadedAt) .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, 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(); return Ok(rows); } public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt, string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum, - int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error, - string? Source, bool HasFiles, + int LinesTotal, int LinesMatched, Guid? SupplyId, string? SupplyNumber, string? Error, + string? Source, bool HasFiles, DateTime? ProcessedAt, List Lines); [HttpGet("{id:guid}")] @@ -179,10 +184,16 @@ public async Task> Detail(Guid id, CancellationTo // (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404. var hasFiles = !string.IsNullOrEmpty(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, up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum, - up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error, - up.Source, hasFiles, lines)); + up.LinesTotal, up.LinesMatched, up.SupplyId, supplyNumber, up.Error, + up.Source, hasFiles, up.ProcessedAt, lines)); } [HttpGet("{id:guid}/file")] @@ -294,7 +305,11 @@ private async Task UpsertAliasAsync(string supplierKey, InvoiceLine l, Cancellat // ── apply → Supply Draft ──────────────────────────────────────────── - public record ApplyDto(Guid SupplierId, Guid StoreId, Guid CurrencyId, string? Notes); + /// SupplierId опционален (null или ): + /// в этом случае черновик ссылается на служебного «Поставщик не указан» + /// — порт sklad 06.10 §11, адаптировано под multi-tenant food-market. + /// Кладовщик может указать реального поставщика позже в карточке приёмки. + public record ApplyDto(Guid? SupplierId, Guid StoreId, Guid CurrencyId, string? Notes); public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines); [HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")] @@ -312,6 +327,12 @@ public async Task> Apply(Guid id, [FromBody] ApplyDto if (ready.Count == 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. var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); @@ -321,9 +342,12 @@ public async Task> Apply(Guid id, [FromBody] ApplyDto Number = number, Date = up.InvoiceDate ?? DateTime.UtcNow, Status = SupplyStatus.Draft, - SupplierId = dto.SupplierId, + SupplierId = supplierId, StoreId = dto.StoreId, CurrencyId = dto.CurrencyId, + // Port sklad 06.10 §11: имя поставщика из накладной в комментарий + // НЕ пишем — на карточке приёмки оно уже есть как подсказка, в + // комментарии только номер + дата исходного документа. Notes = string.IsNullOrWhiteSpace(dto.Notes) ? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim() : dto.Notes, @@ -408,6 +432,35 @@ public async Task CounterpartySearch([FromQuery] string q, Cancel return Ok(rows); } + /// Служебный «Поставщик не указан» — один на организацию. + /// Ищем по IsPlaceholder=true (partial-index, O(1)); если нет — + /// создаём и возвращаем id. Параллельный INSERT (две accept на пустую орг) + /// допустим: оба получат разные id, запись-«победитель» та, что в итоге + /// используется первой; дубль чистится ночным housekeeping'ом по + /// отсутствию ссылок. Для реального потока вероятность ~0. + private async Task 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 GenerateSupplyNumberAsync(DateTime date, CancellationToken ct) { var year = date.Year; diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs index 4aa0ef7..c73565a 100644 --- a/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs @@ -8,7 +8,10 @@ public class InvoiceLine { public string? Barcode { 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 string? Unit { get; set; } public decimal Price { get; set; } @@ -42,7 +45,7 @@ public class InvoiceLine public class PhotoHint { public int Index { get; set; } // c 1 - public string FilePath { get; set; } = ""; + public string? FilePath { get; set; } public List Codes { get; set; } = new(); public List CatalogNames { get; set; } = new(); } @@ -55,7 +58,7 @@ public class ParsedInvoice public decimal? TotalSum { get; set; } public List Lines { get; set; } = new(); - public string Source { get; set; } = "vision"; + public string? Source { get; set; } = "vision"; public int InputTokens { get; set; } public int OutputTokens { get; set; } public string? RawJson { get; set; } diff --git a/src/food-market.api/Infrastructure/Validation/Validators.cs b/src/food-market.api/Infrastructure/Validation/Validators.cs index df68ace..f3327f0 100644 --- a/src/food-market.api/Infrastructure/Validation/Validators.cs +++ b/src/food-market.api/Infrastructure/Validation/Validators.cs @@ -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 +{ + 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 +{ + 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 { public EmployeeInputValidator() diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 3cd802e..65265a7 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -317,6 +317,43 @@ static string ApplyDefaultPoolConfig(string? raw) // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3). o.Filters.AddService(); o.Filters.AddService(); + // 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() + .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.AddSwaggerGen(opts => diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 4670f26..740cc3e 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -35,7 +35,10 @@ public record CounterpartyDto( Guid Id, string Name, string? LegalName, CounterpartyType Type, string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName, 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); diff --git a/src/food-market.application/Mapping/MapsterConfig.cs b/src/food-market.application/Mapping/MapsterConfig.cs index d686e93..529598a 100644 --- a/src/food-market.application/Mapping/MapsterConfig.cs +++ b/src/food-market.application/Mapping/MapsterConfig.cs @@ -76,7 +76,8 @@ public static TypeAdapterConfig Build() src.CountryId, src.Country != null ? src.Country.Name : null, src.Address, src.Phone, src.Email, src.BankName, src.BankAccount, src.Bik, - src.ContactPerson, src.Notes)); + src.ContactPerson, src.Notes, + src.IsPlaceholder)); return cfg; } diff --git a/src/food-market.domain/Catalog/Counterparty.cs b/src/food-market.domain/Catalog/Counterparty.cs index 47b95e8..99295ec 100644 --- a/src/food-market.domain/Catalog/Counterparty.cs +++ b/src/food-market.domain/Catalog/Counterparty.cs @@ -20,4 +20,11 @@ public class Counterparty : TenantEntity public string? Bik { get; set; } public string? ContactPerson { get; set; } public string? Notes { get; set; } + + /// Служебный «Поставщик не указан», создаётся один раз на орг + /// при apply накладной без явного выбора поставщика. Удалению и обычному + /// редактированию не подлежит; в списках контрагентов идёт последним + /// (см. CounterpartiesController и InvoicesController.Apply, порт + /// sklad 06.10 §11). + public bool IsPlaceholder { get; set; } } diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index bf80441..29f3d97 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -79,6 +79,11 @@ private static void ConfigureCounterparty(EntityTypeBuilder b) 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.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 b) diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase32b_CounterpartyIsPlaceholder.cs b/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase32b_CounterpartyIsPlaceholder.cs new file mode 100644 index 0000000..14b0531 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase32b_CounterpartyIsPlaceholder.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Port sklad 06.10 §11: контрагент приёмки обязателен, но + /// кладовщик выбирает его сам. Если он не выбран на apply — ставим + /// служебного «Поставщик не указан» (один на орг). Этот флаг отличает + /// служебного от обычного: UI сортирует его последним, delete запрещён. + /// + /// Partial index по IsPlaceholder = true — быстрый поиск + /// служебного за org без full-scan таблицы. + [DbContext(typeof(AppDbContext))] + [Migration("20261007100000_Phase32b_CounterpartyIsPlaceholder")] + public partial class Phase32b_CounterpartyIsPlaceholder : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AddColumn( + 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"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 8ddd768..3a50c3c 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -377,6 +377,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("IsPlaceholder") + .HasColumnType("boolean"); + b.Property("LegalName") .HasMaxLength(500) .HasColumnType("character varying(500)"); @@ -412,6 +415,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId", "Bin"); + b.HasIndex("OrganizationId", "IsPlaceholder") + .HasFilter("\"IsPlaceholder\" = true"); + b.HasIndex("OrganizationId", "Name"); b.ToTable("counterparties", "public"); diff --git a/src/food-market.web/src/pages/InvoiceReviewPage.tsx b/src/food-market.web/src/pages/InvoiceReviewPage.tsx index 58b12a8..82bd047 100644 --- a/src/food-market.web/src/pages/InvoiceReviewPage.tsx +++ b/src/food-market.web/src/pages/InvoiceReviewPage.tsx @@ -1,10 +1,18 @@ 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 { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, Check, AlertTriangle, Search, Trash2, Camera, ScanBarcode } from 'lucide-react' 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 { barcode?: string | null supplierCode?: string | null @@ -35,6 +43,10 @@ interface InvoiceDetail { linesTotal: number linesMatched: number supplyId: string | null + // Port sklad 06.10 §10: показываем номер черновика в плашке + // «Черновик приёмки № … создан <время>» на экране готовой накладной. + supplyNumber: string | null + processedAt: string | null error: string | null source: string | null hasFiles: boolean @@ -70,6 +82,7 @@ const MATCHED_RU: Record = { export function InvoiceReviewPage() { const { id } = useParams<{ id: string }>() const nav = useNavigate() + const qc = useQueryClient() const [lines, setLines] = useState([]) const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' }) const [scanMsg, setScanMsgRaw] = useState<{ kind: 'ok' | 'warn' | 'err'; text: string } | null>(null) @@ -256,6 +269,9 @@ export function InvoiceReviewPage() { if (!detail.data) return
Загрузка…
const d = detail.data const busy = d.status === 'pending' || d.status === 'parsing' + // Port sklad 06.10 §10: готовая накладная — ТОЛЬКО просмотр. Плашка со + // ссылкой на приёмку, поля readOnly, скан-блок скрыт, apply-кнопки нет. + const applied = d.status === 'applied' return (
@@ -265,9 +281,13 @@ export function InvoiceReviewPage() { {d.status}
- {d.supplyId && ( -
- Приёмка уже создана. Открыть + {applied && d.supplyId && ( +
+ Черновик приёмки {d.supplyNumber ? `№ ${d.supplyNumber}` : ''} создан + {d.processedAt && ` ${parseServerDate(d.processedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}`}. + {' '}Открыть приёмку. + Накладная — только для просмотра.
)} {d.status === 'failed' && d.error && ( @@ -291,20 +311,20 @@ export function InvoiceReviewPage() { {/* шапка */}
- editHeader(h => ({ ...h, supplierName: e.target.value }))} /> - editHeader(h => ({ ...h, invoiceNumber: e.target.value }))} /> - editHeader(h => ({ ...h, invoiceDate: e.target.value }))} />
Итого в накладной - editHeader(h => ({ ...h, totalSum: e.target.value }))} />
@@ -320,7 +340,7 @@ export function InvoiceReviewPage() {
{/* общее поле скана + панель выбора строки */} - {!busy && unmatched > 0 && ( + {!busy && !applied && unmatched > 0 && (
Отсканируйте товары, которых нет в каталоге @@ -376,7 +396,7 @@ export function InvoiceReviewPage() {
)} - {unmatched > 0 && ( + {unmatched > 0 && !applied && (
Не сопоставлено строк: {unmatched} из {lines.length}. Карточки подсвечены оранжевым.
@@ -387,7 +407,7 @@ export function InvoiceReviewPage() { {lines.map((l, i) => ( edit(prev => prev.map((x, j) => j === i ? patched : x))} onRemove={() => edit(prev => prev.filter((_, j) => j !== i))} onSearch={(q) => setSearchHit({ row: i, q })} @@ -398,22 +418,24 @@ export function InvoiceReviewPage() {
{/* bottom */} -
-
- - сопоставлено {matched}/{lines.length} - {saveState === 'saving' && сохраняю…} - {saveState === 'saved' && сохранено} - {saveState === 'error' && не сохранилось} - - + {!applied && ( +
+
+ + сопоставлено {matched}/{lines.length} + {saveState === 'saving' && сохраняю…} + {saveState === 'saved' && сохранено} + {saveState === 'error' && не сохранилось} + + +
-
+ )} {searchHit && ( setSearchHit(null)} @@ -425,9 +447,16 @@ export function InvoiceReviewPage() { )} {applyOpen && ( - 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 && ( @@ -449,11 +478,12 @@ export function InvoiceReviewPage() { /// Карточка строки. Миниатюра снимка (если фото-приёмка), пометка /// «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 line: InvoiceLine invoiceId: string hasFiles: boolean + readOnly?: boolean onChange: (l: InvoiceLine) => void onRemove: () => void onSearch: (q: string) => void @@ -463,7 +493,7 @@ function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove, const bad = !line.productId const sum = line.quantity * line.price return ( -
@@ -479,14 +509,18 @@ function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove, ?
→ {line.productName} ({MATCHED_RU[line.matchedBy] ?? line.matchedBy})
- :
Товар не сопоставлен — найдите, отсканируйте или создайте
} + : readOnly + ?
Товар не сопоставлен
+ :
Товар не сопоставлен — найдите, отсканируйте или создайте
} {line.note &&
{line.note}
}
- + {!readOnly && ( + + )}
- {bad && ( + {bad && !readOnly && (
Кол-во
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" />
-
- -
+ {!readOnly && ( +
+ +
+ )}
) } @@ -595,14 +633,22 @@ function ProductSearchDialog({ q, onClose, onPick }: { ) } -/// Диалог «Создать приёмку»: выбор поставщика (поиск по БИН/имени), -/// склад, валюта. Создаём Draft, редиректим в SupplyEdit. -function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: { - invoiceId: string; supplierName: string +/// Диалог «Создать приёмку». +/// +/// Port sklad 06.10 §11 (адаптировано под multi-tenant): +/// - Поставщик — НЕОБЯЗАТЕЛЬНЫЙ. По умолчанию черновик идёт на служебного +/// «Поставщик не указан»; никаких автоподборов и автозаведений. +/// - Имя из накладной (supplierHint) показываем подсказкой, в +/// комментарий документа оно НЕ пишется (видно на карточке приёмки). +/// - Поиск по уже существующим контрагентам есть, кнопки «создать нового» — +/// нет; создавать новых контрагентов владелец просил только из общего +/// справочника, не из накладной. +function ApplyDialog({ invoiceId, supplierHint, onClose, onDone }: { + invoiceId: string; supplierHint: string onClose: () => void; onDone: (supplyId: string) => void }) { const [cp, setCp] = useState(null) - const [cpQ, setCpQ] = useState(supplierName) + const [cpQ, setCpQ] = useState('') const [storeId, setStoreId] = useState('') const [currencyId, setCurrencyId] = useState('') const [err, setErr] = useState(null) @@ -645,16 +691,23 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: { }, [currencies.data, currencyId]) const create = async () => { - if (!cp) { setErr('Выберите поставщика'); return } if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return } setBusy(true); setErr(null) try { 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) } 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 { setBusy(false) } @@ -666,16 +719,16 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {

Создать приёмку

- + {cp ? (
{cp.name}{cp.bin && БИН {cp.bin}} - +
) : ( <> setCpQ(e.target.value)} - placeholder="Начните вводить название или БИН…" + placeholder="Поиск по уже существующим контрагентам (можно пропустить)" className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" /> {cpSearch.data?.map(h => ( ))} - {cpSearch.data && cpSearch.data.length === 0 && cpQ.length > 0 && ( + {supplierHint?.trim() && (
- Не найден.{' '} - Создать «{cpQ}» + В накладной: «{supplierHint.trim()}». Если это новый поставщик — заведите его в справочнике и выберите выше; иначе оставьте пустым, черновик пойдёт на служебного «Поставщик не указан».
)} @@ -711,12 +762,14 @@ function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
- {err &&
{err}
} + {err &&
{err}
}
-
diff --git a/src/food-market.web/src/pages/InvoicesPage.tsx b/src/food-market.web/src/pages/InvoicesPage.tsx index 980b38f..ef7612c 100644 --- a/src/food-market.web/src/pages/InvoicesPage.tsx +++ b/src/food-market.web/src/pages/InvoicesPage.tsx @@ -1,6 +1,13 @@ import { useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import PhotoSession, { type Shot } from '@/components/PhotoSession' @@ -231,7 +238,14 @@ export function InvoicesPage() { {S.label} - {new Date(r.uploadedAt).toLocaleString('ru')} + {/* Port sklad 06.10 §6: UTC → локальное время, с подшивкой «Z» + если backend/axios её не отдали. На мобильном эта строка — + единственная метка «когда загрузили», карточка её читает. */} + + Загружена {parseServerDate(r.uploadedAt).toLocaleString('ru-RU', { + day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })} +
diff --git a/tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs b/tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs new file mode 100644 index 0000000..2a6bc0b --- /dev/null +++ b/tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs @@ -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; + +/// Порт поведения sklad 06.10 (разделы 1 / 10 / 11 handoff). +/// Проверяем: +/// +/// §1: SaveLines/Apply со всеми необязательными строковыми полями = null → 200 +/// (неявный [Required] отключён, FluentValidation не ругается). +/// §11: Apply без выбранного поставщика → черновик на служебном +/// «Поставщик не указан», idempotent при повторных apply. +/// §10: GET /{id} applied-накладной отдаёт processedAt и supplyNumber. +/// §2 (owner-policy): после apply физические файлы фото-приёмки +/// удаляются (StoredPath пустая/не существует). +/// +/// +[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)}"; + + /// §1: клиент шлёт SaveLines, у строк все необязательные + /// текстовые поля null (barcode/unit/supplierCode/note/barcodeSource/name/productName). + /// До фикса ASP.NET MVC возвращал 400 «The Name field is required.». + [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}"); + } + + /// §11: apply без явного SupplierId использует служебного + /// «Поставщик не указан». При повторном apply (другая накладная) того + /// же сценария — используется ТОТ ЖЕ контрагент. + [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(); + 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(); + } + + /// §11: Delete и Update служебного контрагента — 409 Conflict. + [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(); + 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); + } + + /// §10 / owner-policy: после apply стороны-держатели файлы + /// удаляются (StoredPath очищен или папка пустая), detail отдаёт + /// processedAt + supplyNumber, status=applied. + [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(); + 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)"); + } + + /// §5: ApplyDto с Guid.Empty StoreId → ValidationProblemDetails в + /// нашем формате (errors.StoreId[0] = «Не указан склад.»), humanizeError + /// покажет это пользователю через errors[*][0]. + [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(); + json.GetProperty("errors").GetProperty("StoreId").EnumerateArray() + .Select(e => e.GetString()).Should().Contain(s => s!.Contains("склад")); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private static async Task 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(); + 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()); + } +}