diff --git a/lib/bill-math.js b/lib/bill-math.js index 9cc8d47e..d4801c0f 100644 --- a/lib/bill-math.js +++ b/lib/bill-math.js @@ -1,16 +1,78 @@ +const _ = require('lodash/fp') const uuid = require('uuid') -// Custom algorith for two cassettes. For three or more denominations, we'll need -// to rethink this. Greedy algorithm fails to find *any* solution in some cases. -// Dynamic programming may be too inefficient for large amounts. -// -// We can either require canononical denominations for 3+, or try to expand -// this algorithm. -exports.makeChange = function makeChange (cassettes, amount) { - // Note: Everything here is converted to primitive numbers, - // since they're all integers, well within JS number range, - // and this is way more efficient in a tight loop. +const MAX_AMOUNT_OF_SOLUTIONS = 10000 +const MAX_BRUTEFORCE_ITERATIONS = 10000000 +function newSolution(cassettes, c0, c1, c2, c3, shouldFlip) { + return [ + { + provisioned: shouldFlip ? cassettes[0].count - c0 : c0, + denomination: cassettes[0].denomination + }, + { + provisioned: shouldFlip ? cassettes[1].count - c1 : c1, + denomination: cassettes[1].denomination + }, + { + provisioned: shouldFlip ? cassettes[2].count - c2 : c2, + denomination: cassettes[2].denomination + }, + { + provisioned: shouldFlip ? cassettes[3].count - c3 : c3, + denomination: cassettes[3].denomination + } + ] +} + +function mergeCassettes(cassettes) { + const map = {} + + _.forEach(it => { + if (!map[it.denomination]) { + map[it.denomination] = 0 + } + map[it.denomination] += it.count + }, cassettes) + + return _.map(it => ({ denomination: it, count: map[it] }), _.keys(map)) +} + +function unmergeCassettes(cassettes, output) { + const map = {} + + _.forEach(it => { + if (!map[it.denomination]) { + map[it.denomination] = 0 + } + map[it.denomination] += it.provisioned + }, output) + + const response = [] + _.forEach(it => { + const value = { + denomination: it.denomination, + id: uuid.v4() + } + + const amountNeeded = map[it.denomination] + if (!amountNeeded) { + return response.push({ provisioned: 0, ...value }) + } + + if (amountNeeded < it.count) { + map[it.denomination] = 0 + return response.push({ provisioned: amountNeeded, ...value }) + } + + map[it.denomination] -= it.count + return response.push({ provisioned: it.count, ...value }) + }, cassettes) + + return response +} + +function makeChangeDuo(cassettes, amount) { const small = cassettes[0] const large = cassettes[1] @@ -23,15 +85,140 @@ exports.makeChange = function makeChange (cassettes, amount) { const remainder = amountNum - largeDenom * i if (remainder % smallDenom !== 0) continue - const smallCount = remainder / smallDenom if (smallCount > small.count) continue - return [ - {provisioned: smallCount, denomination: small.denomination, id: uuid.v4()}, - {provisioned: i, denomination: largeDenom, id: uuid.v4()} + { + provisioned: smallCount, + denomination: small.denomination, + id: uuid.v4() + }, + { provisioned: i, denomination: largeDenom, id: uuid.v4() } ] } - return null + return [] } + +function makeChange(outCassettes, amount) { + const available = _.reduce( + (res, val) => res + val.count * val.denomination, + 0, + outCassettes + ) + + if (available < amount) { + console.log(`Tried to dispense more than was available for amount ${amount.toNumber()} with cassettes ${JSON.stringify(cassettes)}`) + return null + } + + const cassettes = mergeCassettes(outCassettes) + const result = + _.size(cassettes) >= 3 + ? makeChangeDynamic(cassettes, amount, available) + : makeChangeDuo(cassettes, amount) + + if (!result.length) return null + return unmergeCassettes(outCassettes, result) +} + +function makeChangeDynamicBruteForce(outCassettes, amount, available) { + const solutions = [] + let x = 0 + + const shouldFlip = amount > _.max(_.map(it => it.denomination * it.count, outCassettes)) + const amountNum = shouldFlip ? available - amount : amount + + const cassettes = shouldFlip ? _.reverse(outCassettes) : outCassettes + const { denomination: denomination0, count: count0 } = cassettes[0] + const { denomination: denomination1, count: count1 } = cassettes[1] + const { denomination: denomination2, count: count2 } = cassettes[2] + const { denomination: denomination3, count: count3 } = cassettes[3] + + const startTime = new Date().getTime() + + loop1: for (let i = 0; i <= count0; i++) { + const firstSum = i * denomination0 + + for (let j = 0; j <= count1; j++) { + const secondSum = firstSum + j * denomination1 + if (secondSum > amountNum) break + + if (secondSum === amountNum) { + solutions.push(newSolution(cassettes, i, j, 0, 0, shouldFlip)) + } + + for (let k = 0; k <= count2; k++) { + const thirdSum = secondSum + k * denomination2 + if (thirdSum > amountNum) break + + if (denomination2 === 0) break + + if (thirdSum === amountNum) { + solutions.push(newSolution(cassettes, i, j, k, 0, shouldFlip)) + } + + for (let l = 0; l <= count3; l++) { + if ((x > MAX_AMOUNT_OF_SOLUTIONS && solutions.length >= 1) || x > MAX_BRUTEFORCE_ITERATIONS) break loop1 + x++ + const fourthSum = thirdSum + l * denomination3 + if (fourthSum > amountNum) break + + if (denomination3 === 0) break + + if (fourthSum === amountNum) { + solutions.push(newSolution(cassettes, i, j, k, l, shouldFlip)) + } + } + } + } + } + + const endTime = new Date().getTime() + + console.log(`Exiting bruteforce after ${x} tries. Took ${endTime - startTime} ms`) + return solutions +} + +function makeChangeDynamic(cassettes, amount, available) { + while (_.size(cassettes) < 4) { + cassettes.push({ denomination: 0, count: 0 }) + } + + const amountNum = amount.toNumber() + + const solutions = makeChangeDynamicBruteForce(cassettes, amountNum, available) + + const sortedSolutions = _.sortBy(it => { + const arr = [] + + for (let la = 0; la < 4; la++) { + arr.push(cassettes[la].count - it[la].provisioned) + } + + if (arr.length < 2) return Infinity + return _.max(arr) - _.min(arr) + }, solutions) + + const cleanSolution = _.filter( + it => it.denomination > 0, + _.head(sortedSolutions) + ) + + const response = cleanSolution + + // Final sanity check + let total = 0 + _.forEach(it => { + total += it.provisioned * it.denomination + }, response) + + if (total === amountNum) return response + + console.log( + `Failed to find a solution for ${amountNum} with cassettes ${JSON.stringify(cassettes)}` + ) + return [] +} + +module.exports = { makeChange } diff --git a/lib/cash-out/cash-out-actions.js b/lib/cash-out/cash-out-actions.js index 420e0ae9..bae631fb 100644 --- a/lib/cash-out/cash-out-actions.js +++ b/lib/cash-out/cash-out-actions.js @@ -37,14 +37,14 @@ function mapDispense (tx) { if (_.isEmpty(bills)) return {} - return { - provisioned_1: bills[0].provisioned, - provisioned_2: bills[1].provisioned, - dispensed_1: bills[0].dispensed, - dispensed_2: bills[1].dispensed, - rejected_1: bills[0].rejected, - rejected_2: bills[1].rejected, - denomination_1: bills[0].denomination, - denomination_2: bills[1].denomination - } + const res = {} + + _.forEach(it => { + res[`provisioned_${it + 1}`] = bills[it].provisioned + res[`denomination_${it + 1}`] = bills[it].denomination + res[`dispensed_${it + 1}`] = bills[it].dispensed + res[`rejected_${it + 1}`] = bills[it].rejected + }, _.times(_.identity(), _.size(bills))) + + return res } diff --git a/lib/cash-out/cash-out-atomic.js b/lib/cash-out/cash-out-atomic.js index 25bbc184..2fb772a7 100644 --- a/lib/cash-out/cash-out-atomic.js +++ b/lib/cash-out/cash-out-atomic.js @@ -111,16 +111,24 @@ function updateCassettes (t, tx) { if (!dispenseOccurred(tx.bills)) return Promise.resolve() const sql = `update devices set - cassette1 = cassette1 - $1, - cassette2 = cassette2 - $2 - where device_id = $3 - returning cassette1, cassette2` + ${_.size(tx.bills) > 0 ? `cassette1 = cassette1 - $1` : ``} + ${_.size(tx.bills) > 1 ? `, cassette2 = cassette2 - $2` : ``} + ${_.size(tx.bills) > 2 ? `, cassette3 = cassette3 - $3` : ``} + ${_.size(tx.bills) > 3 ? `, cassette4 = cassette4 - $4` : ``} + where device_id = $${_.size(tx.bills) + 1} + returning + ${_.size(tx.bills) > 0 ? `cassette1` : ``} + ${_.size(tx.bills) > 1 ? `, cassette2`: ``} + ${_.size(tx.bills) > 2 ? `, cassette3` : ``} + ${_.size(tx.bills) > 3 ? `, cassette4` : ``}` - const values = [ - tx.bills[0].dispensed + tx.bills[0].rejected, - tx.bills[1].dispensed + tx.bills[1].rejected, - tx.deviceId - ] + const values = [] + + _.forEach(it => values.push( + tx.bills[it].dispensed + tx.bills[it].rejected + ), _.times(_.identity(), _.size(tx.bills))) + + values.push(tx.deviceId) return t.one(sql, values) .then(r => socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId}))) diff --git a/lib/cash-out/cash-out-helper.js b/lib/cash-out/cash-out-helper.js index dc4597fd..aecb0eb9 100644 --- a/lib/cash-out/cash-out-helper.js +++ b/lib/cash-out/cash-out-helper.js @@ -39,12 +39,18 @@ function addDbBills (tx) { const bills = tx.bills if (_.isEmpty(bills)) return tx - return _.assign(tx, { - provisioned1: bills[0].provisioned, - provisioned2: bills[1].provisioned, - denomination1: bills[0].denomination, - denomination2: bills[1].denomination - }) + const billsObj = { + provisioned1: bills[0]?.provisioned ?? 0, + provisioned2: bills[1]?.provisioned ?? 0, + provisioned3: bills[2]?.provisioned ?? 0, + provisioned4: bills[3]?.provisioned ?? 0, + denomination1: bills[0]?.denomination ?? 0, + denomination2: bills[1]?.denomination ?? 0, + denomination3: bills[2]?.denomination ?? 0, + denomination4: bills[3]?.denomination ?? 0 + } + + return _.assign(tx, billsObj) } function toDb (tx) { @@ -76,12 +82,12 @@ function toObj (row) { newObj.direction = 'cashOut' - const billFields = ['denomination1', 'denomination2', 'provisioned1', 'provisioned2'] + const billFields = ['denomination1', 'denomination2', 'denomination3', 'denomination4', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4'] if (_.every(_.isNil, _.at(billFields, newObj))) return newObj if (_.some(_.isNil, _.at(billFields, newObj))) throw new Error('Missing cassette values') - const bills = [ + const billFieldsArr = [ { denomination: newObj.denomination1, provisioned: newObj.provisioned1 @@ -89,9 +95,21 @@ function toObj (row) { { denomination: newObj.denomination2, provisioned: newObj.provisioned2 + }, + { + denomination: newObj.denomination3, + provisioned: newObj.provisioned3 + }, + { + denomination: newObj.denomination4, + provisioned: newObj.provisioned4 } ] + // There can't be bills with denomination === 0. + // If a bill has denomination === 0, then that cassette is not set and should be filtered out. + const bills = _.filter(it => it.denomination > 0, billFieldsArr) + return _.set('bills', bills, _.omit(billFields, newObj)) } diff --git a/lib/cash-out/cash-out-tx.js b/lib/cash-out/cash-out-tx.js index c83c1dc7..e4016fa3 100644 --- a/lib/cash-out/cash-out-tx.js +++ b/lib/cash-out/cash-out-tx.js @@ -70,17 +70,12 @@ function postProcess (txVector, justAuthorized, pi) { return bills }) .then(bills => { - const provisioned1 = bills[0].provisioned - const provisioned2 = bills[1].provisioned - const denomination1 = bills[0].denomination - const denomination2 = bills[1].denomination + const rec = {} - const rec = { - provisioned_1: provisioned1, - provisioned_2: provisioned2, - denomination_1: denomination1, - denomination_2: denomination2 - } + _.forEach(it => { + rec[`provisioned_${it + 1}`] = bills[it].provisioned + rec[`denomination_${it + 1}`] = bills[it].denomination + }, _.times(_.identity(), _.size(bills))) return cashOutActions.logAction(db, 'provisionNotes', rec, newTx) .then(_.constant({bills})) diff --git a/lib/machine-loader.js b/lib/machine-loader.js index a51aa8ac..a7731533 100644 --- a/lib/machine-loader.js +++ b/lib/machine-loader.js @@ -23,6 +23,9 @@ function getMachines () { cashbox: r.cashbox, cassette1: r.cassette1, cassette2: r.cassette2, + cassette3: r.cassette3, + cassette4: r.cassette4, + numberOfCassettes: r.number_of_cassettes, version: r.version, model: r.model, pairedAt: new Date(r.created), @@ -100,6 +103,8 @@ function getMachine (machineId, config) { cashbox: r.cashbox, cassette1: r.cassette1, cassette2: r.cassette2, + cassette3: r.cassette3, + cassette4: r.cassette4, version: r.version, model: r.model, pairedAt: new Date(r.created), @@ -123,8 +128,8 @@ function renameMachine (rec) { function resetCashOutBills (rec) { const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId }) - const sql = `UPDATE devices SET cassette1=$1, cassette2=$2 WHERE device_id=$3;` - return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance')) + const sql = `UPDATE devices SET cassette1=$1, cassette2=$2, cassette3=$3, cassette4=$4 WHERE device_id=$5;` + return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.cassettes[2], rec.cassettes[3], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance')) } function emptyCashInBills (rec) { @@ -133,8 +138,8 @@ function emptyCashInBills (rec) { } function setCassetteBills (rec) { - const sql = 'update devices set cashbox=$1, cassette1=$2, cassette2=$3 where device_id=$4' - return db.none(sql, [rec.cashbox, rec.cassettes[0], rec.cassettes[1], rec.deviceId]) + const sql = 'update devices set cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5 where device_id=$6' + return db.none(sql, [rec.cashbox, rec.cassettes[0], rec.cassettes[1], rec.cassettes[2], rec.cassettes[3], rec.deviceId]) } function unpair (rec) { diff --git a/lib/new-admin/graphql/schema.js b/lib/new-admin/graphql/schema.js index f4756ab9..a81fbc84 100644 --- a/lib/new-admin/graphql/schema.js +++ b/lib/new-admin/graphql/schema.js @@ -77,6 +77,9 @@ const typeDefs = gql` cashbox: Int cassette1: Int cassette2: Int + cassette3: Int + cassette4: Int + numberOfCassettes: Int statuses: [MachineStatus] latestEvent: MachineEvent downloadSpeed: String @@ -333,7 +336,7 @@ const typeDefs = gql` } type Mutation { - machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, newName: String): Machine + machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, cassette3: Int, cassette4: Int, newName: String): Machine setCustomer(customerId: ID!, customerInput: CustomerInput): Customer saveConfig(config: JSONObject): JSONObject # resetConfig(schemaVersion: Int): JSONObject @@ -421,7 +424,7 @@ const resolvers = { bills: () => bills.getBills() }, Mutation: { - machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }), + machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }), createPairingTotem: (...[, { name }]) => pairing.totem(name), saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts), // resetAccounts: (...[, { schemaVersion }]) => settingsLoader.resetAccounts(schemaVersion), diff --git a/lib/new-admin/machines.js b/lib/new-admin/machines.js index 78e2f46a..e2d2ad5e 100644 --- a/lib/new-admin/machines.js +++ b/lib/new-admin/machines.js @@ -6,13 +6,13 @@ function getMachine (machineId) { .then(machines => machines.find(({ deviceId }) => deviceId === machineId)) } -function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, newName }) { +function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }) { return getMachine(deviceId) .then(machine => { if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId }) return machine }) - .then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2], newName })) + .then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName })) .then(getMachine(deviceId)) } diff --git a/lib/pairing.js b/lib/pairing.js index 5932878c..00916abe 100644 --- a/lib/pairing.js +++ b/lib/pairing.js @@ -20,16 +20,16 @@ function unpair (deviceId) { return Promise.all([db.none(sql, [deviceId]), db.none(deleteMachinePings, [deviceId])]) } -function pair (token, deviceId, machineModel) { +function pair (token, deviceId, machineModel, numOfCassettes) { return pullToken(token) .then(r => { if (r.expired) return false - const insertSql = `insert into devices (device_id, name) values ($1, $2) + const insertSql = `insert into devices (device_id, name, number_of_cassettes) values ($1, $2, $3) on conflict (device_id) do update set paired=TRUE, display=TRUE` - return db.none(insertSql, [deviceId, r.name]) + return db.none(insertSql, [deviceId, r.name, numOfCassettes]) .then(() => true) }) .catch(err => { diff --git a/lib/plugins.js b/lib/plugins.js index 0cca0bd4..1c7b3681 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -108,8 +108,10 @@ function plugins (settings, deviceId) { if (_.isEmpty(redeemableTxs)) return cassettes const sumTxs = (sum, tx) => { - const bills = tx.bills - const sameDenominations = a => a[0].denomination === a[1].denomination + // 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(cassettes, bills)) if (!doDenominationsMatch) { @@ -119,7 +121,7 @@ function plugins (settings, deviceId) { return _.map(r => r[0] + r[1].provisioned, _.zip(sum, tx.bills)) } - const provisioned = _.reduce(sumTxs, [0, 0], redeemableTxs) + const provisioned = _.reduce(sumTxs, _.times(_.constant(0), _.size(cassettes)), redeemableTxs) const zipped = _.zip(_.map('count', cassettes), provisioned) const counts = _.map(r => r[0] - r[1], zipped) @@ -127,16 +129,15 @@ function plugins (settings, deviceId) { throw new Error('Negative note count: %j', counts) } - return [ - { - denomination: cassettes[0].denomination, - count: counts[0] - }, - { - denomination: cassettes[1].denomination, - count: counts[1] - } - ] + const computedCassettes = [] + _.forEach(it => { + computedCassettes.push({ + denomination: cassettes[it].denomination, + count: counts[it] + }) + }, _.times(_.identity(), _.size(cassettes))) + + return computedCassettes } function buildAvailableCassettes (excludeTxId) { @@ -144,28 +145,32 @@ function plugins (settings, deviceId) { if (!cashOutConfig.active) return Promise.resolve() - const denominations = [cashOutConfig.top, cashOutConfig.bottom] - - const virtualCassettes = [Math.max(cashOutConfig.top, cashOutConfig.bottom) * 2] - return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)]) .then(([rec, _redeemableTxs]) => { const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs) + const denominations = [] + _.forEach(it => { + denominations.push(cashOutConfig[`cassette${it + 1}`]) + }, _.times(_.identity(), rec.numberOfCassettes)) + + const virtualCassettes = [Math.max(...denominations) * 2] + const counts = argv.cassettes ? argv.cassettes.split(',') : rec.counts - const cassettes = [ - { - denomination: parseInt(denominations[0], 10), - count: parseInt(counts[0], 10) - }, - { - denomination: parseInt(denominations[1], 10), - count: parseInt(counts[1], 10) - } - ] + if (rec.counts.length !== denominations.length) { + throw new Error('Denominations and respective counts do not match!') + } + + const cassettes = [] + _.forEach(it => { + cassettes.push({ + denomination: parseInt(denominations[it], 10), + count: parseInt(counts[it], 10) + }) + }, _.times(_.identity(), rec.numberOfCassettes)) try { return { @@ -305,7 +310,7 @@ function plugins (settings, deviceId) { function dispenseAck (tx) { const cashOutConfig = configManager.getCashOut(deviceId, settings.config) - const cassettes = [cashOutConfig.top, cashOutConfig.bottom] + const cassettes = [cashOutConfig.cassette1, cashOutConfig.cassette2, cashOutConfig.cassette3, cashOutConfig.cassette4] return dbm.addDispense(deviceId, tx, cassettes) } @@ -560,8 +565,10 @@ function plugins (settings, deviceId) { function checkDeviceCashBalances (fiatCode, device) { const cashOutConfig = configManager.getCashOut(device.deviceId, settings.config) - const denomination1 = cashOutConfig.top - const denomination2 = cashOutConfig.bottom + const denomination1 = cashOutConfig.cassette1 + const denomination2 = cashOutConfig.cassette2 + const denomination3 = cashOutConfig.cassette3 + const denomination4 = cashOutConfig.cassette4 const cashOutEnabled = cashOutConfig.active const notifications = configManager.getNotifications(null, device.deviceId, settings.config) @@ -601,7 +608,31 @@ function plugins (settings, deviceId) { } : null - return _.compact([cashInAlert, cassette1Alert, cassette2Alert]) + const cassette3Alert = cashOutEnabled && device.cassette3 < notifications.fiatBalanceCassette3 + ? { + code: 'LOW_CASH_OUT', + cassette: 3, + machineName, + deviceId: device.deviceId, + notes: device.cassette3, + denomination: denomination3, + fiatCode + } + : null + + const cassette4Alert = cashOutEnabled && device.cassette4 < notifications.fiatBalanceCassette4 + ? { + code: 'LOW_CASH_OUT', + cassette: 4, + machineName, + deviceId: device.deviceId, + notes: device.cassette4, + denomination: denomination4, + fiatCode + } + : null + + return _.compact([cashInAlert, cassette1Alert, cassette2Alert, cassette3Alert, cassette4Alert]) } function checkCryptoBalances (fiatCode, devices) { diff --git a/lib/postgresql_interface.js b/lib/postgresql_interface.js index eb030e91..1ae15ae1 100644 --- a/lib/postgresql_interface.js +++ b/lib/postgresql_interface.js @@ -26,13 +26,17 @@ exports.recordDeviceEvent = function recordDeviceEvent (deviceId, event) { } exports.cassetteCounts = function cassetteCounts (deviceId) { - const sql = 'SELECT cassette1, cassette2 FROM devices ' + + const sql = 'SELECT cassette1, cassette2, cassette3, cassette4, number_of_cassettes FROM devices ' + 'WHERE device_id=$1' return db.one(sql, [deviceId]) .then(row => { - const counts = [row.cassette1, row.cassette2] - return {counts} + const counts = [] + _.forEach(it => { + counts.push(row[`cassette${it + 1}`]) + }, _.times(_.identity(), row.number_of_cassettes)) + + return { numberOfCassettes: row.number_of_cassettes, counts } }) } diff --git a/lib/routes.js b/lib/routes.js index baee01ea..53c2fdf3 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -58,6 +58,7 @@ function checkHasLightning (settings) { function poll (req, res, next) { const machineVersion = req.query.version const machineModel = req.query.model + const numOfCassettes = req.query.numOfCassettes const deviceId = req.deviceId const deviceTime = req.deviceTime const serialNumber = req.query.sn @@ -417,8 +418,9 @@ function pair (req, res, next) { const token = req.query.token const deviceId = req.deviceId const model = req.query.model + const numOfCassettes = req.query.numOfCassettes - return pairing.pair(token, deviceId, model) + return pairing.pair(token, deviceId, model, numOfCassettes) .then(valid => { if (valid) { return res.json({ status: 'paired' }) diff --git a/migrations/1630432869178-add-more-cassette-support.js b/migrations/1630432869178-add-more-cassette-support.js new file mode 100644 index 00000000..d1268057 --- /dev/null +++ b/migrations/1630432869178-add-more-cassette-support.js @@ -0,0 +1,48 @@ +var db = require('./db') +const _ = require('lodash/fp') +const { saveConfig, loadLatest } = require('../lib/new-settings-loader') +const { getMachines } = require('../lib/machine-loader') + +exports.up = function (next) { + var sql = [ + 'ALTER TABLE devices ADD COLUMN cassette3 INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE devices ADD COLUMN cassette4 INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE cash_out_txs ADD COLUMN provisioned_3 INTEGER', + 'ALTER TABLE cash_out_txs ADD COLUMN provisioned_4 INTEGER', + 'ALTER TABLE cash_out_txs ADD COLUMN denomination_3 INTEGER', + 'ALTER TABLE cash_out_txs ADD COLUMN denomination_4 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN provisioned_3 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN provisioned_4 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN dispensed_3 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN dispensed_4 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN rejected_3 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN rejected_4 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN denomination_3 INTEGER', + 'ALTER TABLE cash_out_actions ADD COLUMN denomination_4 INTEGER', + 'ALTER TABLE devices ADD COLUMN number_of_cassettes INTEGER NOT NULL DEFAULT 2' + ] + + return Promise.all([loadLatest(), getMachines()]) + .then(([config, machines]) => { + const formattedMachines = _.map(it => _.pick(['deviceId'], it), machines) + const newConfig = _.reduce((acc, value) => { + if(_.includes(`cashOut_${value.deviceId}_top`, _.keys(config.config))) { + acc[`cashOut_${value.deviceId}_cassette1`] = config.config[`cashOut_${value.deviceId}_top`] + } + + if(_.includes(`cashOut_${value.deviceId}_bottom`, _.keys(config.config))) { + acc[`cashOut_${value.deviceId}_cassette2`] = config.config[`cashOut_${value.deviceId}_bottom`] + } + + return acc + }, {}, formattedMachines) + + return saveConfig(newConfig) + .then(() => db.multi(sql, next)) + .catch(err => next(err)) + }) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/components/editableTable/Row.js b/new-lamassu-admin/src/components/editableTable/Row.js index 03bf86f4..95504cb6 100644 --- a/new-lamassu-admin/src/components/editableTable/Row.js +++ b/new-lamassu-admin/src/components/editableTable/Row.js @@ -131,6 +131,7 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => { suffix, SuffixComponent = TL2, textStyle = it => {}, + isHidden = it => false, view = it => it?.toString(), inputProps = {} } = config @@ -165,16 +166,18 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => { size={size} bold={bold} textAlign={textAlign}> - {isEditing && isField && ( + {isEditing && isField && !isHidden(values) && ( )} - {isEditing && !isField && } - {!isEditing && values && ( + {isEditing && !isField && !isHidden(values) && ( + + )} + {!isEditing && values && !isHidden(values) && (
{view(values[name], values)}
)} - {suffix && ( + {suffix && !isHidden(values) && ( diff --git a/new-lamassu-admin/src/pages/Cashout/Cashout.js b/new-lamassu-admin/src/pages/Cashout/Cashout.js index d07527cf..49932d17 100644 --- a/new-lamassu-admin/src/pages/Cashout/Cashout.js +++ b/new-lamassu-admin/src/pages/Cashout/Cashout.js @@ -41,6 +41,9 @@ const GET_INFO = gql` cashbox cassette1 cassette2 + cassette3 + cassette4 + numberOfCassettes } config } @@ -62,6 +65,7 @@ const CashOut = ({ name: SCREEN_KEY }) => { } const config = data?.config && fromNamespace(SCREEN_KEY)(data.config) + const fudgeFactorActive = config?.fudgeFactorActive ?? false const locale = data?.config && fromNamespace('locale')(data.config) const machines = data?.machines ?? [] diff --git a/new-lamassu-admin/src/pages/Cashout/Wizard.js b/new-lamassu-admin/src/pages/Cashout/Wizard.js index 11259af1..4db51564 100644 --- a/new-lamassu-admin/src/pages/Cashout/Wizard.js +++ b/new-lamassu-admin/src/pages/Cashout/Wizard.js @@ -6,12 +6,12 @@ import Modal from 'src/components/Modal' import { Autocomplete } from 'src/components/inputs/formik' import denominations from 'src/utils/bill-denominations' import { toNamespace } from 'src/utils/config' +import { transformNumber } from 'src/utils/number' import WizardSplash from './WizardSplash' import WizardStep from './WizardStep' import { DenominationsSchema } from './helper' -const LAST_STEP = 4 const MODAL_WIDTH = 554 const MODAL_HEIGHT = 520 @@ -25,6 +25,7 @@ const getOptions = R.curry((locale, denomiations) => { }) const Wizard = ({ machine, locale, onClose, save, error }) => { + const LAST_STEP = machine.numberOfCassettes + 2 const [{ step, config }, setState] = useState({ step: 0, config: { active: true } @@ -38,7 +39,10 @@ const Wizard = ({ machine, locale, onClose, save, error }) => { const onContinue = async it => { if (isLastStep) { return save( - toNamespace(machine.deviceId, DenominationsSchema.cast(config)) + toNamespace( + machine.deviceId, + DenominationsSchema.cast(config, { assert: false }) + ) ) } @@ -50,40 +54,55 @@ const Wizard = ({ machine, locale, onClose, save, error }) => { }) } - const steps = [ - { - type: 'top', - display: 'Cassette 1 (Top)', - component: Autocomplete, - inputProps: { - options: R.map(it => ({ code: it, display: it }))(options), - labelProp: 'display', - valueProp: 'code' - } - }, - { - type: 'bottom', - display: 'Cassette 2', - component: Autocomplete, - inputProps: { - options: R.map(it => ({ code: it, display: it }))(options), - labelProp: 'display', - valueProp: 'code' - } - }, - { - type: 'zeroConfLimit', - display: '0-conf Limit', - schema: Yup.object().shape({ - zeroConfLimit: Yup.number().required() + const steps = [] + + R.until( + R.gt(R.__, machine.numberOfCassettes), + it => { + steps.push({ + type: `cassette${it}`, + display: `Cassette ${it}`, + component: Autocomplete, + inputProps: { + options: R.map(it => ({ code: it, display: it }))(options), + labelProp: 'display', + valueProp: 'code' + } }) - } - ] + return R.add(1, it) + }, + 1 + ) + + steps.push({ + type: 'zeroConfLimit', + display: '0-conf Limit', + schema: Yup.object().shape({ + zeroConfLimit: Yup.number().required() + }) + }) const schema = () => Yup.object().shape({ - top: Yup.number().required(), - bottom: step >= 2 ? Yup.number().required() : Yup.number() + cassette1: Yup.number().required(), + cassette2: + machine.numberOfCassettes > 1 && step >= 2 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + cassette3: + machine.numberOfCassettes > 2 && step >= 3 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable(), + cassette4: + machine.numberOfCassettes > 3 && step >= 4 + ? Yup.number().required() + : Yup.number() + .transform(transformNumber) + .nullable() }) return ( @@ -100,6 +119,7 @@ const Wizard = ({ machine, locale, onClose, save, error }) => { { const classes = useStyles() @@ -31,22 +32,29 @@ const WizardStep = ({ const cassetesArtworks = { 1: cassetteOne, - 2: cassetteTwo + 2: cassetteTwo, + 3: cassetteOne, + 4: cassetteTwo } return (
{name} - +
- {step <= 2 && ( + {step <= numberOfCassettes && (
@@ -95,7 +103,7 @@ const WizardStep = ({ )} - {step === 3 && ( + {step === numberOfCassettes + 1 && ( { - return [ + const elements = [ { name: 'id', header: 'Machine', @@ -30,47 +39,49 @@ const getElements = (machines, { fiatCurrency } = {}) => { view: it => machines.find(({ deviceId }) => deviceId === it).name, size: 'sm', editable: false - }, - { - name: 'top', - header: 'Cassette 1 (Top)', - size: 'sm', - stripe: true, - width: 200, - textAlign: 'right', - input: NumberInput, - inputProps: { - decimalPlaces: 0 - }, - suffix: fiatCurrency - }, - { - name: 'bottom', - header: 'Cassette 2 (Bottom)', - size: 'sm', - stripe: true, - textAlign: 'right', - width: 200, - input: NumberInput, - inputProps: { - decimalPlaces: 0 - }, - suffix: fiatCurrency - }, - { - name: 'zeroConfLimit', - header: '0-conf Limit', - size: 'sm', - stripe: true, - textAlign: 'right', - width: 200, - input: NumberInput, - inputProps: { - decimalPlaces: 0 - }, - suffix: fiatCurrency } ] + + R.until( + R.gt(R.__, Math.max(...R.map(it => it.numberOfCassettes, machines))), + it => { + elements.push({ + name: `cassette${it}`, + header: `Cassette ${it}`, + size: 'sm', + stripe: true, + textAlign: 'right', + width: 200, + input: NumberInput, + inputProps: { + decimalPlaces: 0 + }, + suffix: fiatCurrency, + isHidden: machine => + it > + machines.find(({ deviceId }) => deviceId === machine.id) + .numberOfCassettes + }) + return R.add(1, it) + }, + 1 + ) + + elements.push({ + name: 'zeroConfLimit', + header: '0-conf Limit', + size: 'sm', + stripe: true, + textAlign: 'right', + width: 200, + input: NumberInput, + inputProps: { + decimalPlaces: 0 + }, + suffix: fiatCurrency + }) + + return elements } export { DenominationsSchema, getElements } diff --git a/new-lamassu-admin/src/pages/Machines/MachineComponents/Cassettes/Cassettes.js b/new-lamassu-admin/src/pages/Machines/MachineComponents/Cassettes/Cassettes.js index 1f881809..86997b5f 100644 --- a/new-lamassu-admin/src/pages/Machines/MachineComponents/Cassettes/Cassettes.js +++ b/new-lamassu-admin/src/pages/Machines/MachineComponents/Cassettes/Cassettes.js @@ -40,6 +40,8 @@ const SET_CASSETTE_BILLS = gql` $cashbox: Int! $cassette1: Int! $cassette2: Int! + $cassette3: Int! + $cassette4: Int! ) { machineAction( deviceId: $deviceId @@ -47,11 +49,15 @@ const SET_CASSETTE_BILLS = gql` cashbox: $cashbox cassette1: $cassette1 cassette2: $cassette2 + cassette3: $cassette3 + cassette4: $cassette4 ) { deviceId cashbox cassette1 cassette2 + cassette3 + cassette4 } } ` @@ -90,7 +96,7 @@ const CashCassettes = ({ machine, config, refetchData }) => { view: (value, { deviceId }) => ( @@ -109,7 +115,47 @@ const CashCassettes = ({ machine, config, refetchData }) => { return ( + ) + }, + input: NumberInput, + inputProps: { + decimalPlaces: 0 + } + }, + { + name: 'cassette3', + header: 'Cash-out 3', + width: 265, + stripe: true, + view: (value, { deviceId }) => { + return ( + + ) + }, + input: NumberInput, + inputProps: { + decimalPlaces: 0 + } + }, + { + name: 'cassette4', + header: 'Cash-out 4', + width: 265, + stripe: true, + view: (value, { deviceId }) => { + return ( + @@ -126,14 +172,18 @@ const CashCassettes = ({ machine, config, refetchData }) => { refetchQueries: () => refetchData() }) - const onSave = (...[, { deviceId, cashbox, cassette1, cassette2 }]) => { + const onSave = ( + ...[, { deviceId, cashbox, cassette1, cassette2, cassette3, cassette4 }] + ) => { return setCassetteBills({ variables: { action: 'setCassetteBills', deviceId: deviceId, cashbox, cassette1, - cassette2 + cassette2, + cassette3, + cassette4 } }) } diff --git a/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js b/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js index e411e5dc..9ed9eeec 100644 --- a/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js +++ b/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js @@ -26,13 +26,25 @@ const ValidationSchema = Yup.object().shape({ .min(0) .max(1000), cassette1: Yup.number() - .label('Cassette 1 (top)') + .label('Cassette 1') .required() .integer() .min(0) .max(500), cassette2: Yup.number() - .label('Cassette 2 (bottom)') + .label('Cassette 2') + .required() + .integer() + .min(0) + .max(500), + cassette3: Yup.number() + .label('Cassette 3') + .required() + .integer() + .min(0) + .max(500), + cassette4: Yup.number() + .label('Cassette 4') .required() .integer() .min(0) @@ -47,6 +59,9 @@ const GET_MACHINES_AND_CONFIG = gql` cashbox cassette1 cassette2 + cassette3 + cassette4 + numberOfCassettes } config } @@ -69,6 +84,8 @@ const SET_CASSETTE_BILLS = gql` $cashbox: Int! $cassette1: Int! $cassette2: Int! + $cassette3: Int! + $cassette4: Int! ) { machineAction( deviceId: $deviceId @@ -76,11 +93,15 @@ const SET_CASSETTE_BILLS = gql` cashbox: $cashbox cassette1: $cassette1 cassette2: $cassette2 + cassette3: $cassette3 + cassette4: $cassette4 ) { deviceId cashbox cassette1 cassette2 + cassette3 + cassette4 } } ` @@ -103,14 +124,18 @@ const CashCassettes = () => { const locale = data?.config && fromNamespace('locale')(data.config) const fiatCurrency = locale?.fiatCurrency - const onSave = (...[, { id, cashbox, cassette1, cassette2 }]) => { + const onSave = ( + ...[, { id, cashbox, cassette1, cassette2, cassette3, cassette4 }] + ) => { return setCassetteBills({ variables: { action: 'setCassetteBills', deviceId: id, cashbox, cassette1, - cassette2 + cassette2, + cassette3, + cassette4 } }) } @@ -136,46 +161,35 @@ const CashCassettes = () => { inputProps: { decimalPlaces: 0 } - }, - { - name: 'cassette1', - header: 'Cassette 1 (Top)', - width: 265, - stripe: true, - view: (value, { id }) => ( - - ), - input: CashCassetteInput, - inputProps: { - decimalPlaces: 0 - } - }, - { - name: 'cassette2', - header: 'Cassette 2 (Bottom)', - width: 265, - stripe: true, - view: (value, { id }) => { - return ( + } + ] + + R.until( + R.gt(R.__, Math.max(...R.map(it => it.numberOfCassettes, machines))), + it => { + elements.push({ + name: `cassette${it}`, + header: `Cassette ${it}`, + width: 265, + stripe: true, + view: (value, { id }) => ( - ) - }, - input: CashCassetteInput, - inputProps: { - decimalPlaces: 0 - } - } - ] + ), + isHidden: ({ numberOfCassettes }) => it > numberOfCassettes, + input: CashCassetteInput, + inputProps: { + decimalPlaces: 0 + } + }) + return R.add(1, it) + }, + 1 + ) return ( <> diff --git a/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js b/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js index 53cfe9dc..04d1de99 100644 --- a/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js +++ b/new-lamassu-admin/src/pages/Maintenance/CashCassettesFooter.js @@ -25,16 +25,23 @@ const CashCassettesFooter = ({ const classes = useStyles() const cashout = config && fromNamespace('cashOut')(config) const getCashoutSettings = id => fromNamespace(id)(cashout) - const reducerFn = (acc, { cassette1, cassette2, id }) => { - const topDenomination = getCashoutSettings(id).top ?? 0 - const bottomDenomination = getCashoutSettings(id).bottom ?? 0 + const reducerFn = ( + acc, + { cassette1, cassette2, cassette3, cassette4, id } + ) => { + const cassette1Denomination = getCashoutSettings(id).cassette1 ?? 0 + const cassette2Denomination = getCashoutSettings(id).cassette2 ?? 0 + const cassette3Denomination = getCashoutSettings(id).cassette3 ?? 0 + const cassette4Denomination = getCashoutSettings(id).cassette4 ?? 0 return [ - (acc[0] += cassette1 * topDenomination), - (acc[1] += cassette2 * bottomDenomination) + (acc[0] += cassette1 * cassette1Denomination), + (acc[1] += cassette2 * cassette2Denomination), + (acc[2] += cassette3 * cassette3Denomination), + (acc[3] += cassette4 * cassette4Denomination) ] } - const totalInCassettes = R.sum(R.reduce(reducerFn, [0, 0], machines)) + const totalInCassettes = R.sum(R.reduce(reducerFn, [0, 0, 0, 0], machines)) /* const totalInCashBox = R.sum( R.flatten(