Ctrl K
ComunidadGuías…

syringe-stock.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'use strict';
// Liquid-culture syringes, petri dishes and colonised grain spawn: the lab's
// count, reported to Billbee, which passes it on to every shop it runs (Shopify,
// Etsy, eBay — whichever have Billbee's stock sync switched on at Billbee's end).
// A petri dish and each bag size of grain spawn are counted and reported exactly
// like a syringe size, as one more article per species. Grain spawn counts the
// finished bags still on the shelf (db.js, _grainSaleStock); a bag that finishes
// growing changes no data, so the retry loop below is what reports it, within a
// quarter of an hour.
//
// ── Why a count and not "+3 / −1" ───────────────────────────────────────────
// Billbee only takes an absolute stock figure; its change field is documented as
// ignored. That turns out to be the right shape anyway. A syringe that sells is
// taken off by Billbee the moment the order comes in, and taken off *again* if
// the lab later reported "−1" when it was scanned out for shipping. Reporting
// what is on the shelf, and letting Billbee subtract what is sold but not yet
// shipped (AutosubtractReservedAmount), counts every sale exactly once.
//
// The consequence to know: **a shipped syringe has to be scanned out.** Until it
// is, the lab still counts it, and once Billbee marks the order shipped it stops
// subtracting it — so the shops would offer it again.
//
// ── Why recount instead of hooking every write ──────────────────────────────
// Syringes change through a dozen paths: the lab form, the beimpfen window, a
// scan "raus", undo, stocktake, delete, the bulk import, the MCP tools. Hooking
// each would miss the next one somebody adds. Instead every data change asks for
// a recount a few seconds later (debounced), and a species is only reported when
// its count differs from the one Billbee last accepted. Cheap: one GROUP BY.
//
// Skipped in worktree mode for the same reason as the channel poll: a second
// copy of the server carries the same credentials.

const DEBOUNCE_MS = 5000;
const RETRY_MS = 15 * 60 * 1000;
// How often every figure goes out again, changed or not, with Billbee told to
// pass it on to the shops. Billbee forwards only changes, so a shop edited by
// hand stayed wrong until the next sale — nobody should have to press a button
// to find that out. Six SKUs every six hours is nothing against Billbee's
// limits. The first run after a start does it too, a quarter of an hour in.
const RESEND_MS = 6 * 60 * 60 * 1000;

// What Billbee's stock history says was counted, in front of the species name.
function _what(r) {
  if (r.type === 'PD') return 'Petrischalen';
  if (r.type === 'GS') return 'Beutel Körnerbrut ' + r.kg + ' kg';
  return 'Spritzen ' + r.ml + ' ml';
}

