Merge pull request #830 from chaotixkilla/feat-add-multiple-cassettes-option

Add support for multiple cassettes
This commit is contained in:
Rafael Taranto 2021-10-22 09:59:25 +01:00 committed by GitHub
commit 1c207ab250
21 changed files with 651 additions and 233 deletions

View file

@ -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 }

View file

@ -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
}

View file

@ -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})))

View file

@ -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))
}

View file

@ -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}))

View file

@ -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) {

View file

@ -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),

View file

@ -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))
}

View file

@ -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 => {

View file

@ -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) {

View file

@ -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 }
})
}

View file

@ -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' })

View file

@ -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()
}

View file

@ -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) && (
<Field name={name} component={input} {...innerProps} />
)}
{isEditing && !isField && <config.input name={name} />}
{!isEditing && values && (
{isEditing && !isField && !isHidden(values) && (
<config.input name={name} />
)}
{!isEditing && values && !isHidden(values) && (
<div style={textStyle(values, isEditing)}>
{view(values[name], values)}
</div>
)}
{suffix && (
{suffix && !isHidden(values) && (
<SuffixComponent
className={classes.suffix}
style={isEditing ? {} : textStyle(values, isEditing)}>

View file

@ -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 ?? []

View file

@ -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 }) => {
<WizardStep
step={step}
name={machine.name}
numberOfCassettes={machine.numberOfCassettes}
error={error}
lastStep={isLastStep}
steps={steps}

View file

@ -23,7 +23,8 @@ const WizardStep = ({
onContinue,
steps,
fiatCurrency,
options
options,
numberOfCassettes
}) => {
const classes = useStyles()
@ -31,22 +32,29 @@ const WizardStep = ({
const cassetesArtworks = {
1: cassetteOne,
2: cassetteTwo
2: cassetteTwo,
3: cassetteOne,
4: cassetteTwo
}
return (
<div className={classes.content}>
<div className={classes.titleDiv}>
<Info2 className={classes.title}>{name}</Info2>
<Stepper steps={4} currentStep={step} />
<Stepper steps={steps.length + 1} currentStep={step} />
</div>
{step <= 2 && (
{step <= numberOfCassettes && (
<Formik
validateOnBlur={false}
validateOnChange={false}
onSubmit={onContinue}
initialValues={{ top: '', bottom: '' }}
initialValues={{
cassette1: '',
cassette2: '',
cassette3: '',
cassette4: ''
}}
enableReinitialize
validationSchema={schema}>
<Form>
@ -95,7 +103,7 @@ const WizardStep = ({
</Formik>
)}
{step === 3 && (
{step === numberOfCassettes + 1 && (
<Formik
validateOnBlur={false}
validateOnChange={false}

View file

@ -1,19 +1,28 @@
import * as R from 'ramda'
import * as Yup from 'yup'
import { NumberInput } from 'src/components/inputs/formik'
const currencyMax = 999999999
const DenominationsSchema = Yup.object().shape({
top: Yup.number()
.label('Cassette 1 (Top)')
cassette1: Yup.number()
.label('Cassette 1')
.required()
.min(1)
.max(currencyMax),
bottom: Yup.number()
.label('Cassette 2 (Bottom)')
cassette2: Yup.number()
.label('Cassette 2')
.required()
.min(1)
.max(currencyMax),
cassette3: Yup.number()
.label('Cassette 3')
.min(1)
.max(currencyMax),
cassette4: Yup.number()
.label('Cassette 4')
.min(1)
.max(currencyMax),
zeroConfLimit: Yup.number()
.label('0-conf Limit')
.required()
@ -22,7 +31,7 @@ const DenominationsSchema = Yup.object().shape({
})
const getElements = (machines, { fiatCurrency } = {}) => {
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 }

View file

@ -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 }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(deviceId)?.top}
denomination={getCashoutSettings(deviceId)?.cassette1}
currency={{ code: fiatCurrency }}
notes={value}
/>
@ -109,7 +115,47 @@ const CashCassettes = ({ machine, config, refetchData }) => {
return (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(deviceId)?.bottom}
denomination={getCashoutSettings(deviceId)?.cassette2}
currency={{ code: fiatCurrency }}
notes={value}
/>
)
},
input: NumberInput,
inputProps: {
decimalPlaces: 0
}
},
{
name: 'cassette3',
header: 'Cash-out 3',
width: 265,
stripe: true,
view: (value, { deviceId }) => {
return (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(deviceId)?.cassette2}
currency={{ code: fiatCurrency }}
notes={value}
/>
)
},
input: NumberInput,
inputProps: {
decimalPlaces: 0
}
},
{
name: 'cassette4',
header: 'Cash-out 4',
width: 265,
stripe: true,
view: (value, { deviceId }) => {
return (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(deviceId)?.cassette2}
currency={{ code: fiatCurrency }}
notes={value}
/>
@ -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
}
})
}

View file

@ -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 }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.top}
currency={{ code: fiatCurrency }}
notes={value}
/>
),
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 }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.bottom}
denomination={getCashoutSettings(id)?.[`cassette${it}`]}
currency={{ code: fiatCurrency }}
notes={value}
/>
)
},
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0
}
}
]
),
isHidden: ({ numberOfCassettes }) => it > numberOfCassettes,
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0
}
})
return R.add(1, it)
},
1
)
return (
<>

View file

@ -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(