Перейти к основному содержимому

Reporting и Export — Практическое Руководство

Назначение

Документ описывает архитектуру, форматы, поля, фильтры и правила реализации отчётов и выгрузок для администраторов, партнёров и регуляторных задач. Охватывает: Excel (ClosedXML), CSV, JSON; роли и permissions; privacy правила; audit.


Форматы Выгрузки

ФорматИспользованиеБиблиотека
.xlsxФинансовые отчёты, аналитика, admin выгрузкиClosedXML
.csvБольшие dataframes, machine-readable выгрузкиCsvHelper
.jsonAPI-style regulatory export, machine integrationSystem.Text.Json

Архитектура Export Pipeline

Выгрузка не генерируется синхронно в HTTP handler. Используется async job:

POST /api/admin/reports/exports
→ CreateReportExportCommand
→ Validate (format, filters, date range, row limit)
→ Authorize (permission, privileged session если PII)
→ Rate limit check
→ Create ReportExportJob record
→ Audit (actor, filters hash, format, report type, reason)
→ Idempotency record
→ Commit
→ Background Job получает ReportExportJobId
→ Fetch data (ClickHouse / PostgreSQL / Elasticsearch)
→ Apply privacy suppression
→ Generate file (ClosedXML / CsvHelper / JsonSerializer)
→ Upload to Object Storage (private, with manifest)
→ Update ReportExportJob status = Completed
→ Notify actor (optional)

GET /api/admin/reports/exports/{jobId}
→ Get job status и signed URL (short-lived)

Типы Отчётов

Отчёты Для Администратора

Тип ОтчётаОписаниеФорматыРазрешение
platform-financial-summaryОбщий P&L: GGR, NGR, deposits, withdrawals по дням/неделям/месяцамxlsx, csvanalytics.reports.financial
user-transactionsТранзакции конкретного игрока: ставки, депозиты, выводы, бонусыxlsx, csv, jsonanalytics.reports.export-user + MFA
user-activity-summaryKPI игрока: sessions, bets, GGR, incentivees, risk scorexlsx, csvanalytics.reports.export-user
withdrawal-queueСписок выводов в обработке с деталямиxlsx, csvaccounting.withdrawal.export
deposit-reconciliationСверка депозитов с провайдеромxlsx, csv, jsonaccounting.reconciliation.export
partner-commission-summaryКомиссии партнёров за периодxlsx, csvanalytics.reports.partner-commission
kyc-verification-queueСписок KYC на верификацииxlsx, csvkyc.reports.export
risk-casesFraud/AML cases за периодxlsx, csvanti-fraud.reports.export
incentive-usageВыданные и отыгранные бонусыxlsx, csvincentive.reports.export
user-safety-eventsЛимиты, самоисключения, cooling-offxlsx, csvuser-safety.reports.export + MFA
regulatory-aml-sarSAR-ready отчёт для регулятораxlsx, jsoncompliance.reports.regulatory + MFA
game-session-logДетальный лог игровых сессийcsv, jsonanalytics.reports.game-sessions

Отчёты Для Партнёра

Тип ОтчётаОписаниеФорматыРазрешение
partner-dashboard-summaryKPI: referred users, GGR, commission accruedxlsx, csvpartner.reports.read
partner-commissionsДетальный список начисленных комиссийxlsx, csvpartner.reports.commissions
partner-referralsСписок привлечённых игроков (без raw PII)xlsx, csvpartner.reports.referrals
partner-promo-codesСтатистика промокодовxlsx, csvpartner.reports.promo
partner-payoutsИстория выплат партнёруxlsx, csvpartner.reports.payouts

Поля Отчётов — Детали

platform-financial-summary Fields

