User guide
Distributor Integration
Looking for what it does rather than how to use it? Read the Distributor Integration overview .
Audience: platform administrators who operate the shared product catalogue. Scope: TD Synnex and Ingram Micro SFTP feeds — catalogue, cost, and availability.
What it is
Solidlio downloads product, cost, and stock feeds from Ingram Micro and TD Synnex over SFTP, parses them, and merges them into one shared catalogue where each product carries the cost and stock position of every distributor that sells it.
The catalogue is global and untenanted.
Concepts
| Concept | What it is |
|---|---|
Distributor | One supplier row, keyed by a unique code (INGRAM, TDSYNNEX). Holds costCurrency, isActive, last-sync stamps. |
| Feed | One file on the distributor’s SFTP host. Ingram publishes three; TD Synnex publishes one combined price-and-availability file. |
DistributorStock | One row per (product, distributor): the distributor’s SKU, unitCost, msrp, per-warehouse quantities, availability. |
DistributorCategory | A category code as the distributor names it, plus its mapping to a Solidlio CatalogCategory. |
DistributorBrand | A manufacturer name as the distributor spells it, plus its mapping to a Solidlio Brand. |
DistributorWarehouse | A named distribution centre. The warehouse code is the key used inside DistributorStock.warehouseStock. |
DistributorSyncLog | One row per import run: type, status, counts, duration, and the error list. |
CatalogProduct | The distributor-agnostic product. Created or matched during a price sync, stamped with a platform SKU. |
Sync types
| Type | Ingram | TD Synnex |
|---|---|---|
PRICE | PRICE.ZIP → PRICE.TXT | The P&A flat file (also carries stock) |
AVAIL | ALLETA.ZIP → ALLETA.TXT | Not a separate feed — included in P&A |
CATEGORIES | NEWCATZ.ZIP → NEWCATZ.TXT | Not a separate feed — codes named from spec |
Requesting AVAIL or CATEGORIES for TD Synnex returns 400 with Availability sync not supported for distributor: TDSYNNEX.
Sync statuses
PENDING → RUNNING → COMPLETED | FAILED | CANCELLED
Feed formats
Ingram PRICE.TXT — comma-delimited, positional, 23 fields, no header row. Field 1 is the Ingram part number, field 3 the vendor (brand) name, field 6 the retail price, field 7 the manufacturer part number, field 9 the UPC, field 14 our cost, field 20 the four-character category code.
Ingram ALLETA.TXT — comma-delimited, quote-aware. Field 0 is the part number, field 1 the total quantity, then repeating three-field groups of warehouse code, ETA, and quantity.
Ingram NEWCATZ.TXT — three quoted columns: category name, parent code, subcategory code.
TD Synnex P&A — tilde-delimited (~), with HDR and DTL record types. Only DTL rows are imported.
Warehouses
Warehouse rows are created automatically by the sync from the codes the feed actually reports; an unrecognised code becomes a WH-<code> placeholder.
| Distributor | Feed code | Solidlio code | Location |
|---|---|---|---|
| Ingram | 10 | CA-V | Vancouver (Richmond), BC |
| Ingram | 40 | CA-M | Toronto (Mississauga), ON |
| Ingram | 05 | CA-VDC | Virtual DC — services and electronic delivery |
| TD Synnex | col 16 | DHA | Dartmouth, NS |
| TD Synnex | col 20 | DCG | Calgary, AB |
| TD Synnex | col 22 | DGU | Guelph, ON |
| TD Synnex | col 61 | DMS | Mississauga, ON |
| TD Synnex | col 62 | DRN | Richmond (Nelson), BC |
Ingram codes 10 and 40 are counter-intuitive — 10 is the Vancouver warehouse.
Quantity 9999 is a flag, not a count. Both distributors use
9999to mean “available, quantity undisclosed” on services, warranties and electronically delivered licences. Ingram does not — see §9.
Roles and permissions
No distributor page carries its own guard.
Walkthroughs
Bring a distributor online
Credentials are read from environment variables, not from the database.
- Go to Platform → Catalog → Distributors. The distributor row appears automatically the first time a sync runs — Ingram self-creates a row with code
INGRAM, TD Synnex with codeTDSYNNEX. - Open the distributor → Settings → set Cost Currency to the currency the distributor invoices you in, and press Save Currency. This drives the store’s multi-currency conversion, so set it before the first price import.
The SFTP Configuration card on that page writes to
Distributor.sftpConfig, which only the Test Connection button reads. It does not change where the importer connects. See §9.
Run the first Ingram import
Run the three feeds in this order — availability only updates stock rows that the price sync creates, and categories should exist before products reference them.
- Platform → Catalog → Distributors → Ingram Micro → Manage.
- In the Categories sync card, press Sync Now. Wait for the card to show a completed timestamp.
- In the Price sync card, press Sync Now. This is the long one — it creates or matches every product and writes
DistributorStock. - In the Availability sync card, press Sync Now.
- Open Category Mapping → Manage and Brand Mapping → Manage to review what mapped (§4.4).
Each button returns 202 Accepted immediately and runs in the background; the page does not block. Watch progress on the Recent Syncs table on the distributor overview page.
Run the TD Synnex import
TD Synnex has one feed, so there is one button.
- Platform → Catalog → Distributors → TD Synnex → Manage.
- Press Sync Now on the Price card.
The importer lists the SFTP directory, sorts filenames descending and takes the first — it assumes the newest P&A file sorts last by name.
Map categories and brands
Distributor taxonomies do not match Solidlio’s, so each distributor code must be mapped to a Solidlio category or brand.
- Distributor → Category Mapping → Manage (or Brand Mapping).
- Filter by Source =
UNMAPPEDto see what needs attention. - Either set a target on a row with the per-row dropdown — that records
mappingSource = MANUALat confidence 1.0 — or press Auto-Map All. - Auto-Map All asks for confirmation, then applies every suggestion at or above the auto-approve threshold and leaves the rest as suggestions.
How the confidence is produced
| Strategy | Max confidence | Can auto-approve? |
|---|---|---|
| Brand — exact name match | 1.00 | Yes (threshold 0.95) |
| Brand — Icecat supplier match | 0.95 | Yes |
| Brand — leading-token match | 0.95 | Yes |
| Brand — alias table | 0.90 | No — review required |
| Brand — fuzzy (Levenshtein/Jaccard/ngram) | 0.85 | No — review required |
| Category — Icecat bridge | 0.95 | Yes (threshold 0.90) |
| Category — name similarity | 0.85 | No — review required |
| Category — path similarity | 0.80 | No — review required |
The Icecat bridge is how categories actually get mapped at scale: for a distributor category, Solidlio looks at up to 100 of its products that already matched an Icecat-linked catalogue category and takes the dominant one.
A loose alias plus an unordered lookup once auto-mapped a large block of real products onto the “Microsoft Demo” supplier; alias matching is now whole-word and the Icecat supplier lookup is an exact match.
Manage warehouses
Distributor → Settings → Warehouses. Add, edit, or delete rows. The code field is immutable once created, because it is the key inside DistributorStock.warehouseStock. Deleting a warehouse asks for confirmation and does not rewrite existing stock rows.
Test SFTP reachability
Distributor → Settings → SFTP Configuration → Test Connection. This dials the host in Distributor.sftpConfig, lists /, and reports connected/not-connected plus latency and file count.
Key paths are constrained to SFTP_KEY_DIR (default /etc/solidlio/sftp-keys) and any path containing .. is rejected before the filesystem is touched.
Configuration
Every value is optional; a sync throws if the host or username for its feed is empty.
Ingram Micro
Ingram issues a separate login per feed. All three must be set for a full import.
| Variable | Default | Used for |
|---|---|---|
INGRAM_SFTP_USER | — | Price feed username |
INGRAM_SFTP_USERNAME | — | Alternative to INGRAM_SFTP_USER |
INGRAM_CATS_USERNAME | — | Categories feed username |
INGRAM_AVAIL_USERNAME | — | Availability feed username |
INGRAM_SFTP_KEY_PATH | — | Key auth; if set, replaces password auth on all feeds |
INGRAM_SFTP_DIRECTORY | /outbound/pricing | See note below |
INGRAM_ACCOUNT_NUMBER | — | Recorded only |
Set it only if your account uses a genuinely different directory.
TD Synnex
| Variable | Default | Used for |
|---|---|---|
SYNNEX_SFTP_USER | — | Required; sync throws without it |
SYNNEX_SFTP_KEY_PATH | — | Key auth; if set, replaces password |
SYNNEX_SFTP_DIRECTORY | / | Where the P&A file is listed |
SYNNEX_TRADING_PARTNER | — | Recorded only |
Shared
| Variable | Default | Effect |
|---|---|---|
SFTP_KEY_DIR | /etc/solidlio/sftp-keys | Directory test-connection key paths must live under |
Database-held settings
| Field | Where edited | Effect |
|---|---|---|
Distributor.costCurrency | Settings → Cost Currency | Real. Stamped on products the Ingram sync creates; drives FX-normalised lowest-cost. |
Distributor.isActive | — | Read by the list filter; no UI writes it. |
Distributor.sftpConfig | Settings → SFTP Configuration | Read only by Test Connection. Not used by the importer. |
Distributor.syncSchedule | Settings → Sync Schedule | Stored; does not schedule anything. See §9. |
Changing costCurrency after products exist does not restate stored costs.
Plan tiers
None. Distributor integration is gated only by the platform administrator role.
Troubleshooting
| Symptom / message | Cause | Fix |
|---|---|---|
Availability sync not supported for distributor: TDSYNNEX (400) | TD Synnex has no separate availability feed | Run the price sync; it carries stock |
Sync not implemented for distributor: <CODE> (400) | The distributor code is not in the sync registry (INGRAM, TDSYNNEX, SYNNEX) | Only these two distributors have importers |
No matching file found in / | The expected archive is absent from the SFTP directory | Check INGRAM_SFTP_DIRECTORY; remember the /outbound/pricing fallback |
No P&A files found on SFTP | SYNNEX_SFTP_DIRECTORY has no .txt/.dat/.csv/.zip/.ap file | Confirm the directory and that TD Synnex has published |
No TXT file found in ZIP archive | The downloaded archive did not contain the expected .TXT | Re-run; if it persists, the feed layout changed |
Invalid SFTP key path (400) | The key path contains .., or resolves outside SFTP_KEY_DIR | Move the key under SFTP_KEY_DIR |
Warehouse with this code already exists (409) | Duplicate warehouse code for this distributor | Edit the existing row instead |
A sync log is stuck at RUNNING | The pod died mid-import | On next boot, runs older than 6 hours are reconciled to FAILED with Reconciled: orphaned by service restart |
| Availability sync updated nothing | Availability only updates stock rows the price sync created | Run price first, then availability |
Limits and known behaviour
Every distributor sync was therefore operator-triggered only.
Whether a scheduled run actually happened is now answerable. See Platform → Background Jobs: it reports each job’s last run, its last success, and flags a job that has not run within its expected cadence. The “Last sync” figures on this page are row watermarks and still stand still on a day the feed changed nothing — which is exactly why the job health page exists.
Both sync services read host, username, password and key path from environment variables. The form also has no password field, so a password-authenticated distributor can never be fully described by it.
There is no way to create, rename, deactivate or delete a distributor. No POST /admin/distributors or DELETE /admin/distributors/:id route exists, and no screen calls the PATCH that could set isActive. Rows appear only via the seed or the first sync run.
There is no sync-logs page. Six links across five screens point at /platform/catalog/distributors/<id>/logs; that route does not exist and all of them 404. The data is available from GET /sync/logs.
Sync preview is a stub. GET /:id/sync/preview validates its query and then returns { preview: [], message: "Preview functionality requires SFTP connection" } regardless of input.
A running sync cannot be cancelled from the UI. DELETE /sync/cancel works and is tested, but no screen calls it.
Availability and category syncs cannot be started from the distributor list. Only the distributor detail page has those buttons; the list page’s Sync Now always requests price.
Sync-log status is always written COMPLETED on a non-throwing run. The services compute a success flag separately — Ingram’s price sync tolerates up to 10% errored records — but the log row is written COMPLETED before that flag is evaluated. A run with many per-record errors reads as successful in the log.
Two counts on the Recent Syncs table are wrong. The api-client SyncLogEntry type declares recordsProcessed/recordsCreated/recordsUpdated/ recordsFailed/errorMessage; the columns are actually processedCount, createdCount, updatedCount, errorCount, errors. The UI renders sync.recordsProcessed ?? 0, so Records is permanently 0. The same type omits CANCELLED from its status union.
Ingram does not apply the 9999 availability flag. TD Synnex excludes 9999 from its Canadian total; Ingram writes it through verbatim, so an Ingram service or ESD SKU reports 9,999 units on hand. Anything comparing stock across the two distributors will prefer Ingram for these SKUs incorrectly.
TD Synnex products are not stamped with the distributor’s cost currency. The Ingram sync copies Distributor.costCurrency onto each product it creates; the TD Synnex sync does not, so those products take the CatalogProduct.costCurrency default of CAD regardless of how the distributor is configured.
TD Synnex never stamps lastAvailSync or lastCategorySync, because it only runs the price path. Those fields stay null forever and any health check reading them will report the feed as never having run.
TD Synnex parse errors are not persisted. The Ingram syncs push parse errors into DistributorSyncLog.errors; the TD Synnex sync logs the first five and discards the rest, so a run that dropped thousands of malformed lines still reads clean.
The Ingram price parser does not handle quoted fields. It splits on commas with no quote awareness. A comma inside a description shifts every later field — cost, UPC, MPN — and the shifted line still passes the field-count check, so it lands in the catalogue with no recorded error.
TD Synnex ETAs are discarded. The P&A parser reads an ETA date but toDistributorStockData hard-codes eta: null for every warehouse, so warehouseDetails never carries one. The Ingram availability path does populate it.
TD Synnex file selection is by filename sort. syncFromSftp lists the directory, filters to .txt/.dat/.csv/.zip/.ap, sorts, and takes the last. Any unrelated file in that directory that sorts later will be picked instead.
On a seeded database both rows exist, and all synced stock and logs attach to tdsynnex — so triggering a sync from the SYNNEX row appears to do nothing.
/platform/integrations/distributors is not a working screen. It renders hardcoded sample data (useDemoData = true) and every action on it — Full Catalog Sync, Test Connection, Disconnect, per-feed Sync Now, Save Settings, and the account-number/API-key/API-secret Connect form — has no handler attached. Use Platform → Catalog → Distributors. Note that the TD Synnex and Ingram Micro cards in the MSP, Org and Settings integration lists link to this page.
They are not reachable from the UI.
Dead schema fields. DistributorSyncLog.fileSize is never written. fileName is written only by the local-file TD Synnex path, never by an SFTP run. duration and errors are written but nothing reads them — the UI recomputes duration from the timestamps.
Auto-mapping is a full-table scan per row. Both mappers load every active brand or catalogue category and score it, once per distributor record, inside a sequential loop. Expect auto-map to be slow on a large catalogue.
Foreign-exchange rates are used without a staleness bound. Lowest-cost comparison converts through getRate without passing maxAgeMs, so if FX sync stops, cost comparison silently keeps using the last known rate.
Test coverage
The POST /sync/{price,avail,categories} happy paths are deliberately not covered end to end, because they require live SFTP credentials; the tests cover the guard, conflict, cancellation and error branches instead.