# Exportable datasets (your data in the HR export wizard) — `IExportDatasetProvider`

The platform ships an export wizard at `/hr/export` that **composes and merges** several HR sources
into ONE file — the hours an employee worked *and* the holidays they took over the same month, on the
same row. This seam puts your own entities in it.

One class, one DI line, and your data becomes a selectable **source**, mergeable with the platform's,
gated by your own permission, scoped by your own DbContext filters — with **ZERO client frontend
code**, because the catalogue is permission-filtered server-side.

What you get for free: the four output formats (CSV, XLSX, PDF, JSON), the column picker, the header
renaming, the live preview, the "Qui ?" targeting step, the saved templates and the schedules.

```csharp
using SmartStack.Application.Common.Exports;

public sealed class OrdersExportProvider(IExtensionsDbContext context) : IExportDatasetProvider
{
    public string DatasetKey => "ventes.commandes";              // PERSISTED — never rename
    public string Icon => "ShoppingCart";                        // a lucide icon name
    public string RequiredPermission => "ventes.commandes.export";
    public int Order => 10;                                      // after the platform's
    public ExportDatasetKind Kind => ExportDatasetKind.Facts;    // dated events
    public string? TypeFieldKey => "status";                     // promoted to the "Type" column

    public string LabelFor(string languageCode) => languageCode switch
    {
        "fr" => "Commandes",
        "de" => "Bestellungen",
        _ => "Orders",
    };

    public Task<IReadOnlyList<ExportFieldDescriptor>> DescribeAsync(
        ExportDescribeContext ctx, CancellationToken ct)
    {
        IReadOnlyList<ExportFieldDescriptor> fields =
        [
            new("total", "Montant", "Commandes", ExportFieldType.Number, ExportFieldKind.Measure),
            new("status", "Statut", "Commandes", ExportFieldType.Text, ExportFieldKind.Dimension),
        ];
        return Task.FromResult(fields);
    }

    public Task<IReadOnlyList<ExportFilterDescriptor>> DescribeFiltersAsync(
        ExportDescribeContext ctx, CancellationToken ct)
    {
        var statuses = new List<ExportFilterOption>
        {
            new("Pending", "En attente"),
            new("Shipped", "Expédiée"),
        };

        // DefaultValues says what "untouched" applies. Advertise every value you would apply by
        // default — see "The filter trap" below.
        IReadOnlyList<ExportFilterDescriptor> filters =
        [
            new("status", "Statut", ExportFilterKind.MultiSelect, statuses, [.. statuses.Select(s => s.Value)]),
        ];
        return Task.FromResult(filters);
    }

    public async Task<ExportDatasetResult> QueryAsync(ExportQueryContext ctx, CancellationToken ct)
    {
        var statuses = ctx.Filter("status", ["Pending", "Shipped"]);
        if (statuses.Count == 0)
            return ExportDatasetResult.Empty;          // "everything unticked" means NOTHING

        var query = context.Orders.AsNoTracking()
            .Where(o => o.OrderedAt >= ctx.From && o.OrderedAt <= ctx.To)
            .Where(o => statuses.Contains(o.Status));

        // The engine already resolved the wizard's targeting into user ids — see "Targeting".
        if (ctx.UserIds.Count > 0)
        {
            var userIds = ctx.UserIds.ToList();
            query = query.Where(o => userIds.Contains(o.SalesRepUserId));
        }

        var rows = await query
            .OrderBy(o => o.OrderedAt)
            .Take(ctx.MaxRows + 1)                     // one over the cap: see "Truncation"
            .Select(o => new { o.SalesRepUserId, o.OrderedAt, o.Total, o.Status })
            .ToListAsync(ct);

        var truncated = rows.Count > ctx.MaxRows;
        if (truncated) rows = rows.Take(ctx.MaxRows).ToList();

        return new ExportDatasetResult(rows.Count, [.. rows.Select(o => new ExportRow(
            o.SalesRepUserId,                          // the JOIN KEY of the whole module
            DateOnly.FromDateTime(o.OrderedAt),
            new Dictionary<string, object?>(StringComparer.Ordinal)
            {
                ["total"] = o.Total,
                ["status"] = o.Status.ToString(),
            }))], truncated);
    }
}
```

