MSO Cloud · Documentation

Manual CSV / Excel Upload Runbook

Source: docs/guides/admin/manual-csv-upload.md Updated 2026-09-21
On this page

This runbook is for operators receiving raw customer data (CSV or Excel) and pushing it through the YNG platform. It covers the supported data shapes, the column-mapping wizard, the most common parse failures, and the fastest path to a green import.

TL;DR#

  1. cd apps/web → open /connectors/uploads while signed into the target org.
  2. Drop in a .csv, .xlsx, or .xls file under 8 MiB.
  3. Pick the upload type (orders / creators / inventory / creator_costs / targets / products) for Wave 1‑2 streams, or a mapping template for Wave 3 streams (ad spend, payouts, returns, warehouse moves, etc.).
  4. Submit. The worker parses, normalises, and writes to staging (import_staging_rows) or bronze (raw_events), then promotes to silver.
  5. Watch the job row in manual_upload_jobs for status and rejected_reasons.

Supported data types#

Stream Wave Path Backing table
orders 1 hardcoded aliases raw_eventsorders
creators 1 hardcoded aliases raw_eventscreators
inventory 1 hardcoded aliases raw_eventsinventory_snapshots
creator_costs 2 hardcoded aliases raw_eventscreator_costs
targets 2 hardcoded aliases raw_eventstargets
products 2 hardcoded aliases raw_eventsproducts
ad_spend 3 configurable template import_staging_rowsad_spend_daily
budget_plan 3 configurable template import_staging_rowsbudget_plans
payouts / returns / shipments / warehouse_movements / promotions 3 configurable template import_staging_rows → per-stream silver

For Wave 1‑2 data types the column aliases live in packages/connectors/core/src/manual-upload.ts (Vietnamese + English headers). For Wave 3, create a mapping template under /connectors/uploads/templates describing the column transforms.

Column mapping template anatomy#

A template is a Zod-validated JSON document with three required sections:

{
  "formatVersion": 1,
  "targetStream": "ad_spend", // which silver table this lands in
  "parser": {
    "delimiter": ",", // , ; | \t
    "quote": "\"",
    "skipLines": 0, // skip title rows / metadata
    "encoding": "utf8", // utf8 | utf16le | latin1 | win1258
    "trimHeaders": true,
    "headerCaseInsensitive": true,
  },
  "columns": [
    {
      "source": "Date", // CSV header
      "target": "day", // canonical field
      "required": true,
      "transform": {
        "kind": "date",
        "format": "DD/MM/YYYY", // Vietnamese day-first dates
        "output": "date", // 'iso' for full ISO-8601 timestamps
      },
    },
    {
      "source": "Spend",
      "target": "spend_vnd",
      "required": true,
      "transform": {
        "kind": "int",
        "thousandsSep": ",",
        "decimalSep": ".",
        "stripCurrency": true, // strips đ, VND, $, %, whitespace
        "parenthesesAsNegative": false, // accounting exports
      },
    },
  ],
  "required": ["platform", "day", "spend_vnd"],
  "dedupeKey": ["platform", "campaign_external_id", "day"],
}

Transform kinds (closed set)#

Adding a new kind is a code change in packages/connectors/core/src/mapping/transforms.ts. The set today:

  • pass — keep the raw string.
  • string — trim, case fold, collapse whitespace, default fallback.
  • int — strip thousands separator, strip currency symbols (stripCurrency: true by default), drop decimal portion, optional multiplyBy, optional parenthesesAsNegative for accounting exports.
  • decimal — same as int but emits a float; never use for VND (CLAUDE.md §3 — VND is integer).
  • date — format-driven parser (DD/MM/YYYY, YYYY-MM-DD, M/D/YYYY, plus time variants). Output dateYYYY-MM-DD, output iso → full ISO-8601 in UTC.
  • enum — explicit source-value → canonical-value map. Unknown values default, reject, or pass.
  • concat — join multiple source columns with a separator. Used for synthetic dedupe keys.
  • constant — emit a fixed value regardless of source.

Things that bite (and the fix)#

Symptom Likely cause Fix
cannot parse int from "1,234,000đ" Currency symbol in VND column stripCurrency: true (now the default for int/decimal)
cannot parse int from "1.234.000" European thousands separator Set thousandsSep: "." and decimalSep: "," in the template
cannot parse date "15/05/2026" with format "MM/DD/YYYY" Day-first dates parsed as US format Set format: "DD/MM/YYYY" in the date transform
cannot parse date "30/02/2026" Impossible calendar date (Feb 30) Real bug in source — surface to customer; the parser correctly rejects
Vietnamese diacritics appear as Bún bɄ File encoded as Windows-1258 (legacy VN) Set encoding: "win1258" in the template's parser block
unsupported_extension on .xlsx upload xlsx peer dep not installed on worker Verify pnpm --filter @yng/worker list xlsx shows a version; reinstall if absent
file_too_large (8 MiB cap) Bulk historical backfill Split into multiple smaller files OR ship via S3/Minio when the object-store path lands
Duplicate rows ingested twice on replay Dedupe key collapsed to [object Object] (old bug) Fixed: dedupe-key builder unwraps __pendingDate markers correctly
Negative numbers ignored or 0 Source uses parens (1234) for negatives Set parenthesesAsNegative: true on the int/decimal transform

Verifying a successful upload#

-- Recent jobs and their counts
SELECT id, upload_type, status, rows_processed, rows_upserted, rows_rejected,
       LEFT(COALESCE(rejected_reasons::text, ''), 200) AS sample_errors
FROM manual_upload_jobs
WHERE org_id = '<org-uuid>'
ORDER BY created_at DESC
LIMIT 5;

For Wave 3 templated imports:

SELECT status, COUNT(*) FROM import_staging_rows
WHERE template_id = '<template-uuid>'
GROUP BY status;

queued → still being promoted. promoted → made it to the silver table. rejected → check error column on the row.

When to escalate#

  • The same row hash keeps producing different dedupe keys → file an issue against @yng/connectors-core/mapping/apply with the row contents.
  • A non-trivial fraction of rows are rejected and the error mentions Zod issue paths → the canonical schema in packages/connectors/core/src/mapping/streams.ts rejected the post-transform shape. Surface to engineering.
  • A .xlsx file fails with unsupported_extension after the worker has been redeployed → confirm xlsx is in apps/worker/package.json deps (not peer).

Test scripts#

End-to-end smoke tests for the upload pipeline live in:

  • apps/web/src/__tests__/import-mapping.test.ts — pure transform unit tests (Vietnamese dates, currency stripping, parens-as-negative, dedupe-key stability, CSV → accept/reject decisions, idempotent replay).
  • apps/web/e2e/manual-upload.spec.ts — Playwright smoke ensuring /connectors/uploads and the templates editor are reachable while authenticated.

Run them with:

pnpm --filter @yng/web test            # vitest unit suite
pnpm --filter @yng/web test:e2e        # playwright (workers=1)