Customers
CCC's computed customer, grouped from orders rather than imported, and the dimension it publishes.
A CCC customer is computed, not imported. NetSuite has customer records, but it does not say which orders belong to the same person across time and channels. CCC works that out by grouping orders that share identifying keys.
Everything surprising about customer figures follows from that one fact.
What it is in CCC
A group of orders judged to belong to one person, with an order sequence and one or more lives computed over it. It has no authoritative upstream identity and it can change shape when new orders arrive or when reconciliation re-runs.
Tables
| Table | Holds |
|---|---|
customersalesorders | CCC's own answer: the published grouping and order sequence |
vkd_customersalesorders | The old pipeline's answer, imported for comparison |
The prefix is the distinction. vkd_ is copied verbatim from verkoopdump; the unprefixed table is what CCC computes and publishes. Joining them on the sales order compares the two.
Reading the comparison: CCC numbering lower usually means history CCC does not hold yet. CCC numbering higher means CCC grouped more orders onto one customer. A NULL on the vkd_ side means the old pipeline never numbered that order, which is not the same as CCC's 0 for an order it deliberately did not count.
Join them on salesorder_id and each row falls into one of four cases:
| Verdict | Means |
|---|---|
| CCC numbering lower | Often missing earlier history; also inspect grouping, dates and counting criteria |
| The two agree | This order has the same number; that alone does not prove identical grouping |
| CCC numbering higher | CCC grouped more orders onto one customer than the old pipeline did |
NULL on the vkd_ side | The old pipeline never numbered that order |
Check import coverage before interpreting a sequence difference as a matching error. Compare the actual order sets and counting criteria, not just the sequence numbers.
Provenance
| Period | Source | Notes |
|---|---|---|
| Grouping and sequence | Computed by CCC from orders | Rebuilt on reconcile, not incrementally patched |
| Comparison sequence | verkoopdump | Refreshed independently from the old pipeline |
The grouping is only as complete as the order history underneath it. Orders CCC does not hold cannot be grouped, which is why an order can look like a customer's first when it is not.
How it is built
app/jobs/customers/reconcile.php recomputes the whole thing once a night, against six tables: customers, customersalesorders and one fingerprint table per identity axis. It recomputes connected groups from shared keys and numbers the orders within each customer and life. See customer matching and customer sequence and life.
customers is computed here; ns_customers is imported from NetSuite. They are different things and their counts need not match: one person routinely holds several NetSuite records, and customers holds only people who have actually ordered.
It writes in place and is not snapshot-consistent, because swapping the tables would reassign identifiers. There is no staging copy.
The schedule is a retry loop, not a single firing
One customers_reconcile batch is queued per night, and the cron line runs every 20 minutes. Almost every run therefore does nothing: it finds no queued batch and exits.
That is the design. A run blocked by a sales order import still in progress costs nothing and simply happens 20 minutes later, so a night cannot be lost to bad timing. A later scheduled run normally sees the batch in progress and leaves it alone. Claiming is not atomic, however: two simultaneous runners can select it before either marks it in progress. Run only one worker; also check for a still-running process before reopening an aged batch.
It blocks only on ns_salesorders, the one job that writes the order columns it reads.
Reading the tables from outside
The columns are a contract: one may be added later, none is renamed or dropped.
Three things to know before trusting a number:
- A past figure can move. Identity is discovered over time, so a merge can turn last year's acquisition into a repeat order, and a merge that fills a gap can reduce a reactivation count. There is no snapshot. An external system that needs a reproducible monthly number keeps its own copy.
- A run in progress is visible, so read these tables when one is not running. A newly imported order gets its
customersalesordersrow before the numbering pass fills the sequence and life columns, so for the few minutes in between that order reads as sequence 0, and a customer whose orders are all new has no row at sequence 1. The next completed run renumbers every customer, so nothing is lost, but a figure taken mid-run can be wrong. - Resolve an identifier in a loop. See merges and splits:
mergedintochains can be more than one hop.
Where it is used
New-versus-returning metrics, reactivation analysis, customer lifetime views, and the downstream BI tools that read them.
It is not an input to margin. CM1 and CM2 do not depend on it, which is why a stale customer dimension leaves every margin figure correct while quietly going out of date. The only signal is its healthcheck.
Known disagreements
- Two customers can become one, moving history: an order that was someone's first becomes their third, and a customer counted as new in March stops being new in March. See merges and splits.
- Membership changes, but absorbed customer rows remain. Follow
mergedintoto resolve an old identifier; a split can give some orders a new customer id. - CCC and the old pipeline disagree by design. That is the reason both tables exist.
Related
- Customer matching
- Customer sequence and life
- Customer merges and splits
- Investigate customer history
- Reconcile customers
Evidence
Grouping from app/models/customers.php::setworknodes(), setunion() and setgroups(); numbering from customers.php:1190-1192. Uncounted statuses from $uncountedstatuses. Table naming and the comparison reading from AGENTS.md, Models and database. The margin independence and its consequence from AGENTS.md, What fails silently. Basis: code-checked against the current implementation.
Matching runs on four fingerprint families, which are the four customerfingerprint* tables: NetSuite entity, email, address and account. Only email and address can be excluded, through config/dictionary/customerexclusions.json, which is read on every reconcile run.
- Type
object - Status
active - Updated
2026-09-14 - Created
2026-09-13