ПолеТипОписание
PerioddateДата (день/неделя/месяц в зависимости от granularity)
TenantIdguidTenant (только для global admin)
BrandIdguidBrand
TotalDepositsAmountdecimalСумма депозитов
TotalWithdrawalsAmountdecimalСумма выводов
TotalBetsAmountdecimalОбщая сумма ставок
TotalWinsAmountdecimalОбщая сумма выигрышей
GrossGamingRevenuedecimalGGR = Bets − Wins
IncentiveCostAmountdecimalСтоимость бонусов
NetGamingRevenuedecimalNGR = GGR − Incentive Cost
ActiveUsersCountintУникальные игравшие игроки
NewUsersCountintНовые регистрации
DepositsCountintКоличество успешных депозитов
WithdrawalsCountintКоличество выводов
CurrencyIdguidВалюта отчёта

user-transactions Fields

ПолеТипPIIОписание
TransactionIdguidнетId транзакции
TransactionTypeenumнетDeposit, Withdrawal, Bet, Win, IncentiveGrant, IncentiveRedemption, Cashback
StatusenumнетCompleted, Pending, Failed, Cancelled
AmountdecimalнетСумма
CurrencyIdguidнетВалюта
BalanceAfterdecimalнетБаланс после транзакции
OccurredAtdatetimeнетВремя транзакции
ProviderReferencestringнетСсылка провайдера (для payment)
UserIdguidPIIId игрока
UserEmailstringPII-highEmail (только с export-user + MFA + reason)
GameIdguidнетId игры (для bet/win)
GameNamestringнетНазвание игры
IncentiveIdguidнетId бонуса (если связан)

partner-commissions Fields

ПолеТипОписание
CommissionIdguidId начисления
PerioddateПериод начисления
CommissionTypeenumRevShare, CPA, Hybrid
ReferredUsersCountintИгроков за период (без raw ids)
BaseAmountdecimalБаза для расчёта
CommissionRatedecimalСтавка (%)
CommissionAmountdecimalНачислено
CurrencyIdguidВалюта
StatusenumAccrued, Approved, Paid
CalculatedAtdatetimeВремя расчёта

Фильтры

Общие Фильтры (для большинства отчётов)

ФильтрТипВалидация
DateFromDateTimeOffsetОбязателен; не старше retention window
DateToDateTimeOffsetОбязателен; >= DateFrom; не в будущем
GranularityenumDay, Week, Month — для summary отчётов
CurrencyIdguid?Фильтр по валюте
BrandIdsguid[]?Фильтр по brand (admin only)
TenantIdguid?Только global admin с cross-tenant permission

Фильтры Для User Reports

ФильтрТипТребуемое Разрешение
UserIdguidanalytics.reports.export-user + MFA
TransactionTypesenum[]?Нет доп. разрешения
Statusesenum[]?Нет доп. разрешения
MinAmountdecimal?Нет доп. разрешения
MaxAmountdecimal?Нет доп. разрешения

Фильтры Для Partner Reports

ФильтрТипОписание
PartnerIdguidАвтоматически из actor context (партнёр видит только своё)
CommissionTypesenum[]?RevShare, CPA, Hybrid
Statusesenum[]?Accrued, Approved, Paid

Request И Result Models

// Pin.Analytics.Contracts/Reports/

// Базовый request для всех export типов
public abstract record BaseReportExportRequest
{
public required ExportFormat Format { get; init; }
public required DateTimeOffset DateFrom { get; init; }
public required DateTimeOffset DateTo { get; init; }
public string? ReasonCode { get; init; } // обязателен для PII-adjacent
public int? RowLimit { get; init; } // max 500_000; default по policy
}

public enum ExportFormat { Xlsx, Csv, Json }

// Конкретный request
public sealed record CreatePlatformFinancialSummaryExportRequest
: BaseReportExportRequest
{
public Granularity Granularity { get; init; } = Granularity.Day;
public Guid[]? BrandIds { get; init; }
public Guid? CurrencyId { get; init; }
}