```csharp
// {Project}.Infrastructure/DependencyInjection.cs — the <<< EXPORT-DATASETS-DI >>> markers
services.AddSmartStackExportDatasetProvider<OrdersExportProvider>();
```

There is **no scaffolder**: a dataset's fields, its measures and its pivots are business decisions a
column-mapping DSL cannot express. The markers are a documented anchor, not a generator target.

---

## The contract

| Member | Meaning |
|---|---|
| `DatasetKey` | Stable, namespaced. **PERSISTED** in saved templates and schedules — renaming it orphans every template that used it |
| `LabelFor(lang)` | A METHOD, not a property: the label also becomes the `Source` CELL INSIDE the file, and the seam has no ambient language. A fixed string is fine for a single-language client |
| `Icon` | A lucide icon name, shown on the source chip |
| `RequiredPermission` | Matched via `PermissionMatcher` (wildcards included). Gates both the OFFER and the execution |
| `Order` | Position in the source picker. Start above the platform's (1–4) |
| `Kind` | `Facts` vs `Attributes` — see below |
| `TypeFieldKey` | The field naming the NATURE of a row, promoted to the synthetic `Type` column in Rows mode. `null` if you have no such notion |

### `Facts` vs `Attributes` — the choice that decides the shape

- **`Facts`** — dated events (orders, interventions, timesheet lines). Bucketed by period; the
  `Measure` fields are summed in Columns mode.
- **`Attributes`** — properties OF the person (cost centre, mandate, contract). No date, one row per
  employee, **repeated on every row of that employee**.

Pick `Attributes` for anything that describes the person. Otherwise a merged export grows a second,
half-empty row family instead of enriching the existing one — which is the entire point of the module.

### `Dimension` vs `Measure`

Only measures are summed. A measure no source contributed to is **zero-filled** in Columns mode, on
purpose: a payroll import rejects a blank numeric column. A dimension keeps the first non-null value
of its bucket.

### `DescribeAsync` is contextual

It receives the merge mode and the granularity, so you can advertise a per-category **pivot** (one
column per type of thing) in Columns mode and a plain type column in Rows mode. That is how the
platform's absence dataset produces "H. vacances" / "H. maladie".

---

## Targeting — the engine does it, not you

The wizard's **"Qui ?"** step lets the caller aim at departments *and* at named people. The composer
resolves the departments into user ids and hands you the **UNION** in `ctx.UserIds`, **before** your
provider runs.

Consequences:

- Your existing `if (ctx.UserIds.Count > 0)` narrowing **inherits department targeting for free**.
  Nothing to write.
- `ctx.DepartmentIds` carries what the caller ticked, **already folded into `UserIds`**. It is
  informational (labelling, grouping). **Do not re-filter on it** — you would turn the union
  "departments ∪ named people" into an intersection and lose everyone named outside those departments.
- A targeting that resolves to **nobody** short-circuits: the composer produces an empty table and
  calls **no provider at all**. That is what keeps the two meanings of an empty `ctx.UserIds` apart —
  when it reaches you it always means "the caller's whole visible perimeter", never "nobody".

---

## The filter trap (behavioural change, v3.66)

`ctx.Filter(key, fallback)` distinguishes two situations that look alike and mean the opposite:

