Customers

TL;DR

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

TableHolds
customersalesordersCCC's own answer: the published grouping and order sequence
vkd_customersalesordersThe 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:

VerdictMeans
CCC numbering lowerOften missing earlier history; also inspect grouping, dates and counting criteria
The two agreeThis order has the same number; that alone does not prove identical grouping
CCC numbering higherCCC grouped more orders onto one customer than the old pipeline did
NULL on the vkd_ sideThe 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

PeriodSourceNotes
Grouping and sequenceComputed by CCC from ordersRebuilt on reconcile, not incrementally patched
Comparison sequenceverkoopdumpRefreshed 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 customersalesorders row 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: mergedinto chains 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 mergedinto to 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.

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.