public sealed record CreateUserTransactionsExportRequest
: BaseReportExportRequest
{
public required Guid UserId { get; init; }
public TransactionType[]? TransactionTypes { get; init; }
public TransactionStatus[]? Statuses { get; init; }
public decimal? MinAmount { get; init; }
public decimal? MaxAmount { get; init; }
public required string MfaToken { get; init; } // обязателен
}

// Job result
public sealed class ReportExportJobView
{
public Guid JobId { get; init; }
public ExportJobStatus Status { get; init; }
public string ReportType { get; init; } = default!;
public ExportFormat Format { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public int? RowCount { get; init; }
public long? FileSizeBytes { get; init; }
public string? DownloadUrl { get; init; } // signed URL, short-lived
public DateTimeOffset? UrlExpiresAt { get; init; }
public string? FailureReason { get; init; }
}

ClosedXML Excel Generator

// Pin.Analytics.Infrastructure/Export/Xlsx/

public sealed class XlsxReportGenerator<TRow>
{
private readonly IReportColumnDefinition<TRow> _columns;

public Stream GenerateStream(IEnumerable<TRow> rows, ExportMetadata metadata)
{
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Report");

// 1. Metadata sheet
AddMetadataSheet(workbook, metadata);

// 2. Headers row
WriteHeaders(sheet);

// 3. Data rows
var rowIndex = 2;
foreach (var row in rows)
{
WriteDataRow(sheet, row, rowIndex++);
}

// 4. Auto-fit columns
sheet.Columns().AdjustToContents();

// 5. Table style
var range = sheet.Range(1, 1, rowIndex - 1, _columns.Count);
range.CreateTable().Theme = XLTableTheme.TableStyleLight1;

var stream = new MemoryStream();
workbook.SaveAs(stream);
stream.Position = 0;
return stream;
}

private void WriteHeaders(IXLWorksheet sheet)
{
for (int i = 0; i < _columns.Count; i++)
{
var cell = sheet.Cell(1, i + 1);
cell.Value = _columns[i].Header;
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#2E6DA4");
cell.Style.Font.FontColor = XLColor.White;
}
}

private void WriteDataRow(IXLWorksheet sheet, TRow row, int rowIndex)
{
for (int i = 0; i < _columns.Count; i++)
{
var cell = sheet.Cell(rowIndex, i + 1);
var column = _columns[i];
var value = column.ValueSelector(row);

cell.Value = value switch
{
decimal d => d, // числа без кавычек
int n => n,
DateTimeOffset dt => dt.ToString("yyyy-MM-dd HH:mm:ss UTC"),
null => "",
_ => value.ToString()
};

if (column.Format is not null)
cell.Style.NumberFormat.Format = column.Format; // например "#,##0.00"
}
}

private void AddMetadataSheet(XLWorkbook workbook, ExportMetadata metadata)
{
var meta = workbook.AddWorksheet("Metadata");
meta.Cell("A1").Value = "Report Type";
meta.Cell("B1").Value = metadata.ReportType;
meta.Cell("A2").Value = "Generated At";
meta.Cell("B2").Value = metadata.GeneratedAt.ToString("o");
meta.Cell("A3").Value = "Generated By";
meta.Cell("B3").Value = metadata.ActorType; // не raw actor id
meta.Cell("A4").Value = "Date From";
meta.Cell("B4").Value = metadata.DateFrom.ToString("yyyy-MM-dd");
meta.Cell("A5").Value = "Date To";
meta.Cell("B5").Value = metadata.DateTo.ToString("yyyy-MM-dd");
meta.Cell("A6").Value = "Row Count";
meta.Cell("B6").Value = metadata.RowCount;
meta.Cell("A7").Value = "Export ID";
meta.Cell("B7").Value = metadata.ExportId.ToString();
}
}

// Column definition
public sealed class ReportColumnDefinition<TRow>
{
public string Header { get; init; } = default!;
public Func<TRow, object?> ValueSelector { get; init; } = default!;
public string? Format { get; init; } // Excel format string
public bool IsPii { get; init; } // для privacy suppression
}

CSV Generator

// Pin.Analytics.Infrastructure/Export/Csv/

public sealed class CsvReportGenerator<TRow>
{
public Stream GenerateStream(IEnumerable<TRow> rows, Type rowType)
{
var stream = new MemoryStream();
using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ",",
HasHeaderRecord = true,
// Всегда quote strings с запятой/кавычками
});

csv.WriteRecords(rows);
writer.Flush();
stream.Position = 0;
return stream;
}
}