// getDatabase, not the handle: a backup restore reopens the database and the
// old handle is closed.
function createSyringeStockSync({ getDatabase, dbApi, channels, log, skip }) {
  let timer = null;
  let interval = null;
  let inFlight = false;
  let again = false;
  let lastResend = 0;

  /**
   * Report every species whose count moved. Never throws; the outcome is the
   * return value and the per-species rows (last_error), which the settings card
   * shows.
   *
   * `force` reports every mapped species regardless — the "Jetzt melden" button,
   * and the way to confirm a drop the guard below held back.
   *
   * `resend` also reports every mapped species and has Billbee pass them on to
   * the shops, but unlike `force` it does not override that guard: it runs on
   * its own, and a timer must never empty the shops on a reset.
   */
  async function runOnce({ force = false, resend = false } = {}) {
    if (skip) return { skipped: 'worktree' };
    const database = getDatabase();
    if (!dbApi.syringeStockSyncEnabled(database)) return { skipped: 'off' };
    const cfg = dbApi.getChannelConfig(database, 'billbee');
    if (!cfg.enabled || !cfg.apiKey || !cfg.clientId || !cfg.clientSecret) return { skipped: 'billbee' };

    const rows = dbApi.listSyringeStock(database).filter((r) => r.sku);
    const due = rows.filter((r) => force || resend || r.pushedCount !== r.count || r.lastError);
    if (!due.length) return { pushed: 0, errors: [] };

    // Every mapped species dropping to zero at once is not a run on syringes, it
    // is a reset of the lab data (resetOperationalData wipes cultures) or a bad
    // import. Sending that would empty the shops while the fridge is full. Held
    // back and said so; "Jetzt melden" sends it if it is true.
    const before = rows.reduce((s, r) => s + (r.pushedCount || 0), 0);
    if (!force && before > 0 && rows.every((r) => r.count === 0)) {
      const msg =
        'Nicht gemeldet: alle Spritzen sind auf einmal weg. Das sieht nach Zurücksetzen oder Import aus. Stimmt es, „Jetzt an Billbee melden" drücken.';
      for (const r of due) dbApi.recordSyringePush(database, r.strainId, r.slot, { error: msg });
      log('warn', 'Syringe stock: all species dropped to zero at once, held back', { before });
      return { pushed: 0, errors: [msg], held: true };
    }

    const prov = channels.getChannelProvider('billbee');
    let pushed = 0;
    let inARow = 0;
    const errors = [];
    for (const r of due) {
      try {
        const res = await prov.setStock(cfg, {
          sku: r.sku,
          count: r.count,
          reason: 'Meistertracker: ' + r.count + ' ' + _what(r) + ' ' + r.name + ' im Labor',
          // A change Billbee forwards on its own; the button and the periodic
          // resend make it pass unchanged figures on as well.
          resend: force || resend
        });
        dbApi.recordSyringePush(database, r.strainId, r.slot, { count: r.count, billbeeStock: res.currentStock });
        pushed++;
        inARow = 0;
      } catch (e) {
        const msg = e.message || 'Billbee hat nicht geantwortet';
        dbApi.recordSyringePush(database, r.strainId, r.slot, { error: msg });
        errors.push(msg);
        log('warn', 'Syringe stock push failed', { sku: r.sku, error: msg });
        // Three in a row is a key or a Billbee that is down, not three bad SKUs.
        // The rest stay due and go with the next run.
        if (++inARow >= 3) break;
      }
    }
    if (pushed) log('info', 'Syringe stock reported to Billbee', { pushed });
    return { pushed, errors };
  }

  /** One run at a time; a request during a run folds into one more run after it. */
  async function run(opts) {
    if (inFlight) {
      again = true;
      return { busy: true };
    }
    inFlight = true;
    try {
      let r = await runOnce(opts);
      while (again) {
        again = false;
        r = await runOnce();
      }
      return r;
    } catch (e) {
      log('warn', 'Syringe stock sync errored', { error: e.message });
      return { error: e.message };
    } finally {
      inFlight = false;
    }
  }

  /** Something changed somewhere; recount shortly. Cheap to call on every write. */
  function schedule() {
    if (skip) return;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      run();
    }, DEBOUNCE_MS);
    if (timer.unref) timer.unref();
  }

  /** The retry loop: whatever failed, or changed by a path that did not ask. */
  function start() {
    if (skip) return false;
    if (interval) clearInterval(interval);
    interval = setInterval(() => {
      const due = Date.now() - lastResend >= RESEND_MS;
      run(due ? { resend: true } : undefined).then((r) => {
        // Counted as done only when it went through, so a Billbee that was down
        // gets the resend on the next tick instead of six hours later.
        if (due && r && !r.skipped && !r.busy && !r.error && !r.held && !(r.errors && r.errors.length)) {
          lastResend = Date.now();
        }
      });
    }, RETRY_MS);
    if (interval.unref) interval.unref();
    return true;
  }

  function stop() {
    if (timer) clearTimeout(timer);
    if (interval) clearInterval(interval);
    timer = null;
    interval = null;
  }

  return { run, runOnce, schedule, start, stop };
}

module.exports = { createSyringeStockSync, DEBOUNCE_MS, RETRY_MS, RESEND_MS };