'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 "x" (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 };