Privacy Suppression

Для соблюдения конфиденциальности:

  • Partner не видит raw PII игроков (email, phone, full name) — только anonymized id и агрегаты.
  • Small bucket suppression: если группа содержит < 5 игроков — скрываем детали.
  • Поля UserEmail, UserPhone, UserFullName доступны только admin с export-user + MFA + ReasonCode.
public sealed class PrivacySuppressor<TRow>
{
private readonly IReadOnlyList<string> _piiFields;
private readonly ActorPermissions _permissions;

public IEnumerable<TRow> Apply(IEnumerable<TRow> rows)
{
if (_permissions.HasPermission("analytics.reports.export-pii"))
return rows; // полный доступ

// Suppress PII fields
return rows.Select(row =>
{
foreach (var field in _piiFields)
SetField(row, field, "***");
return row;
});
}
}

Limits и Rate Limits

ПараметрAdminPartner
Max row limit1 000 000100 000
Max date range365 дней90 дней
Rate limit exports10/час5/час
Concurrent exports31
Max file size500 MB100 MB
Signed URL TTL2 часа1 час
File retention7 дней3 дня

Audit Export

Каждое создание выгрузки пишет audit record:

public sealed class ExportAuditRecord
{
public Guid AuditId { get; init; }
public string Action { get; init; } = "analytics.report.export-created";
public ActorSnapshot Actor { get; init; } = default!;
public TenantId? TenantId { get; init; }
public BrandId? BrandId { get; init; }
public string ReportType { get; init; } = default!;
public ExportFormat Format { get; init; }
public string FiltersHash { get; init; } = default!; // hash фильтров, не raw values
public int? RowLimit { get; init; }
public string? ReasonCode { get; init; }
public bool ContainsPii { get; init; }
public string LegalBasis { get; init; } = default!; // "legitimate-interest", "regulatory"
public DateTimeOffset CreatedAt { get; init; }
}

ReportExportJob Entity

public sealed class ReportExportJob : AggregateRoot<ReportExportJobId>,
ITenantScoped, IHasUpdatedAt
{
public TenantId TenantId { get; private set; }
public BrandId? BrandId { get; private set; }
public ActorSnapshot CreatedByActor { get; private set; }
public string ReportType { get; private set; } = default!;
public ExportFormat Format { get; private set; }
public ExportJobStatus Status { get; private set; }
public string FiltersHash { get; private set; } = default!;
public DateTimeOffset? CompletedAt { get; private set; }
public int? RowCount { get; private set; }
public long? FileSizeBytes { get; private set; }
public string? ObjectStorageKey { get; private set; } // private key, не signed URL
public string? FailureReason { get; private set; }
public DateTimeOffset ExpiresAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }

public Result MarkCompleted(int rowCount, long fileSizeBytes, string objectKey)
{
if (Status != ExportJobStatus.Running)
return Result.Fail(ExportErrors.InvalidTransition(Status, ExportJobStatus.Completed));

Status = ExportJobStatus.Completed;
RowCount = rowCount;
FileSizeBytes = fileSizeBytes;
ObjectStorageKey = objectKey;
CompletedAt = DateTimeOffset.UtcNow;
UpdatedAt = DateTimeOffset.UtcNow;

RaiseDomainEvent(new ExportJobCompletedEvent(Id, CreatedByActor, rowCount));
return Result.Ok();
}
}

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.