@classytic/supplier-performance
v0.1.1
Published
Framework-agnostic supplier performance engine — on-time-delivery, defect, and price-variance tracking with append-only event log + cached scorecards. Powered by MongoDB via @classytic/mongokit. Zero framework deps — usable from Arc, Express, Nest, Ne
Readme
@classytic/supplier-performance
Framework-agnostic supplier performance engine — on-time-delivery, defect, and price-variance tracking with an append-only event log and cached scorecards. Powered by MongoDB via
@classytic/mongokit.
flow.procurement.received
│
▼
recordEvent(supplierId, type, metrics) ──▶ performance_event (append-only log)
│
▼
computeScore(supplierId, period)
│
▼
supplier_score (cached, upsert per period)Zero framework dependencies — usable from Arc, Express, Nest, Next.js, or raw Node.
Install
npm install @classytic/supplier-performance @classytic/mongokit @classytic/primitives mongoosePeer deps:
| | |
|---|---|
| @classytic/mongokit | >=3.11.0 |
| @classytic/primitives | >=0.1.0 |
| mongoose | >=9.4.1 |
Quick start
import mongoose from 'mongoose';
import { createSupplierPerformance } from '@classytic/supplier-performance';
await mongoose.connect('mongodb://localhost/myapp');
const engine = await createSupplierPerformance({ connection: mongoose.connection });
await engine.syncIndexes();
// Host bridges sibling events. Example: turn `flow.procurement.received`
// into a delivery_received event on the supplier's record.
const ctx = {
organizationId: 'org_42',
actorRef: 'system:cron',
actorKind: 'system' as const,
correlationId: 'req_xyz',
};
await engine.services.score.recordEvent(
{
supplierId: 'SUP-001',
type: 'delivery_received',
occurredAt: new Date(),
metrics: { quantity: 100 },
sourceRef: 'PO-2026-0042',
sourceType: 'procurement_order',
},
ctx,
);
// Compute (and cache) a scorecard for a window
const scorecard = await engine.services.score.computeScore(
{
supplierId: 'SUP-001',
period: {
start: new Date('2026-01-01'),
end: new Date('2026-04-01'),
label: '2026-Q1',
},
},
ctx,
);
console.log(scorecard.metrics);
// {
// deliveryCount: 10,
// onTimeCount: 8,
// onTimeRate: 0.8,
// avgDelayDays: 0.6,
// unitsReceived: 1000,
// defectiveUnits: 5,
// defectRate: 0.005,
// priceVarianceCount: 2,
// avgPriceVariance: 0.04,
// compositeScore: 89.5
// }Architecture
Event types
The catalog ships 4 event types — extend by recording additional metadata fields, not new types (kernel-level enum changes break consumers).
| Type | Drives | Required metrics |
|---|---|---|
| delivery_received | on-time rate, units received | quantity |
| delivery_late | on-time rate, average delay | quantity, delayDays |
| defect_reported | defect rate, defective units | quantity |
| price_variance | average variance % | expectedUnitCost, actualUnitCost, variancePct |
Score formula
The default composite score is bounded 0..100 (higher = better):
score = onTimeRate × 50
+ (1 − defectRate) × 40
+ (1 − |variance|) × 10Hosts that need different weights re-rank events directly via the scoreFromEvents pure helper or write their own roll-up.
Repositories
| Repo | Verbs |
|---|---|
| performanceEvent | inherited mongokit CRUD + findInWindow(supplierId, start, end, ctx) |
| supplierScore | inherited CRUD + upsertForPeriod(input, ctx), findLatest(supplierId, ctx) |
Service
| Verb | Behavior |
|---|---|
| recordEvent(input, ctx) | Append one row to the event log; emit supplier_performance:event.recorded. |
| computeScore({ supplierId, period }, ctx) | Load events in window → run scoreFromEvents → upsert score. Idempotent. Emits supplier_performance:score.computed. |
| getScorecard(supplierId, ctx, period?) | When period is supplied, recompute. Otherwise return the latest persisted snapshot. |
Events
supplier_performance:event.recorded
supplier_performance:score.computedUse any EventTransport-shaped bus (Arc's MemoryEventTransport, Redis, Kafka, …). The bundled InProcessSupplierPerformanceBus is a zero-dep default.
Multi-tenant
Drives off @classytic/primitives/tenant. Default field type is 'string' (matches the legacy convention shared by sibling packages):
const engine = await createSupplierPerformance({
connection,
tenant: { fieldType: 'objectId' }, // when org IDs are stored as ObjectId
});Indexes
| Collection | Index | Purpose |
|---|---|---|
| supplier_performance_events | { supplierId, occurredAt } | Range query for scoring window |
| supplier_performance_events | { sourceRef, type } (partial) | Drill-down from PO / RMA into the events that fired |
| supplier_scores | { supplierId, period.start, period.end } (unique) | One snapshot per (supplier, period) |
| supplier_scores | { period.end ↓, metrics.compositeScore ↓ } | Dashboard "top suppliers this month" |
The tenant field is prepended to compound indexes via injectTenantField when scoping is enabled.
Soft delete
Every collection wires softDeletePlugin with a default 7-year retention. Hosts override via retentionDays on the engine config.
Why a service (and not just repos)?
Per PACKAGE_RULES §2, a service is justified when it does ≥1 of:
- multi-repo orchestration ✅ (event log + score cache)
- pure domain algorithm ✅ (
scoreFromEventsis incalc/, called by the service) - event emission ✅
So ScoreService exists. Pure scoring is testable in isolation via scoreFromEvents.
Bridges (host-side)
This package never imports @classytic/flow, @classytic/order, or any sibling — supplier-performance is downstream. The host wires whatever signals it cares about:
import { subscribe } from '#lib/events/arcEvents.js';
subscribe('flow.procurement.received', async (event) => {
const order = await flow.repositories.procurement.getByQuery(/* … */);
const onTime = order.expectedAt && new Date() <= order.expectedAt;
await engine.services.score.recordEvent(
{
supplierId: order.vendorRef,
type: onTime ? 'delivery_received' : 'delivery_late',
occurredAt: new Date(),
metrics: {
quantity: totalReceived,
...(order.expectedAt ? { expectedAt: order.expectedAt, actualAt: new Date() } : {}),
},
sourceRef: order.orderNumber,
sourceType: 'procurement_order',
},
ctx,
);
});License
MIT
