From 2967ad3a758fee21e5340ea6b0a1baba5f19c4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Salgado?= Date: Thu, 20 Apr 2023 17:58:50 +0100 Subject: [PATCH] fix: multiple fixes related with recyclers/stackers feat: add bill destination unit for cash-in txs feat: l-m communication regarding cash unit state --- lib/cash-in/cash-in-atomic.js | 2 +- lib/constants.js | 26 +++- lib/graphql/resolvers.js | 52 ++++--- lib/graphql/types.js | 11 ++ lib/notifier/codes.js | 2 + lib/plugins.js | 137 ++++++++++++++---- lib/postgresql_interface.js | 15 ++ ...83-fiat-balance-notification-to-percent.js | 2 +- .../1681428616990-aveiro-recycler-settings.js | 4 +- new-lamassu-admin/src/pages/Cashout/Wizard.js | 57 ++++++-- new-lamassu-admin/src/pages/Cashout/helper.js | 68 ++++++++- .../pages/Maintenance/CashCassettesFooter.js | 53 ++++++- .../src/pages/Maintenance/Wizard/Wizard.js | 77 ++++++++-- .../pages/Maintenance/Wizard/WizardStep.js | 70 +++++++-- .../src/pages/Maintenance/helper.js | 2 - .../sections/FiatBalanceAlerts.js | 77 ++++++++++ new-lamassu-admin/src/utils/machine.js | 20 ++- 17 files changed, 573 insertions(+), 102 deletions(-) diff --git a/lib/cash-in/cash-in-atomic.js b/lib/cash-in/cash-in-atomic.js index e3441a76..56a810ad 100644 --- a/lib/cash-in/cash-in-atomic.js +++ b/lib/cash-in/cash-in-atomic.js @@ -41,7 +41,7 @@ function insertNewBills (t, billRows, machineTx) { if (_.isEmpty(bills)) return Promise.resolve([]) const dbBills = _.map(cashInLow.massage, bills) - const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time'] + const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time', 'destination_unit'] const sql = pgp.helpers.insert(dbBills, columns, 'bills') const deviceID = machineTx.deviceId const sql2 = `update devices set cashbox = cashbox + $2 diff --git a/lib/constants.js b/lib/constants.js index 280c300a..2853ff17 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -13,7 +13,29 @@ const anonymousCustomer = { name: 'anonymous' } -const CASSETTE_MAX_CAPACITY = 500 +const CASH_UNIT_CAPACITY = { + grandola: { + cashbox: 2000, + recycler: 2800 + }, + aveiro: { + cashbox: 1500, + stacker: 60, + cassette: 500 + }, + tejo: { + // TODO: add support for the different cashbox configuration in Tejo + cashbox: 1000, + cassette: 500 + }, + gaia: { + cashbox: 600 + }, + sintra: { + cashbox: 1000, + cassette: 500 + } +} const CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES = 2 const CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES = 4 @@ -39,7 +61,7 @@ const BALANCE_FETCH_SPEED_MULTIPLIER = { module.exports = { anonymousCustomer, - CASSETTE_MAX_CAPACITY, + CASH_UNIT_CAPACITY, AUTHENTICATOR_ISSUER_ENTITY, AUTH_TOKEN_EXPIRATION_TIME, REGISTRATION_TOKEN_EXPIRATION_TIME, diff --git a/lib/graphql/resolvers.js b/lib/graphql/resolvers.js index 82d27164..d9e20e4b 100644 --- a/lib/graphql/resolvers.js +++ b/lib/graphql/resolvers.js @@ -167,13 +167,25 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => { )(cassettes) : null + const massageStackers = stackers => + stackers ? + _.flow( + stackers => _.set('physical', _.get('stackers', stackers), stackers), + stackers => _.set('virtual', _.get('virtualStackers', stackers), stackers), + _.unset('stackers'), + _.unset('virtualStackers') + )(stackers) : + null + state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids) return _.flow( - _.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'coins', 'rates']), + _.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'stackers', 'coins', 'rates']), _.update('cassettes', massageCassettes), + _.update('stackers', massageStackers), + /* [{ cryptoCode, rates }, ...] => [[cryptoCode, rates], ...] */ _.update('coins', _.map(({ cryptoCode, rates }) => [cryptoCode, rates])), @@ -185,9 +197,10 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => { /* Group the separate objects by cryptoCode */ /* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */ - ({ areThereAvailablePromoCodes, balances, cassettes, coins, rates }) => ({ + ({ areThereAvailablePromoCodes, balances, cassettes, stackers, coins, rates }) => ({ areThereAvailablePromoCodes, cassettes, + stackers, coins: _.flow( _.reduce( (ret, [cryptoCode, obj]) => _.update(cryptoCode, _.assign(obj), ret), @@ -216,22 +229,25 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => { const configs = (parent, { currentConfigVersion }, { deviceId, deviceName, operatorId, pid, settings }, info) => plugins(settings, deviceId) .pollQueries() - .then(pq => ({ - static: staticConfig({ - currentConfigVersion, - deviceId, - deviceName, - pq, - settings, - }), - dynamic: dynamicConfig({ - deviceId, - operatorId, - pid, - pq, - settings, - }), - })) + .then(pq => { + console.log(pq) + return { + static: staticConfig({ + currentConfigVersion, + deviceId, + deviceName, + pq, + settings, + }), + dynamic: dynamicConfig({ + deviceId, + operatorId, + pid, + pq, + settings, + }), + } + }) const massageTerms = terms => (terms.active && terms.text) ? ({ diff --git a/lib/graphql/types.js b/lib/graphql/types.js index d09d0755..26bac048 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -169,14 +169,25 @@ type PhysicalCassette { count: Int! } +type PhysicalStacker { + denomination: Int! + count: Int! +} + type Cassettes { physical: [PhysicalCassette!]! virtual: [Int!]! } +type Stackers { + physical: [PhysicalStacker!]! + virtual: [Int!]! +} + type DynamicConfig { areThereAvailablePromoCodes: Boolean! cassettes: Cassettes + stackers: Stackers coins: [DynamicCoinValues!]! reboot: Boolean! shutdown: Boolean! diff --git a/lib/notifier/codes.js b/lib/notifier/codes.js index 105437e8..cb7da476 100644 --- a/lib/notifier/codes.js +++ b/lib/notifier/codes.js @@ -15,6 +15,8 @@ const CODES_DISPLAY = { HIGH_CRYPTO_BALANCE: 'High Crypto Balance', CASH_BOX_FULL: 'Cash box full', LOW_CASH_OUT: 'Low Cash-out', + LOW_RECYCLER_STACKER: 'Low Recycler Stacker', + HIGH_RECYCLER_STACKER: 'High Recycler Stacker', CASHBOX_REMOVED: 'Cashbox removed' } diff --git a/lib/plugins.js b/lib/plugins.js index adeaaca8..384a5df3 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -27,7 +27,7 @@ const loyalty = require('./loyalty') const transactionBatching = require('./tx-batching') const state = require('./middlewares/state') -const { CASSETTE_MAX_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants') +const { CASH_UNIT_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants') const notifier = require('./notifier') @@ -147,27 +147,63 @@ function plugins (settings, deviceId) { return computedCassettes } + function computeAvailableStackers (stackers, redeemableTxs) { + if (_.isEmpty(redeemableTxs)) return stackers + + const sumTxs = (sum, tx) => { + // cash-out-helper sends 0 as fallback value, need to filter it out as there are no '0' denominations + const bills = _.filter(it => it.denomination > 0, tx.bills) + const sameDenominations = a => a[0]?.denomination === a[1]?.denomination + + const doDenominationsMatch = _.every(sameDenominations, _.zip(stackers, bills)) + + if (!doDenominationsMatch) { + throw new Error('Denominations don\'t add up, stackers were changed.') + } + + return _.map(r => r[0] + r[1].provisioned, _.zip(sum, tx.bills)) + } + + const provisioned = _.reduce(sumTxs, _.times(_.constant(0), _.size(stackers)), redeemableTxs) + const zipped = _.zip(_.map('count', stackers), provisioned) + const counts = _.map(r => r[0] - r[1], zipped) + + if (_.some(_.lt(_, 0), counts)) { + throw new Error('Negative note count: %j', counts) + } + + const computedStackers = [] + _.forEach(it => { + computedStackers.push({ + denomination: stackers[it].denomination, + count: counts[it] + }) + }, _.times(_.identity(), _.size(stackers))) + + return computedStackers + } + function buildAvailableCassettes (excludeTxId) { const cashOutConfig = configManager.getCashOut(deviceId, settings.config) if (!cashOutConfig.active) return Promise.resolve() return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)]) - .then(([rec, _redeemableTxs]) => { + .then(([_cassettes, _redeemableTxs]) => { const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs) const denominations = [] _.forEach(it => { denominations.push(cashOutConfig[`cassette${it + 1}`]) - }, _.times(_.identity(), rec.numberOfCassettes)) + }, _.times(_.identity(), _cassettes.numberOfCassettes)) const virtualCassettes = [Math.max(...denominations) * 2] const counts = argv.cassettes ? argv.cassettes.split(',') - : rec.counts + : _cassettes.counts - if (rec.counts.length !== denominations.length) { + if (_cassettes.counts.length !== denominations.length) { throw new Error('Denominations and respective counts do not match!') } @@ -177,7 +213,7 @@ function plugins (settings, deviceId) { denomination: parseInt(denominations[it], 10), count: parseInt(counts[it], 10) }) - }, _.times(_.identity(), rec.numberOfCassettes)) + }, _.times(_.identity(), _cassettes.numberOfCassettes)) try { return { @@ -194,6 +230,51 @@ function plugins (settings, deviceId) { }) } + function buildAvailableStackers (excludeTxId) { + const cashOutConfig = configManager.getCashOut(deviceId, settings.config) + + if (!cashOutConfig.active) return Promise.resolve() + + return Promise.all([dbm.stackerCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)]) + .then(([_stackers, _redeemableTxs]) => { + const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs) + + const denominations = [] + _.forEach(it => { + denominations.push(cashOutConfig[`stacker${it + 1}f`], cashOutConfig[`stacker${it + 1}r`]) + }, _.times(_.identity(), _stackers.numberOfStackers)) + + const virtualStackers = [Math.max(...denominations) * 2] + + const counts = _stackers.counts + + if (counts.length !== denominations.length) { + throw new Error('Denominations and respective counts do not match!') + } + + const stackers = [] + _.forEach(it => { + stackers.push({ + denomination: parseInt(denominations[it], 10), + count: parseInt(counts[it], 10) + }) + }, _.times(_.identity(), _stackers.numberOfStackers * 2)) + + try { + return { + stackers: computeAvailableStackers(stackers, redeemableTxs), + virtualStackers + } + } catch (err) { + logger.error(err) + return { + stackers, + virtualStackers + } + } + }) + } + function fetchCurrentConfigVersion () { const sql = `select id from user_config where type=$1 @@ -240,6 +321,7 @@ function plugins (settings, deviceId) { return Promise.all([ buildAvailableCassettes(), + buildAvailableStackers(), fetchCurrentConfigVersion(), millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone)), loyalty.getNumberOfAvailablePromoCodes(), @@ -250,6 +332,7 @@ function plugins (settings, deviceId) { ]) .then(([ cassettes, + stackers, configVersion, timezone, numberOfAvailablePromoCodes, @@ -273,6 +356,7 @@ function plugins (settings, deviceId) { return { cassettes, + stackers, rates: buildRates(tickers), balances: buildBalances(balances), coins, @@ -652,7 +736,10 @@ function plugins (settings, deviceId) { const denomination3f = cashOutConfig.stacker3f const denomination3r = cashOutConfig.stacker3r const cashOutEnabled = cashOutConfig.active - const isCassetteLow = (have, max, limit) => cashOutEnabled && ((have / max) * 100) < limit + const isUnitLow = (have, max, limit) => cashOutEnabled && ((have / max) * 100) < limit + // const isUnitHigh = (have, max, limit) => cashOutEnabled && ((have / max) * 100) > limit + + // const isUnitOutOfBounds = (have, max, lowerBound, upperBound) => isUnitLow(have, max, lowerBound) || isUnitHigh(have, max, upperBound) const notifications = configManager.getNotifications(null, device.deviceId, settings.config) @@ -667,7 +754,7 @@ function plugins (settings, deviceId) { } : null - const cassette1Alert = device.numberOfCassettes >= 1 && isCassetteLow(device.cashUnits.cassette1, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageCassette1) + const cassette1Alert = device.numberOfCassettes >= 1 && isUnitLow(device.cashUnits.cassette1, CASH_UNIT_CAPACITY[device.model]['cassette'], notifications.fillingPercentageCassette1) ? { code: 'LOW_CASH_OUT', cassette: 1, @@ -679,7 +766,7 @@ function plugins (settings, deviceId) { } : null - const cassette2Alert = device.numberOfCassettes >= 2 && isCassetteLow(device.cashUnits.cassette2, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageCassette2) + const cassette2Alert = device.numberOfCassettes >= 2 && isUnitLow(device.cashUnits.cassette2, CASH_UNIT_CAPACITY[device.model]['cassette'], notifications.fillingPercentageCassette2) ? { code: 'LOW_CASH_OUT', cassette: 2, @@ -691,7 +778,7 @@ function plugins (settings, deviceId) { } : null - const cassette3Alert = device.numberOfCassettes >= 3 && isCassetteLow(device.cashUnits.cassette3, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageCassette3) + const cassette3Alert = device.numberOfCassettes >= 3 && isUnitLow(device.cashUnits.cassette3, CASH_UNIT_CAPACITY[device.model]['cassette'], notifications.fillingPercentageCassette3) ? { code: 'LOW_CASH_OUT', cassette: 3, @@ -703,7 +790,7 @@ function plugins (settings, deviceId) { } : null - const cassette4Alert = device.numberOfCassettes >= 4 && isCassetteLow(device.cashUnits.cassette4, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageCassette4) + const cassette4Alert = device.numberOfCassettes >= 4 && isUnitLow(device.cashUnits.cassette4, CASH_UNIT_CAPACITY[device.model]['cassette'], notifications.fillingPercentageCassette4) ? { code: 'LOW_CASH_OUT', cassette: 4, @@ -715,9 +802,9 @@ function plugins (settings, deviceId) { } : null - const stacker1fAlert = device.numberOfStackers >= 1 && isCassetteLow(device.cashUnits.stacker1f, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker1f) + const stacker1fAlert = device.numberOfStackers >= 1 && isUnitLow(device.cashUnits.stacker1f, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker1f) ? { - code: 'LOW_CASH_OUT', + code: 'LOW_RECYCLER_STACKER', cassette: 4, machineName, deviceId: device.deviceId, @@ -727,9 +814,9 @@ function plugins (settings, deviceId) { } : null - const stacker1rAlert = device.numberOfStackers >= 1 && isCassetteLow(device.cashUnits.stacker1r, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker1r) + const stacker1rAlert = device.numberOfStackers >= 1 && isUnitLow(device.cashUnits.stacker1r, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker1r) ? { - code: 'LOW_CASH_OUT', + code: 'LOW_RECYCLER_STACKER', cassette: 4, machineName, deviceId: device.deviceId, @@ -739,21 +826,21 @@ function plugins (settings, deviceId) { } : null - const stacker2fAlert = device.numberOfStackers >= 2 && isCassetteLow(device.cashUnits.stacker2f, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker2f) + const stacker2fAlert = device.numberOfStackers >= 2 && isUnitLow(device.cashUnits.stacker2f, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker2f) ? { - code: 'LOW_CASH_OUT', + code: 'LOW_RECYCLER_STACKER', cassette: 4, machineName, deviceId: device.deviceId, - notes: device.cashUnits.stacker1f, - denomination: denomination1f, + notes: device.cashUnits.stacker2f, + denomination: denomination2f, fiatCode } : null - const stacker2rAlert = device.numberOfStackers >= 2 && isCassetteLow(device.cashUnits.stacker2r, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker2r) + const stacker2rAlert = device.numberOfStackers >= 2 && isUnitLow(device.cashUnits.stacker2r, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker2r) ? { - code: 'LOW_CASH_OUT', + code: 'LOW_RECYCLER_STACKER', cassette: 4, machineName, deviceId: device.deviceId, @@ -763,9 +850,9 @@ function plugins (settings, deviceId) { } : null - const stacker3fAlert = device.numberOfStackers >= 3 && isCassetteLow(device.cashUnits.stacker3f, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker3f) + const stacker3fAlert = device.numberOfStackers >= 3 && isUnitLow(device.cashUnits.stacker3f, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker3f) ? { - code: 'LOW_CASH_OUT', + code: 'LOW_RECYCLER_STACKER', cassette: 4, machineName, deviceId: device.deviceId, @@ -775,9 +862,9 @@ function plugins (settings, deviceId) { } : null - const stacker3rAlert = device.numberOfStackers >= 3 && isCassetteLow(device.cashUnits.stacker3r, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker3r) + const stacker3rAlert = device.numberOfStackers >= 3 && isUnitLow(device.cashUnits.stacker3r, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker3r) ? { - code: 'LOW_CASH_OUT', + code: 'LOW_RECYCLER_STACKER', cassette: 4, machineName, deviceId: device.deviceId, diff --git a/lib/postgresql_interface.js b/lib/postgresql_interface.js index 1ae15ae1..17369a1d 100644 --- a/lib/postgresql_interface.js +++ b/lib/postgresql_interface.js @@ -40,6 +40,21 @@ exports.cassetteCounts = function cassetteCounts (deviceId) { }) } +exports.stackerCounts = function stackerCounts (deviceId) { + const sql = 'SELECT stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r, number_of_stackers FROM devices ' + + 'WHERE device_id=$1' + + return db.one(sql, [deviceId]) + .then(row => { + const counts = [] + _.forEach(it => { + counts.push(row[`stacker${it + 1}f`], row[`stacker${it + 1}r`]) + }, _.times(_.identity(), row.number_of_stackers)) + + return { numberOfStackers: row.number_of_stackers, counts } + }) +} + // Note: since we only prune on insert, we'll always have // last known state. exports.machineEvent = function machineEvent (rec) { diff --git a/migrations/1619968992683-fiat-balance-notification-to-percent.js b/migrations/1619968992683-fiat-balance-notification-to-percent.js index 713761d7..a3d6b500 100644 --- a/migrations/1619968992683-fiat-balance-notification-to-percent.js +++ b/migrations/1619968992683-fiat-balance-notification-to-percent.js @@ -1,6 +1,6 @@ const _ = require('lodash/fp') const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader') -const { CASSETTE_MAX_CAPACITY } = require('../lib/constants') +const CASSETTE_MAX_CAPACITY = 500 exports.up = function (next) { return loadLatest() diff --git a/migrations/1681428616990-aveiro-recycler-settings.js b/migrations/1681428616990-aveiro-recycler-settings.js index cb000e80..86ea6dd5 100644 --- a/migrations/1681428616990-aveiro-recycler-settings.js +++ b/migrations/1681428616990-aveiro-recycler-settings.js @@ -47,7 +47,9 @@ exports.up = function (next) { ADD COLUMN denomination_2f INTEGER, ADD COLUMN denomination_2r INTEGER, ADD COLUMN denomination_3f INTEGER, - ADD COLUMN denomination_3r INTEGER` + ADD COLUMN denomination_3r INTEGER`, + `CREATE TYPE bill_destination_unit AS ENUM ('cashbox', 'stacker1f', 'stacker1r', 'stacker2f', 'stacker2r', 'stacker3f', 'stacker3r')`, + `ALTER TABLE bills ADD COLUMN destination_unit bill_destination_unit NOT NULL DEFAULT 'cashbox'` ] db.multi(sql, next) diff --git a/new-lamassu-admin/src/pages/Cashout/Wizard.js b/new-lamassu-admin/src/pages/Cashout/Wizard.js index 5243f0b8..bb646758 100644 --- a/new-lamassu-admin/src/pages/Cashout/Wizard.js +++ b/new-lamassu-admin/src/pages/Cashout/Wizard.js @@ -17,7 +17,8 @@ const MODAL_WIDTH = 554 const MODAL_HEIGHT = 520 const Wizard = ({ machine, locale, onClose, save, error }) => { - const LAST_STEP = machine.numberOfCassettes + machine.numberOfStackers + 1 + // Each stacker counts as two steps, one for front and another for rear + const LAST_STEP = machine.numberOfCassettes + machine.numberOfStackers * 2 + 1 const [{ step, config }, setState] = useState({ step: 0, config: { active: true } @@ -83,30 +84,68 @@ const Wizard = ({ machine, locale, onClose, save, error }) => { } } ], - R.range( - machine.numberOfCassettes + 1, - machine.numberOfCassettes + machine.numberOfStackers + 1 - ) + R.range(1, machine.numberOfStackers + 1) ) ) const schema = () => Yup.object().shape({ - cassette1: Yup.number().required(), + cassette1: + machine.numberOfCassettes >= 1 && step >= 1 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), cassette2: - machine.numberOfCassettes > 1 && step >= 2 + machine.numberOfCassettes >= 2 && step >= 2 ? Yup.number().required() : Yup.number() .transform(transformNumber) .nullable(), cassette3: - machine.numberOfCassettes > 2 && step >= 3 + machine.numberOfCassettes >= 3 && step >= 3 ? Yup.number().required() : Yup.number() .transform(transformNumber) .nullable(), cassette4: - machine.numberOfCassettes > 3 && step >= 4 + machine.numberOfCassettes >= 4 && step >= 4 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + stacker1f: + machine.numberOfStackers >= 1 && step >= machine.numberOfCassettes + 1 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + stacker1r: + machine.numberOfStackers >= 1 && step >= machine.numberOfCassettes + 2 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + stacker2f: + machine.numberOfStackers >= 2 && step >= machine.numberOfCassettes + 3 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + stacker2r: + machine.numberOfStackers >= 2 && step >= machine.numberOfCassettes + 4 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + stacker3f: + machine.numberOfStackers >= 3 && step >= machine.numberOfCassettes + 5 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + stacker3r: + machine.numberOfStackers >= 3 && step >= machine.numberOfCassettes + 6 ? Yup.number().required() : Yup.number() .transform(transformNumber) diff --git a/new-lamassu-admin/src/pages/Cashout/helper.js b/new-lamassu-admin/src/pages/Cashout/helper.js index c80216d1..0539f165 100644 --- a/new-lamassu-admin/src/pages/Cashout/helper.js +++ b/new-lamassu-admin/src/pages/Cashout/helper.js @@ -9,9 +9,13 @@ import { CURRENCY_MAX } from 'src/utils/constants' import { transformNumber } from 'src/utils/number' const widthsByNumberOfCassettes = { - 2: { machine: 300, cassette: 225, zeroConf: 200 }, - 3: { machine: 210, cassette: 180, zeroConf: 200 }, - 4: { machine: 200, cassette: 150, zeroConf: 150 } + 2: { machine: 320, cassette: 315 }, + 3: { machine: 305, cassette: 215 }, + 4: { machine: 195, cassette: 190 }, + 5: { machine: 175, cassette: 155 }, + 6: { machine: 170, cassette: 130 }, + 7: { machine: 140, cassette: 125 }, + 8: { machine: 120, cassette: 125 } } const DenominationsSchema = Yup.object().shape({ @@ -45,6 +49,14 @@ const getElements = (machines, locale = {}, classes) => { ...R.map(it => it.numberOfCassettes, machines), 0 ) + const maxNumberOfStackers = Math.max( + ...R.map(it => it.numberOfStackers, machines), + 0 + ) + const maxNumberOfCashUnits = Math.max( + ...R.map(it => it.numberOfCassettes + it.numberOfStackers * 2, machines), + 0 + ) const options = getBillOptions(locale, denominations) const cassetteProps = @@ -61,7 +73,7 @@ const getElements = (machines, locale = {}, classes) => { { name: 'id', header: 'Machine', - width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine, + width: widthsByNumberOfCassettes[maxNumberOfCashUnits]?.machine, view: it => machines.find(({ deviceId }) => deviceId === it).name, size: 'sm', editable: false @@ -77,7 +89,7 @@ const getElements = (machines, locale = {}, classes) => { size: 'sm', stripe: true, textAlign: 'right', - width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette, + width: widthsByNumberOfCassettes[maxNumberOfCashUnits]?.cassette, suffix: fiatCurrency, bold: bold, view: it => it, @@ -94,6 +106,52 @@ const getElements = (machines, locale = {}, classes) => { 1 ) + R.until( + R.gt(R.__, maxNumberOfStackers), + it => { + elements.push( + { + name: `stacker${it}f`, + header: `Stacker ${it}F`, + size: 'sm', + stripe: true, + textAlign: 'right', + width: widthsByNumberOfCassettes[maxNumberOfCashUnits]?.cassette, + suffix: fiatCurrency, + bold: bold, + view: it => it, + input: options?.length > 0 ? Autocomplete : NumberInput, + inputProps: cassetteProps, + doubleHeader: 'Denominations', + isHidden: machine => + it > + machines.find(({ deviceId }) => deviceId === machine.id) + .numberOfStackers + }, + { + name: `stacker${it}r`, + header: `Stacker ${it}R`, + size: 'sm', + stripe: true, + textAlign: 'right', + width: widthsByNumberOfCassettes[maxNumberOfCashUnits]?.cassette, + suffix: fiatCurrency, + bold: bold, + view: it => it, + input: options?.length > 0 ? Autocomplete : NumberInput, + inputProps: cassetteProps, + doubleHeader: 'Denominations', + isHidden: machine => + it > + machines.find(({ deviceId }) => deviceId === machine.id) + .numberOfStackers + } + ) + return R.add(1, it) + }, + 1 + ) + return elements } diff --git a/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js b/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js index 4f619c2a..cc417cc4 100644 --- a/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js +++ b/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js @@ -22,9 +22,9 @@ const CashCassettesFooter = ({ const classes = useStyles() const cashout = config && fromNamespace('cashOut')(config) const getCashoutSettings = id => fromNamespace(id)(cashout) - const reducerFn = ( + const cashoutReducerFn = ( acc, - { cassette1, cassette2, cassette3, cassette4, id } + { cashUnits: { cassette1, cassette2, cassette3, cassette4 }, id } ) => { const cassette1Denomination = getCashoutSettings(id).cassette1 ?? 0 const cassette2Denomination = getCashoutSettings(id).cassette2 ?? 0 @@ -38,11 +38,49 @@ const CashCassettesFooter = ({ ] } - const totalInCassettes = R.sum(R.reduce(reducerFn, [0, 0, 0, 0], machines)) + const recyclerReducerFn = ( + acc, + { + cashUnits: { + stacker1f, + stacker1r, + stacker2f, + stacker2r, + stacker3f, + stacker3r + }, + id + } + ) => { + const stacker1fDenomination = getCashoutSettings(id).stacker1f ?? 0 + const stacker1rDenomination = getCashoutSettings(id).stacker1r ?? 0 + const stacker2fDenomination = getCashoutSettings(id).stacker2f ?? 0 + const stacker2rDenomination = getCashoutSettings(id).stacker2r ?? 0 + const stacker3fDenomination = getCashoutSettings(id).stacker3f ?? 0 + const stacker3rDenomination = getCashoutSettings(id).stacker3r ?? 0 + return [ + (acc[0] += stacker1f * stacker1fDenomination), + (acc[1] += stacker1r * stacker1rDenomination), + (acc[0] += stacker2f * stacker2fDenomination), + (acc[1] += stacker2r * stacker2rDenomination), + (acc[0] += stacker3f * stacker3fDenomination), + (acc[1] += stacker3r * stacker3rDenomination) + ] + } + + const totalInRecyclers = R.sum( + R.reduce(recyclerReducerFn, [0, 0, 0, 0, 0, 0], machines) + ) + + const totalInCassettes = R.sum( + R.reduce(cashoutReducerFn, [0, 0, 0, 0], machines) + ) const totalInCashBox = R.sum(R.map(it => it.fiat)(bills)) - const total = new BigNumber(totalInCassettes + totalInCashBox).toFormat(0) + const total = new BigNumber( + totalInCassettes + totalInCashBox + totalInRecyclers + ).toFormat(0) return (
@@ -62,6 +100,13 @@ const CashCassettesFooter = ({ {numberToFiatAmount(totalInCassettes)} {currencyCode}
+
+ + Recycle: + + {numberToFiatAmount(totalInRecyclers)} {currencyCode} + +
Total: diff --git a/new-lamassu-admin/src/pages/Maintenance/Wizard/Wizard.js b/new-lamassu-admin/src/pages/Maintenance/Wizard/Wizard.js index 21f6d86b..f1ae5143 100644 --- a/new-lamassu-admin/src/pages/Maintenance/Wizard/Wizard.js +++ b/new-lamassu-admin/src/pages/Maintenance/Wizard/Wizard.js @@ -4,6 +4,7 @@ import * as Yup from 'yup' import Modal from 'src/components/Modal' import { MAX_NUMBER_OF_CASSETTES } from 'src/utils/constants' +import { cashUnitCapacity, modelPrettifier } from 'src/utils/machine' import { defaultToZero } from 'src/utils/number' import WizardSplash from './WizardSplash' @@ -11,7 +12,6 @@ import WizardStep from './WizardStep' const MODAL_WIDTH = 554 const MODAL_HEIGHT = 535 -const CASHBOX_DEFAULT_CAPACITY = 500 const CASSETTE_FIELDS = R.map( it => `cassette${it}`, @@ -37,8 +37,9 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => { R.isEmpty(cashoutSettings) || !cashoutSettings?.active const numberOfCassettes = isCashOutDisabled ? 0 : machine.numberOfCassettes + const numberOfStackers = machine.numberOfStackers - const LAST_STEP = numberOfCassettes + 1 + const LAST_STEP = numberOfCassettes + numberOfStackers * 2 + 1 const title = `Update counts` const isLastStep = step === LAST_STEP @@ -104,11 +105,57 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => { .integer() .required() .min(0) - .max(CASHBOX_DEFAULT_CAPACITY) + .max( + cashUnitCapacity[machine.model].cassette, + `${modelPrettifier[machine.model]} maximum cassette capacity is ${ + cashUnitCapacity[machine.model].cassette + } bills` + ) }) })) ) + const makeStackerSteps = R.pipe( + R.add(1), + R.range(1), + R.chain(i => [ + { + type: `stacker ${i}f`, + schema: Yup.object().shape({ + [`stacker${i}f`]: Yup.number() + .label('Bill count') + .positive() + .integer() + .required() + .min(0) + .max( + cashUnitCapacity[machine.model].stacker, + `${modelPrettifier[machine.model]} maximum stacker capacity is ${ + cashUnitCapacity[machine.model].stacker + } bills` + ) + }) + }, + { + type: `stacker ${i}r`, + schema: Yup.object().shape({ + [`stacker${i}r`]: Yup.number() + .label('Bill count') + .positive() + .integer() + .required() + .min(0) + .max( + cashUnitCapacity[machine.model].stacker, + `${modelPrettifier[machine.model]} maximum stacker capacity is ${ + cashUnitCapacity[machine.model].stacker + } bills` + ) + }) + } + ]) + ) + const makeInitialValues = () => !R.isEmpty(cashoutSettings) ? R.reduce( @@ -121,16 +168,19 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => { ) : {} - const steps = R.prepend( - { - type: 'cashbox', - schema: Yup.object().shape({ - wasCashboxEmptied: Yup.string().required('Select one option.') - }), - cashoutRequired: false - }, - makeCassetteSteps(numberOfCassettes) - ) + const steps = R.pipe( + R.concat(makeStackerSteps(numberOfStackers)), + R.concat(makeCassetteSteps(numberOfCassettes)), + R.concat([ + { + type: 'cashbox', + schema: Yup.object().shape({ + wasCashboxEmptied: Yup.string().required('Select one option.') + }), + cashoutRequired: false + } + ]) + )([]) return ( { name={machine?.name} machine={machine} cashoutSettings={cashoutSettings} - cassetteCapacity={CASHBOX_DEFAULT_CAPACITY} error={error} lastStep={isLastStep} steps={steps} diff --git a/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js b/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js index 22e3fb75..378c4041 100644 --- a/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js +++ b/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js @@ -23,7 +23,9 @@ import tejo4CassetteThree from 'src/styling/icons/cassettes/tejo/4-cassettes/4-c import tejo4CassetteFour from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-4-left.svg' import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg' import { comet, errorColor } from 'src/styling/variables' +import { cashUnitCapacity } from 'src/utils/machine' import { numberToFiatAmount } from 'src/utils/number' +import { startCase } from 'src/utils/string' const styles = { content: { @@ -104,19 +106,41 @@ const styles = { const useStyles = makeStyles(styles) -const cassetesArtworks = (numberOfCassettes, step) => - [ +const CASHBOX_STEP = 1 + +const cassetesArtworks = (step, numberOfCassettes, numberOfStackers) => { + const cassetteStepsStart = CASHBOX_STEP + 1 + return [ + [cassetteOne], [cassetteOne, cassetteTwo], [tejo3CassetteOne, tejo3CassetteTwo, tejo3CassetteThree], [tejo4CassetteOne, tejo4CassetteTwo, tejo4CassetteThree, tejo4CassetteFour] - ][numberOfCassettes - 2][step - 2] + ][numberOfCassettes - cassetteStepsStart + 1][step - cassetteStepsStart] +} + +const getCashUnitFieldName = (step, numberOfCassettes, numberOfStackers) => { + if (step === CASHBOX_STEP) return { name: 'cashbox', category: 'cashbox' } + const cassetteStepsStart = CASHBOX_STEP + 1 + if (step < cassetteStepsStart + numberOfCassettes) + return { + name: `cassette${step - cassetteStepsStart + 1}`, + category: 'cassette' + } + const stackerStepsStart = CASHBOX_STEP + numberOfCassettes + 1 + if (step < stackerStepsStart + numberOfStackers * 2) + return { + name: `stacker${Math.ceil((step - stackerStepsStart + 1) / 2)}${ + (step - stackerStepsStart) % 2 === 0 ? 'f' : 'r' + }`, + category: 'stacker' + } +} const WizardStep = ({ step, name, machine, cashoutSettings, - cassetteCapacity, error, lastStep, steps, @@ -133,16 +157,20 @@ const WizardStep = ({ { display: 'No', code: 'NO' } ] - const cassetteField = `cassette${step - 1}` const numberOfCassettes = machine.numberOfCassettes - const originalCassetteCount = machine?.[cassetteField] - const cassetteDenomination = cashoutSettings?.[cassetteField] + const numberOfStackers = machine.numberOfStackers + const { + name: cashUnitField, + category: cashUnitCategory + } = getCashUnitFieldName(step, numberOfCassettes, numberOfStackers) + const originalCashUnitCount = machine?.cashUnits?.[cashUnitField] + const cashUnitDenomination = cashoutSettings?.[cashUnitField] - const cassetteCount = values => values[cassetteField] || originalCassetteCount - const cassetteTotal = values => cassetteCount(values) * cassetteDenomination + const cassetteCount = values => values[cashUnitField] || originalCashUnitCount + const cassetteTotal = values => cassetteCount(values) * cashUnitDenomination const getPercentage = R.pipe( cassetteCount, - count => 100 * (count / cassetteCapacity), + count => 100 * (count / cashUnitCapacity[machine.model][cashUnitCategory]), R.clamp(0, 100) ) @@ -161,7 +189,7 @@ const WizardStep = ({ initialValues={{ wasCashboxEmptied: '' }} enableReinitialize validationSchema={steps[0].schema}> - {({ values, errors }) => ( + {({ errors }) => (
@@ -236,7 +264,11 @@ const WizardStep = ({ cassette + src={cassetesArtworks( + step, + numberOfCassettes, + numberOfStackers + )}>
- Cash cassette {step - 1} (dispenser) + {startCase(cashUnitField)} ( + {R.includes('cassette', cashUnitField) + ? `dispenser` + : R.includes('stacker', cashUnitField) + ? `recycler` + : ``} + )

- {cassetteDenomination} {fiatCurrency} bills loaded + {cashUnitDenomination} {fiatCurrency} bills loaded

diff --git a/new-lamassu-admin/src/pages/Maintenance/helper.js b/new-lamassu-admin/src/pages/Maintenance/helper.js index ec525e00..4ed73bc3 100644 --- a/new-lamassu-admin/src/pages/Maintenance/helper.js +++ b/new-lamassu-admin/src/pages/Maintenance/helper.js @@ -158,7 +158,6 @@ const getElements = ( header: `Stacker ${it}F`, width: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.cassette, stripe: true, - doubleHeader: 'Cash recycling', view: (_, { id, cashUnits }) => ( ( { DEFAULT_NUMBER_OF_CASSETTES ) + const maxNumberOfStackers = Math.max( + ...R.map(it => it.numberOfStackers, machines), + DEFAULT_NUMBER_OF_STACKERS + ) + const schema = Yup.object().shape({ cashInAlertThreshold: Yup.number() .transform(transformNumber) @@ -160,6 +167,76 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => { )}

+
+ +
setEditing(RECYCLER_STACKER_KEY, it)} + /> +
+ {R.chain( + it => [ + <> +
+ +
+ Stacker {it + 1}F + (x === '' ? '-' : x)} + decoration="%" + width={fieldWidth} + /> +
+
+ , + <> +
+ +
+ Stacker {it + 1}R + (x === '' ? '-' : x)} + decoration="%" + width={fieldWidth} + /> +
+
+ + ], + R.times(R.identity, maxNumberOfStackers) + )} +
+ )} diff --git a/new-lamassu-admin/src/utils/machine.js b/new-lamassu-admin/src/utils/machine.js index e2cf97f9..42a19490 100644 --- a/new-lamassu-admin/src/utils/machine.js +++ b/new-lamassu-admin/src/utils/machine.js @@ -8,14 +8,26 @@ const modelPrettifier = { } const cashUnitCapacity = { + grandola: { + cashbox: 2000, + recycler: 2800 + }, + aveiro: { + cashbox: 1500, + stacker: 60, + cassette: 500 + }, tejo: { + // TODO: add support for the different cashbox configuration in Tejo cashbox: 1000, cassette: 500 }, - aveiro: { - cashbox: 500, - cassette: 200, - stacker: 60 + gaia: { + cashbox: 600 + }, + sintra: { + cashbox: 1000, + cassette: 500 } }