food-market/tests/food-market.IntegrationTests/FiscalMockFlowTests.cs
nns 0d3ef81f72 feat(s11): ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты
Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.

Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
  FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
  IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
  • MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
    по Sale.Id (используется dev/stage и интеграционными тестами);
  • WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
    JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
  • Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
    RegisterAsync бросает FiscalNotConfiguredException (нужны
    спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
  Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
  NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
  ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
  и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
  stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
  остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
  (опции для select'а) + POST /test-send (фейк-чек к выбранному
  провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
  для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
  в GET — только has-* флаги), спец-значение "__clear__" для снятия,
  кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
  (Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
  не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
  Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
  кредов, retry-сценариями.

Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:27:17 +05:00

175 lines
9.5 KiB
C#
Raw 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.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>End-to-end проверка фискализации через MockFiscalProvider:
/// в новой организации PUT /api/organization/fiscal с provider=1 (Mock),
/// создаём чек, проводим POST → ожидаем что в ответе и в последующем GET
/// FiscalNumber начинается с <c>MOCK-</c>.
///
/// Используем общий <see cref="ApiCollection"/> — это критично, т.к.
/// xUnit в одном процессе не любит нескольких <c>WebApplicationFactory&lt;Program&gt;</c>
/// инстансов одновременно (бросает «entry point exited without ever
/// building an IHost»). Mock-провайдер активируется per-org через БД,
/// а не глобальным config-override'ом.</summary>
[Collection(ApiCollection.Name)]
public class FiscalMockFlowTests
{
private readonly ApiFactory _factory;
public FiscalMockFlowTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Posting_retail_sale_with_mock_provider_sets_fiscal_number()
{
var actor = new ApiActor(_factory.CreateClient());
await actor.SignupAndLoginAsync($"fiscal-{Guid.NewGuid():N}");
// Включаем Mock-провайдер на этой организации.
var putResp = await actor.Http.PutAsJsonAsync("/api/organization/fiscal", new
{
provider = 1, // Mock
newApiKey = (string?)null,
newApiSecret = (string?)null,
cashboxUniqueNumber = (string?)null,
apiBaseUrl = (string?)null,
});
putResp.EnsureSuccessStatusCode();
var fiscalCfg = await putResp.Content.ReadFromJsonAsync<JsonElement>();
fiscalCfg.GetProperty("provider").GetInt32().Should().Be(1);
fiscalCfg.GetProperty("providerName").GetString().Should().Contain("Mock");
// Сидим товар + приёмку, чтобы было что продавать.
var (productId, storeId, retailPointId, currencyId) = await SeedProductWithStockAsync(actor);
// Создаём чек на 1000 ₸, paidCash=1000.
var draftResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 10m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 1000m, discountTotal = 0m, total = 1000m,
paidCash = 1000m, paidCard = 0m,
});
draftResp.EnsureSuccessStatusCode();
var saleId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
// Post чек.
var postResp = await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null);
postResp.EnsureSuccessStatusCode();
// Читаем чек обратно — FiscalNumber должен быть MOCK-…
var getResp = await actor.Http.GetAsync($"/api/sales/retail/{saleId}");
getResp.EnsureSuccessStatusCode();
var sale = await getResp.Content.ReadFromJsonAsync<JsonElement>();
sale.GetProperty("fiscalNumber").GetString().Should().StartWith("MOCK-",
"MockFiscalProvider должен был отработать на post'е и сохранить FiscalNumber");
sale.GetProperty("fiscalQrCode").GetString().Should().NotBeNullOrEmpty();
sale.GetProperty("fiscalUrl").GetString().Should().StartWith("https://mock.ofd.local/");
sale.GetProperty("fiscalProviderKind").GetInt32().Should().Be(1, "FiscalProviderKind.Mock = 1");
}
[Fact]
public async Task Test_send_with_mock_provider_returns_ok_and_fiscal_number()
{
var actor = new ApiActor(_factory.CreateClient());
await actor.SignupAndLoginAsync($"fiscal-test-{Guid.NewGuid():N}");
// Выбираем Mock.
(await actor.Http.PutAsJsonAsync("/api/organization/fiscal", new
{
provider = 1, newApiKey = (string?)null, newApiSecret = (string?)null,
cashboxUniqueNumber = (string?)null, apiBaseUrl = (string?)null,
})).EnsureSuccessStatusCode();
var resp = await actor.Http.PostAsJsonAsync("/api/organization/fiscal/test-send", new { });
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("ok").GetBoolean().Should().BeTrue();
body.GetProperty("fiscalNumber").GetString().Should().StartWith("MOCK-");
}
[Fact]
public async Task Default_provider_none_does_not_fiscalize()
{
// Без явной установки FiscalProvider он остаётся 0 (None) — поведение
// обратно-совместимое: чек посту, но FiscalNumber пустой.
var actor = new ApiActor(_factory.CreateClient());
await actor.SignupAndLoginAsync($"fiscal-none-{Guid.NewGuid():N}");
var (productId, storeId, retailPointId, currencyId) = await SeedProductWithStockAsync(actor);
var draft = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow, storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 0m } },
subtotal = 100m, discountTotal = 0m, total = 100m,
paidCash = 100m, paidCard = 0m,
});
var saleId = (await draft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
(await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null)).EnsureSuccessStatusCode();
var sale = await actor.GetJsonAsync($"/api/sales/retail/{saleId}");
// fiscalNumber == null означает «провайдер None / не дёрнули»
var fiscal = sale.GetProperty("fiscalNumber");
(fiscal.ValueKind == JsonValueKind.Null || string.IsNullOrEmpty(fiscal.GetString()))
.Should().BeTrue("при Provider=None фискальный номер не записывается");
}
/// <summary>Аналог LoyaltyFlowTests.SeedProductAsync — копия чтобы не
/// тащить кросс-теовые helper'ы. Создаёт товар с приёмкой на 100 шт.</summary>
private static async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)>
SeedProductWithStockAsync(ApiActor actor)
{
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
.GetProperty("items").EnumerateArray().First();
var pts = (await actor.GetJsonAsync("/api/catalog/price-types"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isRetail").GetBoolean());
var curs = (await actor.GetJsonAsync("/api/catalog/currencies"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "KZT");
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isMain").GetBoolean());
var retailPoints = (await actor.GetJsonAsync("/api/catalog/retail-points"))
.GetProperty("items").EnumerateArray().First();
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = "Fiscal test", article = $"FSC-{Guid.NewGuid():N}",
unitOfMeasureId = units.GetProperty("id").GetString(),
vat = 12, vatEnabled = true,
productGroupId = groups.GetProperty("id").GetString(),
packaging = 1,
prices = new[] { new { priceTypeId = pts.GetProperty("id").GetString(), amount = 100m, currencyId = curs.GetProperty("id").GetString() } },
barcodes = new[] { new { code = $"700000{Guid.NewGuid().GetHashCode():X}".Replace("-", "").Substring(0, 12) + "0", type = 1, isPrimary = true } },
});
prodResp.EnsureSuccessStatusCode();
var productId = (await prodResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync<JsonElement>();
var supply = await (await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow,
supplierId = supplier.GetProperty("id").GetString(),
storeId = stores.GetProperty("id").GetString(),
currencyId = curs.GetProperty("id").GetString(),
lines = new[] { new { productId, quantity = 100m, unitPrice = 50m } },
})).Content.ReadFromJsonAsync<JsonElement>();
(await actor.Http.PostAsync($"/api/purchases/supplies/{supply.GetProperty("id").GetString()}/post", null))
.EnsureSuccessStatusCode();
return (productId,
stores.GetProperty("id").GetString()!,
retailPoints.GetProperty("id").GetString()!,
curs.GetProperty("id").GetString()!);
}
}