food-market/tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs
nns e45600df4b
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
feat(invoices+catalog): порт улучшений 06.10.2026 из sklad
Основное:

* 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>
2026-10-07 19:50:03 +05:00

275 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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