| Situation | What you get |
|---|---|
| The key is **ABSENT** — the caller never touched the filter | your `fallback` (the descriptor's `DefaultValues`) |
| The key is **PRESENT with an EMPTY list** — the caller unticked everything | an **empty** collection |

**Return `ExportDatasetResult.Empty` on an empty collection.** Returning your defaults instead would
export everything the caller just unticked — which is exactly what the wizard's "Aucun" button does
with one click.

> **If you wrote a provider before v3.66**: an empty collection used to fall back to your defaults. If
> your code assumes `ctx.Filter(...)` is never empty, add the check. Stored templates are migrated on
> read (document schema v1 → v2): a v1 document's empty filter lists are dropped, which restores their
> original meaning exactly. Nothing to do on your side for existing templates.

`ctx.HasFilter(key)` tells "untouched" from "cleared" when a fallback is not expressive enough — use
it when building the fallback would cost a query (a catalogue of every id, say).

**Corollary on `DefaultValues`**: advertise what "untouched" applies, **"all of them" included**. An
empty `DefaultValues` makes "no chip lit" render identically to "everything unticked" while meaning
the opposite, and the caller has no way to tell.

---

## Row perimeter — the rules that are not negotiable

1. **Never call `IgnoreQueryFilters()`.** The row perimeter of an export IS the perimeter of the
   corresponding list. A guard test scans the IL of every shipped provider for it.
2. **The subject key is a `User` id** (`ExportRow.SubjectUserId`). If your entity keys on
   `HrEmployee`, resolve `HrEmployee.UserId`. Identity columns (name, e-mail, personnel number) belong
   to the **ENGINE** — do not emit your own, or a merged export carries three near-identical name
   columns and the composer has no canonical label to sort on.
3. **Period columns belong to the engine too.** Return the row's date; the composer buckets it.
4. **Declare your truncation.** Set `IsTruncated` when you hit `ctx.MaxRows` (the `Take(MaxRows + 1)`
   pattern). It is NOT inferred from a count mismatch, because several datasets filter in memory and
   "fewer rows than matched" is routine.
5. **A provider that throws is logged and skipped** — one broken dataset must not cost a payroll clerk
   their whole month. It also means a silent bug shows up as a missing column, so log on your side.
6. **Providers run SEQUENTIALLY.** They share a scoped DbContext; concurrent execution would throw
   "a second operation was started on this context".

---

## Labels

Field labels become the **HEADERS INSIDE the file**, so they are resolved SERVER-side — a scheduled
run has no browser to translate anything. Return them already localized for `ctx.LanguageCode`.

Format and option labels are the exception: they never leave the wizard, so the SPA translates them
from their stable keys and falls back to the string you return.

---

## Your provider also runs without a request

A schedule replays a template on its own, so `DescribeAsync` and `QueryAsync` are called from a
background scope with no HTTP request:

- no `IHttpContextAccessor`, nothing read from headers, cookies or the path;
- `ICurrentUserService` / `ICurrentTenantService` **do** answer — with the schedule's owner and their
  tenant — so your query filters work unchanged;
- no static per-user cache;
- the catalogue is permission-filtered in the background too, against the owner.

The targeting is replayed as well: a template saved as "Paie — Production" stays scoped to Production
on every nightly run. Only the PERIOD is chosen fresh.

---

## Adding an output format

Same shape, one interface down:

```csharp
services.AddSmartStackExportFormatter<PartnerFixedWidthFormatter>();
```

A formatter receives a table that is already scoped, merged, column-selected and renamed. **It must
not query anything** — everything it needs is in the table and its `Meta`.

- **`Delivery`** says what the viewer does with the artifact — `Download` (the default) or `Print`.
  The built-in PDF returns an HTML document with `Print`: that is how the platform produces PDFs with
  no server-side PDF engine, the browser's own print dialog converts it. The content type alone would
  not say it, since the payload really is `text/html`.
- **Options that are not one of a handful of choices** (an Excel split column, a Liquid template body)
  are read straight from the options dictionary rather than declared in `Options` — their legal values
  depend on the current selection, which a static descriptor cannot know.
- **The document dressing arrives in `table.Meta.Presentation`** — title, header text, footer text,
  sign-off blocks and the logo, the latter already resolved to BYTES (`ExportLogo.ToDataUri()`). It
  travels on the meta rather than as an extra `FormatAsync` parameter precisely so that adding it did
  not break the formatters clients had already written. `ExportPresentation.IsEmpty` (and a null
  `Presentation`) is your signal to take the untouched path and emit exactly the bytes you emitted
  before the field existed. Machine formats — CSV, JSON — ignore the dressing by design: a title on
  the first line of a CSV is a payroll import that fails.

### The document layout — the split and the matrix (v3.68)

`table.Meta.Structure` says how the composed table becomes a DOCUMENT. It rides on the request rather
than in the options dictionary, so it survives a change of format and belongs to a saved template's
document rather than to one format's dictionary.

| Field | Effect |
|---|---|
| `SplitColumn` | One Excel sheet / one PDF page per distinct value |
| `SplitHeader` | `Promote` (default) lifts the columns that are constant within a split above the grid; `Repeat` leaves them in it |
| `GroupBy` | The matrix's ROW AXIS, outermost first. Every level but the innermost closes on a subtotal line |
| `PivotColumn` | The matrix's COLUMN AXIS — one column group per distinct value |
| `RowLayout` | `Tabular` (default), `Stepped` or `Repeat` — how the row axis is spelled out |

Two helpers do the drawing, and your formatter should call them rather than re-deriving any of it:

```csharp
foreach (var view in ExportSplitView.Build(table))          // one per sheet / page
{
    // view.Header  → the promoted "label : value" pairs, already extracted
    // view.Columns → the grid's columns, minus whatever was promoted
    var matrix = ExportMatrix.Build(
        view.Columns, view.Rows, structure.GroupByKeys, structure.RowLayout, table.Meta.LanguageCode);

    foreach (var line in matrix.Lines)
    {
        // line.Kind   → Data or Subtotal
        // line.Cells  → aligned with matrix.Columns, already blanked for the row layout
        // line.Label  → written at line.LabelIndex with line.LabelType — NEVER through Cells
    }
}
```

Both return the trivial answer when nothing is asked for — one view, plain data lines, no blanking —
so a formatter keeps a single code path and its old output stays byte-identical.

- **A sign-off block says WHEN it is given.** `ExportVisa.Scope` is `Page` (the default, and what an
  absent value on the wire reads as) or `Document`: `presentation.PageVisas` are drawn under every
  page and every sheet, `presentation.DocumentVisas` once, under the last one. The two formats used
  to disagree in silence — the spreadsheet repeated every block on every sheet, the paginated document
  printed them once at the very end — and neither answer fits both blocks of one document: on a
  per-employee statement each employee signs THEIR page, while the HR block is given once for the lot.
  `Page` is the default because it is what the spreadsheet has always printed, so a stored dressing
  keeps its workbook byte for byte; and a signature that appears too often gets noticed, where one
  that silently stopped being printed does not.

Three things that bite:

- **`GroupBy` is a MATRIX, not an outline.** Until v3.67 it meant stacked sections opening on a merged
  banner. It now puts the grouping value in its own COLUMN, next to the rows it qualifies. A banner
  cannot say two grouping levels at once and stops meaning anything once a dimension is transposed
  into columns. `ExportSection.Build` still exists, unchanged, for formatters written against it —
  write new ones against `ExportMatrix`.
- **A line's LABEL never travels through its cell array.** It carries its own `LabelType` and its own
  `LabelIndex` precisely because the column it lands in may be typed: written through the cells,
  "Total" inside a Date column comes back out as 01.01.0001.
- **Blanking is a DOCUMENT concern.** A machine format (CSV, JSON) must ignore the split and the
  matrix entirely — a column that goes empty every second line is a column a payroll import rejects.

A stored template written before v3.68 is migrated on read (document schema v3 → v4): one that carries
a `GroupBy` is pinned to `RowLayout: Repeat`, which writes every value on every row — exactly what its
sections did. Without the pin the new `Tabular` default would put holes in a monthly file nobody
edited.

---

## Checklist

- [ ] `DatasetKey` is stable, namespaced and will never be renamed
- [ ] `RequiredPermission` exists and is granted to the roles that should export
- [ ] `Kind` is `Attributes` if the data describes the person rather than an event
- [ ] Measures are `ExportFieldKind.Measure`; everything else is a `Dimension`
- [ ] Rows carry a `User` id as `SubjectUserId` (resolve `HrEmployee.UserId` if needed)
- [ ] No identity or period column emitted by the dataset — they belong to the engine
- [ ] No re-filtering on `ctx.DepartmentIds` — the engine already folded it into `UserIds`
- [ ] An emptied filter returns `ExportDatasetResult.Empty`, not your defaults
- [ ] `DefaultValues` advertises what "untouched" applies, "all of them" included
- [ ] No `IgnoreQueryFilters()`
- [ ] Labels localized for `ctx.LanguageCode`
- [ ] `IsTruncated` set when the cap is hit
- [ ] No `IHttpContextAccessor`, no static per-user cache — a schedule runs the provider without a request
- [ ] A custom FORMATTER honours `table.Meta.Structure` through `ExportSplitView.Build` +
      `ExportMatrix.Build`, and writes a line's label at its `LabelIndex` with its `LabelType`
- [ ] A custom MACHINE format ignores the split and the matrix — no blanked cells in a CSV
- [ ] Registered inside the `<<< EXPORT-DATASETS-DI >>>` markers
- [ ] Unit test on the provider (mock the context, assert the rows **and** the perimeter)
