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>
175 lines
9.5 KiB
C#
175 lines
9.5 KiB
C#
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<Program></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 — копия чтобы не
|
||
/// тащить кросс-теcтовые 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()!);
|
||
}
|
||
}
|