Products, channels & reporting (seams)
A product is the hub of the catalog, but most of what makes a product useful happens in systems that sit next to it: it gets listed on sales channels, its channel counterparts get synced, its performance is rolled up into dashboards and scorecards, it feeds what-if profitability models, it shows up as a row in the report builder, and it gets indexed so search can find it.
This page maps those seams — the connection points from the product out to each neighbouring system — so you understand what crosses each boundary and why. It deliberately doesn't teach you how to operate those systems. Each one has its own home in the docs, and this page links you there. Think of it as the wiring diagram behind the product, not the manual for any single appliance.
For the inventory seam (stock levels, opening balance, FIFO valuation), see the Inventory guides instead — that facet is documented under View a product's stock and Browse FIFO layers, and isn't repeated here.
The product as a hub
At a high level, a single product record radiates out to six adjacent systems. Each arrow is a seam this page describes.
None of these systems own the product; the product owns a reference to each of them (or is referenced by them). Understanding the direction of ownership at each seam is the useful thing — it tells you what happens when a product is created, edited, merged, or deleted.
Sales-channel listings
A product hasMany listings — one ProductListing per place the product is published or linked on a sales channel. The product's Listings tab surfaces those listings, counted by integration, with the controls to publish and link. From the product's point of view this is a one-way hasMany relationship: the product is the source of truth, and each listing is a downstream projection onto a channel.
A few product-facing facts leak back across this seam and are worth knowing:
has_fbais derived from listings. A product exposes ahas_fbaflag that's not stored — it's computed by asking whether any of the product's listings belong to an FBA channel. When you already have the listings loaded, SKU.io reads the flag from memory; otherwise it runs a lightweight existence query rather than hydrating the whole listings collection.- Listings survive alongside the product and are cleaned up with it. When a product is deleted, its listings are deleted in the same transaction (see Archive, unarchive & delete a product). When two products are merged, the listing rows move to the surviving product rather than being orphaned (see Merge duplicate products).
- Channel orders can create products. An inbound channel listing can spawn a SKU.io product through the "create products from listing" flow — but that flow lives on the listing side, not the product side.
- Data health counts channel coverage. The product data-health check inspects channel mappings as one of its signals, so a product with no listing where you'd expect one shows up there (see Review product data health).
Everything about publishing, mapping, and managing listings — including how kits and bundles are published to a channel and how a bundle expands into component lines on an inbound channel order — is the Listings area, coming in the Listings guide. From the product docs, cross-link there; don't operate listings from here.
Channel product sync
Distinct from your listings is the channel's own representation of a product. Each integration (Shopify, Amazon, WooCommerce, and more) maintains its own product records, and SKU.io unifies them behind a single supertype: SalesChannelProduct.
- One table, many sources.
SalesChannelProductis a supertype row that projects each integration's native channel-product (for exampleAmazonProductorShopifyProduct) into a common shape. During the expand/contract transition itssource_type/source_idpoint back to the authoritative integration row it was projected from, andchannel_typeis the discriminator that tells the integrations apart. - Mapping drives two jobs. Once a channel product is mapped to an internal SKU.io product, that mapping is what pushes inventory out to the channel and what resolves inbound order lines back to the right internal product. The mapping of order lines to channel products runs as a background job.
- Some 3PL joins are by SKU string. A couple of third-party-logistics stock relationships (for example ShipMyOrders and ShipFusion inventory) join to the product on the raw SKU string, not on a foreign key. That coupling is brittle: rename or mismatch a SKU and the join silently misses. Keep SKUs stable and consistent (see Edit a product's details for SKU-change caveats).
The mechanics of each integration's sync — auth, polling, field mapping, conflict resolution — belong to that integration's area, not here. This page only marks the seam so you know channel products and your products are two different things joined by a mapping.
Metric snapshots & scorecards
The product carries a lot of performance data, but almost none of it's entered by hand — it's computed and owned by the demand-planning and reporting area and stored either as pre-aggregated snapshot tables or as cached fields on the product itself.
There are two layers to keep straight:
Snapshot tables (macro dashboards). These are monthly, pre-aggregated buckets read by the macro dashboards:
| Snapshot | Grain | Holds |
|---|---|---|
ProductMetricSnapshot | one row per product per month | sales volume, units sold, COGS, profit, purchase volume, units received/returned, ending on-hand units, ending inventory cost |
ProductChannelMetricSnapshot | product × sales channel × month | the sales-side split of the above per channel (no purchasing columns — purchasing isn't channel-attributable) |
BrandMetricSnapshot | one row per brand per month | brand-level sales and purchasing rollups, plus PO on-time/received counts |
Purchasing figures live only at the product and brand grain, never per channel, because a purchase order isn't attributable to a sales channel. Snapshots are rebuilt in bulk by a background Tracked Job (you can watch its progress in the job tray), and are recomputed incrementally when the underlying data is invalidated.
Cached velocity fields (demand planning). Separately from the snapshot tables, a handful of fields are cached directly on the product so demand planning can read them cheaply: qty_sold_30d / 60d / 90d / 180d, daily_average_consumption, abc_classification, xyz_classification, demand_trend_pct, and velocity_computed_at. These look like ordinary product columns, but you never edit them — they're refreshed by the velocity computation, and velocity_computed_at tells you how fresh they are.
Scorecards. On top of the metrics, each product gets a composite scorecard score. The weighting is tenant-global — a single ProductScorecardSettings row holds the weights, thresholds, and a min_so_count gate. The score is a weighted blend of six dimensions:
| Dimension | What it measures |
|---|---|
gross_margin | margin on sales |
gmroi | gross-margin return on inventory investment |
sales_growth | period-over-period sales change |
sell_through | how fast stock converts to sales |
stock_cover | days of cover on hand |
return_rate | share of units returned |
Two rules matter: the weights must sum to 100, and a product is only scored once it has at least min_so_count sales orders in the period — below that gate it's left unscored rather than scored misleadingly on thin data.
All of this — the dashboards, the scorecard configuration, the velocity model — is documented in the demand-planning and reporting area, not in product CRUD. This page only tells you which fields on a product are computed rather than entered, so you don't try to edit them.
Pro Forma analyzer
Three fields on the product exist purely to feed the Pro Forma Analyzer, a profitability what-if surface:
proforma_shipping_costproforma_landed_cost_percentageproforma_marketplace_cost_percentage
The analyzer combines these with channel and rate-card assumptions to model a product's profitability under different scenarios. A saved scenario is a ProFormaScenario — a per-user what-if snapshot that stores the full input set, the computed outputs, and the rate-card version used, so that when a rate card is superseded the analyzer can prompt you to recalculate a stale scenario. A scenario's product_id is nullable: you can model a purely hypothetical product by entering a manual product profile in the inputs, with no real product behind it.
The analyzer itself is a separate surface with its own documentation. Here, just know that those three proforma_ fields on the product are its inputs (you can set them alongside other financials on the product — see Set a product's accounting & financial line type).
Reporting & the report builder
The product participates in reporting in two ways.
As a morphable reportable. The product is an abstract reportable and morphMany-owns rows in ReportingDailyFinancial (its reportingProducts relation). These daily financial rows are the product's contribution to the financial reporting layer. Because the product owns them, deleting a product deletes its reportingProducts rows in the same transaction as the rest of its cleanup — they're not left dangling.
As a report-builder entity. The custom report builder exposes the product through a ProductEntity that publishes product columns — identity (SKU, name, barcode, MPN), classification (ABC/XYZ, primary category), supplier, velocity, cost, accounting, and the proforma_ fields — as dimensions and measures you can pivot on. Archived products are surfaced with a non-null archived_at so reports can include or exclude them.
The report builder and the financial reporting layer are documented in the reporting area. This seam only notes that a product is both a reportable and a first-class report-builder entity, and that its reporting rows are cleaned up with it.
Search indexing
Products are indexed for search via Laravel Scout, backed by Typesense. Indexing is automatic — the product's Searchable behaviour keeps the index in step with creates, edits, and deletes without any manual step.
What gets indexed is deliberately narrow. A product's searchable document contains:
| Field | Indexed |
|---|---|
id | always |
sku | always |
name | always |
created_at | always (as a timestamp) |
barcode | only when non-empty |
mpn | only when non-empty |
Barcode and MPN are omitted from the document when blank, so an empty barcode never pollutes the index.
Relevance is tuned so identity beats description. The fulltext index uses field weights sku,60 / mpn,20 / name,10, and an exact-SKU match is boosted to the very top via an order-by rule (if(products.sku=?,0,1)) — so typing a full SKU surfaces that product first even if the same string appears inside other products' names. The products list's general search box routes through this fulltext index (generalSearchByFulltext is on), which is why searching there behaves like the Typesense index rather than a plain LIKE scan.
For how the general search box, columns, and filters behave in the UI, see Browse, search & filter the products list and the Products list columns, search & filters reference. The search infrastructure (Scout/Typesense operation) is platform-level and documented outside the product catalog.
Next steps
- How a product flows through the system — the end-to-end lifecycle these seams hang off.
- Products — overview — the map of every product-catalog capability.
- Merge duplicate products — how listings, reporting rows, and mappings are re-homed when products merge.
- Archive, unarchive & delete a product — what's cascaded when a product is removed.
- View a product's stock — the stock/FIFO seam, documented in the Inventory area.