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>
77 lines
2.8 KiB
C#
77 lines
2.8 KiB
C#
using FluentAssertions;
|
||
using foodmarket.Application.Common.Fiscal;
|
||
using foodmarket.Domain.Sales;
|
||
using foodmarket.Infrastructure.Fiscal;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
/// <summary>Контракт MockFiscalProvider'а: возвращает префикс MOCK-, тот же
|
||
/// чек двумя вызовами даёт тот же FiscalNumber (идемпотентность), QR содержит
|
||
/// FiscalNumber, ProviderTxId не пуст. SimulatedLatency обнулена — тест
|
||
/// должен быть мгновенным.</summary>
|
||
public class FiscalMockProviderTests
|
||
{
|
||
private static MockFiscalProvider New() => new(NullLogger<MockFiscalProvider>.Instance)
|
||
{
|
||
SimulatedLatency = TimeSpan.Zero,
|
||
};
|
||
|
||
private static RetailSale SaleStub(Guid? id = null) => new()
|
||
{
|
||
Id = id ?? Guid.NewGuid(),
|
||
Number = "TST-000001",
|
||
OrganizationId = Guid.NewGuid(),
|
||
StoreId = Guid.NewGuid(),
|
||
CurrencyId = Guid.NewGuid(),
|
||
Total = 1000m,
|
||
};
|
||
|
||
[Fact]
|
||
public void Kind_is_Mock()
|
||
=> New().Kind.Should().Be(FiscalProviderKind.Mock);
|
||
|
||
[Fact]
|
||
public async Task Register_returns_mock_prefixed_fiscal_number()
|
||
{
|
||
var sale = SaleStub();
|
||
var r = await New().RegisterAsync(sale, CancellationToken.None);
|
||
r.FiscalNumber.Should().StartWith("MOCK-").And.HaveLength("MOCK-".Length + 8);
|
||
r.FiscalQrCode.Should().Contain(r.FiscalNumber);
|
||
r.FiscalUrl.Should().StartWith("https://mock.ofd.local/check/");
|
||
r.ProviderTxId.Should().NotBeNullOrEmpty().And.StartWith("mock-tx-");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Register_is_idempotent_per_sale_id()
|
||
{
|
||
var id = Guid.NewGuid();
|
||
var r1 = await New().RegisterAsync(SaleStub(id), CancellationToken.None);
|
||
var r2 = await New().RegisterAsync(SaleStub(id), CancellationToken.None);
|
||
r1.FiscalNumber.Should().Be(r2.FiscalNumber);
|
||
r1.ProviderTxId.Should().Be(r2.ProviderTxId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Different_sales_get_different_fiscal_numbers()
|
||
{
|
||
var a = await New().RegisterAsync(SaleStub(), CancellationToken.None);
|
||
var b = await New().RegisterAsync(SaleStub(), CancellationToken.None);
|
||
a.FiscalNumber.Should().NotBe(b.FiscalNumber);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Simulated_latency_actually_delays()
|
||
{
|
||
var prov = new MockFiscalProvider(NullLogger<MockFiscalProvider>.Instance)
|
||
{
|
||
SimulatedLatency = TimeSpan.FromMilliseconds(150),
|
||
};
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
await prov.RegisterAsync(SaleStub(), CancellationToken.None);
|
||
sw.Stop();
|
||
sw.ElapsedMilliseconds.Should().BeGreaterThanOrEqualTo(140);
|
||
}
|
||
}
|