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>
275 lines
14 KiB
C#
275 lines
14 KiB
C#
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());
|
||
}
|
||
}
|