order-board.js
Rama main · réplica de lectura del repositorio original.
'use strict';
// The packing board: incoming shop orders, read as the two questions the floor
// asks of them every morning — what goes into a parcel today, and what has to be
// made first.
//
// The orders themselves arrive through the Billbee sync in server.js (every
// 15 minutes, channels.js → billbee.fetchOrders), so nothing here talks to the
// network. This module only reads what the sync stored and sorts it: pure
// functions over plain objects, so the tests can hand it orders directly.
//
// It never sees a customer. The Billbee import keeps no name, address or e-mail
// (see _billbeeLabRecord in channels.js), and what a packer needs to know about
// the buyer is only "these two orders go to the same person", which the import
// carries as an anonymous key.
//
// ── Why the board keeps its own marks ──────────────────────────────────────
//
// Billbee's state cannot say whether a parcel has been packed. Its timed rules
// buy the GLS label and set the order to "Versendet" at 07:00 and 12:00, before
// anybody has packed it — and that label day is exactly when the parcel has to
// go out. So an order leaves "Heute packen" only when someone taps "Gepackt"
// here, and a made-to-order one reaches it only when someone taps
// "Hergestellt". Both marks live in this app's database (order_marks, keyed on
// the Billbee order id, with who and when). Billbee's state is shown on the
// card as information, and nothing is ever written back to Billbee.
// How far back the board looks. The same window the Billbee sync pulls, so the
// board never shows an order whose state the sync has stopped refreshing.
const BOARD_WINDOW_DAYS = 30;
// A sync older than this is reported as stale: the poll runs every 15 minutes,
// so an hour without one means it has stopped, not that it is slow.
const SYNC_STALE_MS = 60 * 60 * 1000;
// The farm's day. Orders arrive stamped in UTC; "today" and "in four working
// days" are counted in the time zone the team works in.
const BOARD_TZ = 'Europe/Berlin';
// Products made fresh for each order rather than taken from the shelf. Their
// parcel waits until somebody marks them made, and Billbee buys the label by its
// own rule a fixed number of working days after the order. The figure here has
// to match that rule; the board shows it as the planned label day.
const MADE_TO_ORDER = [{ skuPrefix: 'SPWN-DUBL-', labelAfterWorkdays: 4 }];
// The Billbee tag its rules set on orders that go out in one parcel.
const COMBINE_TAG = 'ZUSAMMENFASSEN';
// Billbee states that take an order off the board: Reklamation (5, returns and
// refunds), gelöscht (6), storniert (8). Billbee's import maps 6 and 8 to the
// hub's 'cancelled' already; 5 it leaves open, because a return is not a
// cancellation for the books, but it is nothing to pack.
const DROPPED_STATES = [5, 6, 8];
// Positions that are not things to pack: Billbee hands discounts over as lines
// of their own, often without a SKU.
const NON_ITEM_TITLE = /^\s*(rabatt|gutschein|discount|coupon|versand|shipping)\b/i;
/** The lines of an order that are something to pack, with their quantity. */
function packLines(items) {
const out = [];
for (const it of items || []) {
const qty = Number(it.qty);
if (!Number.isFinite(qty) || qty <= 0) continue;
if (!it.sku && NON_ITEM_TITLE.test(String(it.title || ''))) continue;
out.push({ sku: it.sku || null, title: it.title || null, qty });
}
return out;
}
/**
* A refund, not an order. When a purchase is refunded Billbee keeps a copy of
* it with negative quantities, in state "bestellt" for good — it would sit on
* the board as an unpaid order for a month. Nothing in it is positive.
*/
function isRefundRecord(items) {
const qtys = (items || []).map((it) => Number(it.qty)).filter(Number.isFinite);
return qtys.some((q) => q < 0) && !qtys.some((q) => q > 0);
}
/**
* A SKU read as product and size. The shop's SKUs are PRODUCT-VARIANT, where
* the variant is either "<count>x<grams>" (KOMP-AIOCVG-3x1750: three bags of
* 1750 g) or a name (SPWN-DUBL-BLUE). The product is the first two parts.
* Anything else counts as one of whatever the rest says.
*/
function skuParts(sku) {
const parts = String(sku || '')
.trim()
.split('-');
const product = parts.slice(0, 2).join('-');
const variant = parts.slice(2).join('-');
const m = /^(\d+)x(\d+)$/i.exec(variant);
if (m) return { product, variant, perPack: Number(m[1]) || 1, size: m[2], grams: Number(m[2]) };
return { product, variant, perPack: 1, size: variant || null, grams: null };
}
/**
* Everything the open orders need, counted once: per product the number of
* pieces (bags, plugs, kits), how many of each size, and which SKUs were
* ordered. This is the shelf list — pull it all, then pack order by order.
*/
function buildTotals(rows) {
const byProduct = new Map();
for (const r of rows) {
for (const it of r.items) {
const key = it.sku ? skuParts(it.sku).product.toUpperCase() : 'ohne-sku:' + String(it.title || '?');
if (!byProduct.has(key)) {
byProduct.set(key, {
product: it.sku ? skuParts(it.sku).product : null,
title: it.sku ? null : it.title || null,
pieces: 0,
orders: new Set(),
sizes: new Map(),
skus: new Map(),
madeToOrder: false
});
}
const t = byProduct.get(key);
const p = it.sku ? skuParts(it.sku) : { perPack: 1, size: null, grams: null };
const pieces = p.perPack * it.qty;
t.pieces += pieces;
t.orders.add(r.id);
if (it.madeToOrder) t.madeToOrder = true;
const sizeKey = p.size == null ? '' : String(p.size);
const sz = t.sizes.get(sizeKey) || { size: p.size, grams: p.grams, pieces: 0 };
sz.pieces += pieces;
t.sizes.set(sizeKey, sz);
if (it.sku) t.skus.set(it.sku, (t.skus.get(it.sku) || 0) + it.qty);
}
}
return [...byProduct.values()]
.map((t) => ({
product: t.product,
title: t.title,
pieces: t.pieces,
orders: t.orders.size,
madeToOrder: t.madeToOrder,
sizes: [...t.sizes.values()].sort(
(a, b) => _cmp(a.grams, b.grams) || _cmp(String(a.size || ''), String(b.size || ''))
),
skus: [...t.skus.entries()].sort((a, b) => _cmp(a[0], b[0])).map(([sku, qty]) => ({ sku, qty }))
}))
.sort((a, b) => _cmp(a.product || a.title, b.product || b.title));
}
const _dayFmt = new Intl.DateTimeFormat('en-CA', {
timeZone: BOARD_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
/** 'YYYY-MM-DD' of an instant, in the farm's time zone. null for no date. */
function localDay(iso) {
if (!iso) return null;
const d = iso instanceof Date ? iso : new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return _dayFmt.format(d);
}
/**
* The day `n` working days after `day`, counting Monday to Friday (public
* holidays are not taken out). Day 1 is the first working day *after* the order
* day, so an order from Monday lands on Friday, and one from Saturday or Sunday
* on the Thursday after.
*/
function addWorkdays(day, n) {
if (!day) return null;
const [y, m, d] = day.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
let left = Math.max(0, n | 0);
while (left > 0) {
dt.setUTCDate(dt.getUTCDate() + 1);
const wd = dt.getUTCDay();
if (wd !== 0 && wd !== 6) left--;
}
return dt.toISOString().slice(0, 10);
}
/** The made-to-order rule a SKU falls under, or null for a shelf product. */
function madeToOrderRule(sku, rules = MADE_TO_ORDER) {
const s = String(sku || '').toUpperCase();
if (!s) return null;
return rules.find((r) => s.startsWith(r.skuPrefix.toUpperCase())) || null;
}
/**
* Paid, as far as the stored record says. Billbee states it twice — a payment
* date, and state 3 ("bezahlt") — and either is enough. Orders from the direct
* shop connections arrive only once they are paid, so they count as paid.
*/
function isPaid(order) {
if (order.channel !== 'billbee') return true;
const raw = order.raw || {};
return !!raw.PayedAt || Number(raw.State) === 3;
}
/**
* What Billbee says about the order, for the card: 'label' once a label has
* been bought, 'shipped' once Billbee calls it versendet without one, 'open'
* otherwise. Information only — none of the three takes an order off the list.
*/
function billbeeState(order) {
const raw = order.raw || {};
if (raw.LabelAt || Number(raw.LabelCount) > 0) return 'label';
if (order.status === 'shipped') return 'shipped';
return 'open';
}
function _shopName(order) {
const raw = order.raw || {};
return (order.channel === 'billbee' && raw.ShopName) || null;
}
function _tags(order) {
const raw = order.raw || {};
return Array.isArray(raw.Tags) ? raw.Tags.map(String) : [];
}
/** A link into Billbee, only when it is one. */
function _webUrl(order) {
const u = order.raw && order.raw.WebUrl;
return typeof u === 'string' && /^https:\/\//i.test(u) ? u : null;
}
function _mark(order, kind) {
const m = order.marks && order.marks[kind];
return m && m.at ? { by: m.by || null, at: m.at } : null;
}
/**
* Turn the stored orders into the board.
*
* @param {Array} orders rows from db.listBoardOrders: {id, channel,
* channelOrderId, status, orderDate, raw, items:[{sku, title, qty}],
* marks:{packed?:{by, at}, made?:{by, at}}}
* @param {object} [opts]
* @param {Date|string} [opts.now] for tests; defaults to the current time
* @param {string|null} [opts.marksSince] when the marks began (db.orderMarksSince)
* @param {Array} [opts.rules] made-to-order rules; defaults to MADE_TO_ORDER
*/
function buildOrderBoard(
orders,
{ now = new Date(), marksSince = null, showNames = false, rules = MADE_TO_ORDER } = {}
) {
const today = localDay(now);
const open = [];
const done = [];
let unpaid = 0;
for (const o of orders || []) {
const raw = o.raw || {};
if (o.status === 'cancelled' || DROPPED_STATES.includes(Number(raw.State))) continue;
if (isRefundRecord(o.items)) continue;
const packed = _mark(o, 'packed');
const made = _mark(o, 'made');
const bb = billbeeState(o);
// Packed before the board could be tapped: a label bought (or the order
// shipped) before the marks began went out without anybody marking it.
if (!packed && bb !== 'open' && marksSince) {
const when = raw.LabelAt || raw.ShippedAt || o.orderDate;
// Compared as instants: Billbee writes its own offset, the app writes UTC.
if (when && new Date(when).getTime() < new Date(marksSince).getTime()) continue;
}
if (!packed && bb === 'open' && !isPaid(o)) {
unpaid++;
continue;
}
const orderDay = localDay(o.orderDate);
let plannedLabelDay = null;
const items = packLines(o.items).map((it) => {
const rule = madeToOrderRule(it.sku, rules);
if (rule) {
const d = addWorkdays(orderDay, rule.labelAfterWorkdays);
if (d && (!plannedLabelDay || d > plannedLabelDay)) plannedLabelDay = d;
}
return { ...it, madeToOrder: !!rule };
});
const madeToOrder = items.some((it) => it.madeToOrder);
const tags = _tags(o);
const row = {
id: o.id,
channel: o.channel,
shop: _shopName(o),
// Who it is for, first on the card — only for users who may see it.
customer: showNames ? o.customerName || null : null,
number: raw.OrderNumber || o.channelOrderId,
billbee: bb,
orderDate: o.orderDate || null,
orderDay,
items,
madeToOrder,
plannedLabelDay,
// The label day has come (or gone): Billbee buys it today or already did.
labelDue: !!plannedLabelDay && plannedLabelDay <= today,
packed,
made: madeToOrder ? made : null,
// Waiting for something to be made before it can go into a parcel.
needsMaking: madeToOrder && !made,
tags,
customerKey: raw.CustomerKey || null,
taggedCombine: tags.some((t) => t.toUpperCase() === COMBINE_TAG),
webUrl: _webUrl(o),
combine: null,
waitsForGroup: false
};
if (packed) {
// Erledigt heute: packed today. Packed on an earlier day is simply done.
if (localDay(packed.at) === today) done.push(row);
continue;
}
open.push(row);
}
// Orders that go out in one parcel. Billbee's tag says so outright; two open
// orders from the same buyer say so too, whether or not a rule tagged them.
const byKey = new Map();
for (const r of open) {
if (!r.customerKey) continue;
if (!byKey.has(r.customerKey)) byKey.set(r.customerKey, []);
byKey.get(r.customerKey).push(r);
}
for (const [key, group] of byKey) {
if (group.length < 2) continue;
// One parcel is packed when its last part is made: a shelf order from the
// same buyer as an unmade made-to-order one waits with it rather than going
// out alone and costing a second postage.
const waiting = group.some((r) => r.needsMaking);
for (const r of group) {
r.combine = { key, size: group.length };
if (waiting && !r.needsMaking) r.waitsForGroup = true;
}
}
for (const r of open) {
if (!r.combine && r.taggedCombine) r.combine = { key: null, size: 1 };
}
const pack = open.filter((r) => !r.needsMaking && !r.waitsForGroup);
const later = open.filter((r) => r.needsMaking || r.waitsForGroup);
// A bought label first: that parcel has to leave today. Then the oldest.
const urgency = (r) => (r.billbee === 'open' ? 1 : 0);
_sortKeepingGroups(pack, (a, b) => _cmp(urgency(a), urgency(b)) || _cmp(a.orderDate, b.orderDate));
_sortKeepingGroups(later, (a, b) => _cmp(a.plannedLabelDay, b.plannedLabelDay) || _cmp(a.orderDate, b.orderDate));
done.sort((a, b) => _cmp(b.packed.at, a.packed.at));
return {
today,
windowDays: BOARD_WINDOW_DAYS,
pack,
later,
make: buildMakeList(open, today),
totals: buildTotals(open),
done,
unpaid
};
}
/**
* What has to be made: the made-to-order lines of every open order nobody has
* marked made yet, totalled per SKU, each with the days their labels are due.
* Sorted by the earliest of those days — the next thing to make is the one
* whose label comes first.
*/
function buildMakeList(open, today) {
const bySku = new Map();
for (const r of open) {
if (!r.needsMaking) continue;
for (const it of r.items) {
if (!it.madeToOrder) continue;
const key = String(it.sku).toUpperCase();
if (!bySku.has(key)) bySku.set(key, { sku: it.sku, title: it.title, qty: 0, orders: 0, days: new Map() });
const m = bySku.get(key);
m.qty += it.qty;
m.orders++;
if (!m.title && it.title) m.title = it.title;
const day = r.plannedLabelDay || today;
m.days.set(day, (m.days.get(day) || 0) + it.qty);
}
}
return [...bySku.values()]
.map((m) => {
const byDay = [...m.days.entries()].sort((a, b) => _cmp(a[0], b[0])).map(([day, qty]) => ({ day, qty }));
return {
sku: m.sku,
title: m.title,
qty: m.qty,
orders: m.orders,
earliestDay: byDay.length ? byDay[0].day : null,
// Due today or late: the label is bought, the product is not made.
overdue: byDay.length ? byDay[0].day <= today : false,
byDay
};
})
.sort((a, b) => _cmp(a.earliestDay, b.earliestDay) || _cmp(a.sku, b.sku));
}
/**
* Where the board's data stands, as one word the interface can say in plain
* language. The raw sync error is a message like "Billbee Aufträge HTTP 401" —
* accurate, and of no use to someone at a packing table.
*/
function syncProblem(cfg, { now = new Date() } = {}) {
if (!cfg || !cfg.enabled) return 'off';
if (!cfg.connected) return 'not_connected';
const err = String(cfg.lastError || '');
if (err) {
if (/HTTP 40[13]\b|Doppelpunkt|fehlt|erforderlich/i.test(err)) return 'auth';
if (/HTTP 429\b/.test(err)) return 'busy';
if (/HTTP 5\d\d\b/.test(err)) return 'down';
if (/timeout|abort|fetch failed|ENOTFOUND|ECONNRE|EAI_AGAIN|network/i.test(err)) return 'offline';
return 'failed';
}
if (!cfg.lastSync) return 'never';
const age = new Date(now).getTime() - new Date(cfg.lastSync).getTime();
if (Number.isFinite(age) && age > SYNC_STALE_MS) return 'stale';
return null;
}
function _cmp(a, b) {
if (a === b) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a < b ? -1 : 1;
}
// Sort, then pull each combine group together at the position of its first
// member, so the two orders for one parcel sit next to each other on the list.
function _sortKeepingGroups(rows, cmp) {
rows.sort(cmp);
const out = [];
const placed = new Set();
for (const r of rows) {
if (placed.has(r)) continue;
const key = r.combine && r.combine.key;
const group = key ? rows.filter((x) => x.combine && x.combine.key === key) : [r];
for (const g of group) {
out.push(g);
placed.add(g);
}
}
rows.splice(0, rows.length, ...out);
}
module.exports = {
BOARD_WINDOW_DAYS,
MADE_TO_ORDER,
COMBINE_TAG,
localDay,
addWorkdays,
madeToOrderRule,
isPaid,
isRefundRecord,
skuParts,
billbeeState,
buildTotals,
buildOrderBoard,
buildMakeList,
syncProblem
};