Merge pull request #1552 from chaotixkilla/feat-add-aveiro-admin-ui-8.5

[LAM-849] [Release 8.5] Add Aveiro admin UI
This commit is contained in:
Rafael Taranto 2023-06-26 21:29:09 +01:00 committed by GitHub
commit 0366cc54ed
72 changed files with 3070 additions and 926 deletions

View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const _ = require('lodash')
const db = require('../lib/db')
if (process.argv.length !== 4) {
console.log('Usage: lamassu-update-stackers <device_id> <number_of_stackers>')
process.exit(1)
}
if (!_.isFinite(parseInt(process.argv[3]))) {
console.log('Error: <number_of_stackers> is not a valid number (%s)', err)
process.exit(3)
}
if (parseInt(process.argv[3]) > 3 || parseInt(process.argv[3]) < 1) {
console.log('Error: <number_of_stackers> is out of range. Should be a number between 1 and 3')
process.exit(3)
}
const deviceId = process.argv[2]
const numberOfStackers = parseInt(process.argv[3])
const query = `UPDATE devices SET number_of_stackers = $1 WHERE device_id = $2`
db.none(query, [numberOfStackers, deviceId])
.then(() => {
console.log('Success! Device %s updated to %s stackers', deviceId, numberOfStackers)
process.exit(0)
})
.catch(err => {
console.log('Error: %s', err)
process.exit(3)
})

View file

@ -1,225 +1,145 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const uuid = require('uuid') const sumService = require('@haensl/subset-sum')
const MAX_AMOUNT_OF_SOLUTIONS = 10000 const BILL_LIST_MODES = {
const MAX_BRUTEFORCE_ITERATIONS = 10000000 LAST_UNIT_FIRST: 0,
FIRST_UNIT_FIRST: 1,
function newSolution(cassettes, c0, c1, c2, c3, shouldFlip) { LOWEST_VALUE_FIRST: 2,
return [ HIGHEST_VALUE_FIRST: 3,
{ UNIT_ROUND_ROBIN: 4,
provisioned: shouldFlip ? cassettes[0].count - c0 : c0, VALUE_ROUND_ROBIN: 5
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 buildBillList = (units, mode) => {
const map = {} switch (mode) {
case BILL_LIST_MODES.LAST_UNIT_FIRST:
_.forEach(it => { return _.reduce(
if (!map[it.denomination]) { (acc, value) => {
map[it.denomination] = 0 acc.push(..._.times(_.constant(value.denomination), value.count))
} return acc
map[it.denomination] += it.count },
}, cassettes) [],
_.reverse(units)
return _.map(it => ({ denomination: it, count: map[it] }), _.keys(map)) )
} case BILL_LIST_MODES.FIRST_UNIT_FIRST:
return _.reduce(
function unmergeCassettes(cassettes, output) { (acc, value) => {
const map = {} acc.push(..._.times(_.constant(value.denomination), value.count))
return acc
_.forEach(it => { },
if (!map[it.denomination]) { [],
map[it.denomination] = 0 units
} )
map[it.denomination] += it.provisioned case BILL_LIST_MODES.LOWEST_VALUE_FIRST:
}, output) return _.reduce(
(acc, value) => {
const response = [] acc.push(..._.times(_.constant(value.denomination), value.count))
_.forEach(it => { return acc
const value = { },
denomination: it.denomination, [],
id: uuid.v4() _.orderBy(['denomination'], ['asc'])(units)
} )
case BILL_LIST_MODES.HIGHEST_VALUE_FIRST:
const amountNeeded = map[it.denomination] return _.reduce(
if (!amountNeeded) { (acc, value) => {
return response.push({ provisioned: 0, ...value }) acc.push(..._.times(_.constant(value.denomination), value.count))
} return acc
},
if (amountNeeded < it.count) { [],
map[it.denomination] = 0 _.orderBy(['denomination'], ['desc'])(units)
return response.push({ provisioned: amountNeeded, ...value }) )
} case BILL_LIST_MODES.UNIT_ROUND_ROBIN:
map[it.denomination] -= it.count
return response.push({ provisioned: it.count, ...value })
}, cassettes)
return response
}
function makeChangeDuo(cassettes, amount) {
// Initialize empty cassettes in case of undefined, due to same denomination across all cassettes results in a single merged cassette
const small = cassettes[0] ?? { denomination: 0, count: 0 }
const large = cassettes[1] ?? { denomination: 0, count: 0 }
const largeDenom = large.denomination
const smallDenom = small.denomination
const largeBills = Math.min(large.count, Math.floor(amount / largeDenom))
const amountNum = amount.toNumber()
for (let i = largeBills; i >= 0; i--) {
const remainder = amountNum - largeDenom * i
if (remainder % smallDenom !== 0) continue
const smallCount = remainder / smallDenom
if (smallCount > small.count) continue
return [
{ {
provisioned: smallCount, const amountOfBills = _.reduce(
denomination: small.denomination, (acc, value) => acc + value.count,
id: uuid.v4() 0,
}, units
{ provisioned: i, denomination: largeDenom, id: uuid.v4() } )
]
const _units = _.filter(it => it.count > 0)(_.cloneDeep(units))
const bills = []
for(let i = 0; i < amountOfBills; i++) {
const idx = i % _.size(_units)
if (_units[idx].count > 0) {
bills.push(_units[idx].denomination)
_units[idx].count--
}
if (_units[idx].count === 0) {
_units.splice(idx, 1)
}
}
return bills
}
case BILL_LIST_MODES.VALUE_ROUND_ROBIN:
{
const amountOfBills = _.reduce(
(acc, value) => acc + value.count,
0,
units
)
const _units = _.flow([_.filter(it => it.count > 0), _.orderBy(['denomination'], ['asc'])])(_.cloneDeep(units))
const bills = []
for(let i = 0; i < amountOfBills; i++) {
const idx = i % _.size(_units)
if (_units[idx].count > 0) {
bills.push(_units[idx].denomination)
_units[idx].count--
}
if (_units[idx].count === 0) {
_units.splice(idx, 1)
}
}
return bills
}
default:
throw new Error(`Invalid mode: ${mode}`)
}
}
const getSolution = (units, amount, mode) => {
const billList = buildBillList(units, mode)
if (_.sum(billList) < amount.toNumber()) {
return []
} }
return [] const solver = sumService.subsetSum(billList, amount.toNumber())
const solution = _.countBy(Math.floor, solver.next().value)
return _.reduce(
(acc, value) => {
acc.push({ denomination: _.toNumber(value), provisioned: solution[value] })
return acc
},
[],
_.keys(solution)
)
}
const solutionToOriginalUnits = (solution, units) => {
const billsLeft = _.clone(_.fromPairs(_.map(it => [it.denomination, it.provisioned])(solution)))
return _.reduce(
(acc, value) => {
const unit = units[value]
const billsToAssign = _.clamp(0, unit.count)(_.isNaN(billsLeft[unit.denomination]) || _.isNil(billsLeft[unit.denomination]) ? 0 : billsLeft[unit.denomination])
acc.push({ name: unit.name, denomination: unit.denomination, provisioned: billsToAssign })
billsLeft[unit.denomination] -= billsToAssign
return acc
},
[],
_.range(0, _.size(units))
)
} }
function makeChange(outCassettes, amount) { function makeChange(outCassettes, amount) {
const available = _.reduce( const solution = getSolution(outCassettes, amount, BILL_LIST_MODES.VALUE_ROUND_ROBIN)
(res, val) => res + val.count * val.denomination, return solutionToOriginalUnits(solution, outCassettes)
0,
outCassettes
)
if (available < amount) {
console.log(`Tried to dispense more than was available for amount ${amount.toNumber()} with cassettes ${JSON.stringify(outCassettes)}`)
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 } module.exports = { makeChange }

View file

@ -41,13 +41,24 @@ function insertNewBills (t, billRows, machineTx) {
if (_.isEmpty(bills)) return Promise.resolve([]) if (_.isEmpty(bills)) return Promise.resolve([])
const dbBills = _.map(cashInLow.massage, bills) const dbBills = _.map(cashInLow.massage, bills)
const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time'] const getBillsByDestination = destination => _.filter(it => it.destination_unit === destination)(dbBills)
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 sql = pgp.helpers.insert(dbBills, columns, 'bills')
const deviceID = machineTx.deviceId const deviceID = machineTx.deviceId
const sql2 = `update devices set cashbox = cashbox + $2 const sql2 = `update devices set cashbox = cashbox + $2, stacker1f = stacker1f + $3, stacker1r = stacker1r + $4, stacker2f = stacker2f + $5, stacker2r = stacker2r + $6, stacker3f = stacker3f + $7, stacker3r = stacker3r + $8
where device_id = $1` where device_id = $1`
return t.none(sql2, [deviceID, dbBills.length]) return t.none(sql2, [
deviceID,
getBillsByDestination('cashbox').length,
getBillsByDestination('stacker1f').length,
getBillsByDestination('stacker1r').length,
getBillsByDestination('stacker2f').length,
getBillsByDestination('stacker2r').length,
getBillsByDestination('stacker3f').length,
getBillsByDestination('stacker3r').length
])
.then(() => { .then(() => {
return t.none(sql) return t.none(sql)
}) })

View file

@ -40,10 +40,11 @@ function mapDispense (tx) {
const res = {} const res = {}
_.forEach(it => { _.forEach(it => {
res[`provisioned_${it + 1}`] = bills[it].provisioned const suffix = bills[it].name.replace(/cassette|stacker/gi, '')
res[`denomination_${it + 1}`] = bills[it].denomination res[`provisioned_${suffix}`] = bills[it].provisioned
res[`dispensed_${it + 1}`] = bills[it].dispensed res[`denomination_${suffix}`] = bills[it].denomination
res[`rejected_${it + 1}`] = bills[it].rejected res[`dispensed_${suffix}`] = bills[it].dispensed
res[`rejected_${suffix}`] = bills[it].rejected
}, _.times(_.identity(), _.size(bills))) }, _.times(_.identity(), _.size(bills)))
return res return res

View file

@ -107,17 +107,10 @@ function nextHd (t, isHd, tx) {
function updateCassettes (t, tx) { function updateCassettes (t, tx) {
if (!dispenseOccurred(tx.bills)) return Promise.resolve() if (!dispenseOccurred(tx.bills)) return Promise.resolve()
const sql = `update devices set const billsStmt = _.join(', ')(_.map(it => `${tx.bills[it].name} = ${tx.bills[it].name} - $${it + 1}`)(_.range(0, _.size(tx.bills))))
${_.size(tx.bills) > 0 ? `cassette1 = cassette1 - $1` : ``} const returnStmt = _.join(', ')(_.map(bill => `${bill.name}`)(tx.bills))
${_.size(tx.bills) > 1 ? `, cassette2 = cassette2 - $2` : ``}
${_.size(tx.bills) > 2 ? `, cassette3 = cassette3 - $3` : ``} const sql = `UPDATE devices SET ${billsStmt} WHERE device_id = $${_.size(tx.bills) + 1} RETURNING ${returnStmt}`
${_.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 = [] const values = []

View file

@ -17,6 +17,32 @@ case
else 'Pending' else 'Pending'
end` end`
const MAX_CASSETTES = 4
const MAX_STACKERS = 3
const BILL_FIELDS = [
'denomination1',
'denomination2',
'denomination3',
'denomination4',
'denomination1f',
'denomination1r',
'denomination2f',
'denomination2r',
'denomination3f',
'denomination3r',
'provisioned1',
'provisioned2',
'provisioned3',
'provisioned4',
'provisioned1f',
'provisioned1r',
'provisioned2f',
'provisioned2r',
'provisioned3f',
'provisioned3r'
]
module.exports = { redeemableTxs, toObj, toDb, REDEEMABLE_AGE, CASH_OUT_TRANSACTION_STATES } module.exports = { redeemableTxs, toObj, toDb, REDEEMABLE_AGE, CASH_OUT_TRANSACTION_STATES }
const mapValuesWithKey = _.mapValues.convert({cap: false}) const mapValuesWithKey = _.mapValues.convert({cap: false})
@ -43,23 +69,37 @@ function convertBigNumFields (obj) {
} }
function convertField (key) { function convertField (key) {
return _.snakeCase(key) return _.includes('denomination', key) || _.includes('provisioned', key) ? key : _.snakeCase(key)
} }
function addDbBills (tx) { function addDbBills (tx) {
const bills = tx.bills const bills = tx.bills
if (_.isEmpty(bills)) return tx if (_.isEmpty(bills)) return tx
const billsObj = { const billFields = _.map(it => _.replace(/(denomination|provisioned)/g, '$1_')(it), BILL_FIELDS)
provisioned1: bills[0]?.provisioned ?? 0,
provisioned2: bills[1]?.provisioned ?? 0, const billsObj = _.flow(
provisioned3: bills[2]?.provisioned ?? 0, _.reduce(
provisioned4: bills[3]?.provisioned ?? 0, (acc, value) => {
denomination1: bills[0]?.denomination ?? 0, const suffix = value.name.replace(/cassette|stacker/gi, '')
denomination2: bills[1]?.denomination ?? 0, return {
denomination3: bills[2]?.denomination ?? 0, ...acc,
denomination4: bills[3]?.denomination ?? 0 [`provisioned_${suffix}`]: value.provisioned,
} [`denomination_${suffix}`]: value.denomination
}
},
{}
),
it => {
const missingKeys = _.reduce(
(acc, value) => {
return _.assign({ [value]: 0 })(acc)
},
{}
)(_.difference(billFields, _.keys(it)))
return _.assign(missingKeys, it)
}
)(bills)
return _.assign(tx, billsObj) return _.assign(tx, billsObj)
} }
@ -78,7 +118,7 @@ function toObj (row) {
let newObj = {} let newObj = {}
keys.forEach(key => { keys.forEach(key => {
const objKey = _.camelCase(key) const objKey = key.match(/denomination|provisioned/g) ? key.replace(/_/g, '') : _.camelCase(key)
if (key === 'received_crypto_atoms' && row[key]) { if (key === 'received_crypto_atoms' && row[key]) {
newObj[objKey] = new BN(row[key]) newObj[objKey] = new BN(row[key])
return return
@ -93,35 +133,28 @@ function toObj (row) {
newObj.direction = 'cashOut' newObj.direction = 'cashOut'
const billFields = ['denomination1', 'denomination2', 'denomination3', 'denomination4', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4'] if (_.every(_.isNil, _.at(BILL_FIELDS, newObj))) return newObj
if (_.some(_.isNil, _.at(BILL_FIELDS, newObj))) throw new Error('Missing cassette values')
if (_.every(_.isNil, _.at(billFields, newObj))) return newObj const billFieldsArr = _.concat(
if (_.some(_.isNil, _.at(billFields, newObj))) throw new Error('Missing cassette values') _.map(it => ({ name: `cassette${it + 1}`, denomination: newObj[`denomination${it + 1}`], provisioned: newObj[`provisioned${it + 1}`] }))(_.range(0, MAX_CASSETTES)),
_.reduce(
const billFieldsArr = [ (acc, value) => {
{ acc.push(
denomination: newObj.denomination1, { name: `stacker${value + 1}f`, denomination: newObj[`denomination${value + 1}f`], provisioned: newObj[`provisioned${value + 1}f`] },
provisioned: newObj.provisioned1 { name: `stacker${value + 1}r`, denomination: newObj[`denomination${value + 1}r`], provisioned: newObj[`provisioned${value + 1}r`] }
}, )
{ return acc
denomination: newObj.denomination2, },
provisioned: newObj.provisioned2 []
}, )(_.range(0, MAX_STACKERS))
{ )
denomination: newObj.denomination3,
provisioned: newObj.provisioned3
},
{
denomination: newObj.denomination4,
provisioned: newObj.provisioned4
}
]
// There can't be bills with denomination === 0. // 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. // 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) const bills = _.filter(it => it.denomination > 0, billFieldsArr)
return _.set('bills', bills, _.omit(billFields, newObj)) return _.set('bills', bills, _.omit(BILL_FIELDS, newObj))
} }
function redeemableTxs (deviceId) { function redeemableTxs (deviceId) {
@ -129,7 +162,10 @@ function redeemableTxs (deviceId) {
where device_id=$1 where device_id=$1
and redeem=$2 and redeem=$2
and dispense=$3 and dispense=$3
and provisioned_1 is not null and (
provisioned_1 is not null or provisioned_2 is not null or provisioned_3 is not null or provisioned_4 is not null or
provisioned_1f is not null or provisioned_1r is not null or provisioned_2f is not null or provisioned_2r is not null or provisioned_3f is not null or provisioned_3r is not null
)
and extract(epoch from (now() - greatest(created, confirmed_at))) < $4` and extract(epoch from (now() - greatest(created, confirmed_at))) < $4`
return db.any(sql, [deviceId, true, false, REDEEMABLE_AGE]) return db.any(sql, [deviceId, true, false, REDEEMABLE_AGE])

View file

@ -56,14 +56,15 @@ function postProcess (txVector, justAuthorized, pi) {
} }
if ((newTx.dispense && !oldTx.dispense) || (newTx.redeem && !oldTx.redeem)) { if ((newTx.dispense && !oldTx.dispense) || (newTx.redeem && !oldTx.redeem)) {
return pi.buildAvailableCassettes(newTx.id) return pi.buildAvailableUnits(newTx.id)
.then(cassettes => { .then(_units => {
const units = _.concat(_units.cassettes, _units.stackers)
logger.silly('Computing bills to dispense:', { logger.silly('Computing bills to dispense:', {
txId: newTx.id, txId: newTx.id,
cassettes: cassettes.cassettes, units: units,
fiat: newTx.fiat fiat: newTx.fiat
}) })
const bills = billMath.makeChange(cassettes.cassettes, newTx.fiat) const bills = billMath.makeChange(units, newTx.fiat)
logger.silly('Bills to dispense:', JSON.stringify(bills)) logger.silly('Bills to dispense:', JSON.stringify(bills))
if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE) if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE)
@ -73,8 +74,9 @@ function postProcess (txVector, justAuthorized, pi) {
const rec = {} const rec = {}
_.forEach(it => { _.forEach(it => {
rec[`provisioned_${it + 1}`] = bills[it].provisioned const suffix = bills[it].name.replace(/cassette|stacker/gi, '')
rec[`denomination_${it + 1}`] = bills[it].denomination rec[`provisioned_${suffix}`] = bills[it].provisioned
rec[`denomination_${suffix}`] = bills[it].denomination
}, _.times(_.identity(), _.size(bills))) }, _.times(_.identity(), _.size(bills)))
return cashOutActions.logAction(db, 'provisionNotes', rec, newTx) return cashOutActions.logAction(db, 'provisionNotes', rec, newTx)

View file

@ -2,26 +2,37 @@ const constants = require('./constants')
const db = require('./db') const db = require('./db')
const _ = require('lodash/fp') const _ = require('lodash/fp')
const uuid = require('uuid') const uuid = require('uuid')
const camelize = require('./utils')
function createCashboxBatch (deviceId, cashboxCount) { function createCashboxBatch (deviceId, cashboxCount) {
if (_.isEqual(0, cashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.') if (_.isEqual(0, cashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.')
const sql = `INSERT INTO cashbox_batches (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty') RETURNING *` const sql = `INSERT INTO cash_unit_operation (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty') RETURNING *`
const sql2 = ` const sql2 = `
UPDATE bills SET cashbox_batch_id=$1 UPDATE bills SET cashbox_batch_id=$1
FROM cash_in_txs FROM cash_in_txs
WHERE bills.cash_in_txs_id = cash_in_txs.id AND WHERE bills.cash_in_txs_id = cash_in_txs.id AND
cash_in_txs.device_id = $2 AND cash_in_txs.device_id = $2 AND
bills.destination_unit = 'cashbox' AND
bills.cashbox_batch_id IS NULL bills.cashbox_batch_id IS NULL
` `
return db.tx(async t => { const sql3 = `
const newBatch = await t.oneOrNone(sql, [uuid.v4(), deviceId]) UPDATE empty_unit_bills SET cashbox_batch_id=$1
return t.oneOrNone(sql2, [newBatch.id, newBatch.device_id]) WHERE empty_unit_bills.device_id = $2 AND empty_unit_bills.cashbox_batch_id IS NULL`
return db.tx(t => {
const batchId = uuid.v4()
const q1 = t.none(sql, [batchId, deviceId])
const q2 = t.none(sql2, [batchId, deviceId])
const q3 = t.none(sql3, [batchId, deviceId])
return t.batch([q1, q2, q3])
}) })
} }
function updateMachineWithBatch (machineContext, oldCashboxCount) { function updateMachineWithBatch (machineContext, oldCashboxCount) {
const isValidContext = _.has(['deviceId', 'cashbox', 'cassettes'], machineContext) const cashUnits = machineContext.cashUnits
const isCassetteAmountWithinRange = _.inRange(constants.CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES, constants.CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES + 1, _.size(machineContext.cassettes)) const isValidContext = _.has(['deviceId', 'cashUnits'], machineContext) && _.has(['cashbox', 'cassette1', 'cassette2', 'cassette3', 'cassette4', 'stacker1f', 'stacker1r', 'stacker2f', 'stacker2r', 'stacker3f', 'stacker3r'], cashUnits)
const cassettes = _.filter(it => !_.isNil(it))([cashUnits.cassette1, cashUnits.cassette2, cashUnits.cassette3, cashUnits.cassette4])
const isCassetteAmountWithinRange = _.inRange(constants.CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES, constants.CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES + 1, _.size(cassettes))
if (!isValidContext && !isCassetteAmountWithinRange) if (!isValidContext && !isCassetteAmountWithinRange)
throw new Error('Insufficient info to create a new cashbox batch') throw new Error('Insufficient info to create a new cashbox batch')
if (_.isEqual(0, oldCashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.') if (_.isEqual(0, oldCashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.')
@ -29,43 +40,59 @@ function updateMachineWithBatch (machineContext, oldCashboxCount) {
return db.tx(t => { return db.tx(t => {
const deviceId = machineContext.deviceId const deviceId = machineContext.deviceId
const batchId = uuid.v4() const batchId = uuid.v4()
const q1 = t.none(`INSERT INTO cashbox_batches (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty')`, [batchId, deviceId]) const q1 = t.none(`INSERT INTO cash_unit_operation (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty')`, [batchId, deviceId])
const q2 = t.none(`UPDATE bills SET cashbox_batch_id=$1 FROM cash_in_txs const q2 = t.none(`UPDATE bills SET cashbox_batch_id=$1 FROM cash_in_txs
WHERE bills.cash_in_txs_id = cash_in_txs.id AND WHERE bills.cash_in_txs_id = cash_in_txs.id AND
cash_in_txs.device_id = $2 AND cash_in_txs.device_id = $2 AND
bills.destination_unit = 'cashbox' AND
bills.cashbox_batch_id IS NULL`, [batchId, deviceId]) bills.cashbox_batch_id IS NULL`, [batchId, deviceId])
const q3 = t.none(`UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5 WHERE device_id=$6`, [ const q3 = t.none(`UPDATE empty_unit_bills SET cashbox_batch_id=$1
machineContext.cashbox, WHERE empty_unit_bills.device_id = $2 AND empty_unit_bills.cashbox_batch_id IS NULL`, [batchId, deviceId])
machineContext.cassettes[0], const q4 = t.none(`UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5, stacker1f=$6, stacker1r=$7, stacker2f=$8, stacker2r=$9, stacker3f=$10, stacker3r=$11 WHERE device_id=$12`, [
machineContext.cassettes[1], cashUnits.cashbox,
machineContext.cassettes[2], cashUnits.cassette1,
machineContext.cassettes[3], cashUnits.cassette2,
cashUnits.cassette3,
cashUnits.cassette4,
cashUnits.stacker1f,
cashUnits.stacker1r,
cashUnits.stacker2f,
cashUnits.stacker2r,
cashUnits.stacker3f,
cashUnits.stacker3r,
machineContext.deviceId machineContext.deviceId
]) ])
return t.batch([q1, q2, q3]) return t.batch([q1, q2, q3, q4])
}) })
} }
function getBatches (from = new Date(0).toISOString(), until = new Date().toISOString()) { function getBatches (from = new Date(0).toISOString(), until = new Date().toISOString()) {
const sql = ` const sql = `
SELECT cb.id, cb.device_id, cb.created, cb.operation_type, cb.bill_count_override, cb.performed_by, json_agg(b.*) AS bills SELECT cuo.id, cuo.device_id, cuo.created, cuo.operation_type, cuo.bill_count_override, cuo.performed_by, json_agg(bi.*) AS bills
FROM cashbox_batches AS cb FROM cash_unit_operation AS cuo
LEFT JOIN bills AS b ON cb.id = b.cashbox_batch_id LEFT JOIN (
WHERE cb.created >= $1 AND cb.created <= $2 SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, cit.device_id AS device_id FROM bills b LEFT OUTER JOIN (SELECT id, device_id FROM cash_in_txs) AS cit ON cit.id = b.cash_in_txs_id UNION
GROUP BY cb.id SELECT id, fiat, fiat_code, created, cashbox_batch_id, device_id FROM empty_unit_bills
ORDER BY cb.created DESC ) AS bi ON cuo.id = bi.cashbox_batch_id
WHERE cuo.created >= $1 AND cuo.created <= $2 AND cuo.operation_type = 'cash-box-empty'
GROUP BY cuo.id
ORDER BY cuo.created DESC
` `
return db.any(sql, [from, until]).then(res => _.map(it => _.mapKeys(ite => _.camelCase(ite), it), res))
return db.any(sql, [from, until]).then(camelize)
} }
function editBatchById (id, performedBy) { function editBatchById (id, performedBy) {
const sql = `UPDATE cashbox_batches SET performed_by=$1 WHERE id=$2` const sql = `UPDATE cash_unit_operation SET performed_by=$1 WHERE id=$2 AND cuo.operation_type = 'cash-box-empty'`
return db.none(sql, [performedBy, id]) return db.none(sql, [performedBy, id])
} }
function getBillsByBatchId (id) { function getBillsByBatchId (id) {
const sql = `SELECT * FROM bills WHERE cashbox_batch_id=$1` const sql = `SELECT bi.* FROM (
SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, cit.device_id AS device_id FROM bills b LEFT OUTER JOIN (SELECT id, device_id FROM cash_in_txs) AS cit ON cit.id = b.cash_in_txs_id UNION
SELECT id, fiat, fiat_code, created, cashbox_batch_id, device_id FROM empty_unit_bills
) AS bi WHERE bi.cashbox_batch_id=$1`
return db.any(sql, [id]) return db.any(sql, [id])
} }

View file

@ -13,7 +13,29 @@ const anonymousCustomer = {
name: 'anonymous' 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_MINIMUM_AMOUNT_OF_CASSETTES = 2
const CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES = 4 const CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES = 4
@ -39,7 +61,7 @@ const BALANCE_FETCH_SPEED_MULTIPLIER = {
module.exports = { module.exports = {
anonymousCustomer, anonymousCustomer,
CASSETTE_MAX_CAPACITY, CASH_UNIT_CAPACITY,
AUTHENTICATOR_ISSUER_ENTITY, AUTHENTICATOR_ISSUER_ENTITY,
AUTH_TOKEN_EXPIRATION_TIME, AUTH_TOKEN_EXPIRATION_TIME,
REGISTRATION_TOKEN_EXPIRATION_TIME, REGISTRATION_TOKEN_EXPIRATION_TIME,

View file

@ -5,6 +5,7 @@ const plugins = require('../plugins')
const configManager = require('../new-config-manager') const configManager = require('../new-config-manager')
const { batchGetCustomInfoRequest, getCustomInfoRequests } = require('../new-admin/services/customInfoRequests') const { batchGetCustomInfoRequest, getCustomInfoRequests } = require('../new-admin/services/customInfoRequests')
const state = require('../middlewares/state') const state = require('../middlewares/state')
const { getMachine } = require('../machine-loader')
const VERSION = require('../../package.json').version const VERSION = require('../../package.json').version
@ -114,6 +115,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
configManager.getOperatorInfo(settings.config), configManager.getOperatorInfo(settings.config),
configManager.getReceipt(settings.config), configManager.getReceipt(settings.config),
!!configManager.getCashOut(deviceId, settings.config).active, !!configManager.getCashOut(deviceId, settings.config).active,
getMachine(deviceId, currentConfigVersion),
]) ])
.then(([ .then(([
enablePaperWalletOnly, enablePaperWalletOnly,
@ -124,6 +126,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
operatorInfo, operatorInfo,
receiptInfo, receiptInfo,
twoWayMode, twoWayMode,
{ numberOfCassettes, numberOfStackers },
]) => ]) =>
(currentConfigVersion && currentConfigVersion >= staticConf.configVersion) ? (currentConfigVersion && currentConfigVersion >= staticConf.configVersion) ?
null : null :
@ -138,7 +141,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
languages: localeInfo.languages, languages: localeInfo.languages,
fiatCode: localeInfo.fiatCurrency fiatCode: localeInfo.fiatCurrency
}, },
machineInfo: { deviceId, deviceName }, machineInfo: { deviceId, deviceName, numberOfCassettes, numberOfStackers },
twoWayMode, twoWayMode,
speedtestFiles, speedtestFiles,
urlsToPing, urlsToPing,
@ -167,13 +170,25 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
)(cassettes) : )(cassettes) :
null 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) state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids)
return _.flow( const res = _.flow(
_.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'coins', 'rates']), _.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'stackers', 'coins', 'rates']),
_.update('cassettes', massageCassettes), _.update('cassettes', massageCassettes),
_.update('stackers', massageStackers),
/* [{ cryptoCode, rates }, ...] => [[cryptoCode, rates], ...] */ /* [{ cryptoCode, rates }, ...] => [[cryptoCode, rates], ...] */
_.update('coins', _.map(({ cryptoCode, rates }) => [cryptoCode, rates])), _.update('coins', _.map(({ cryptoCode, rates }) => [cryptoCode, rates])),
@ -185,9 +200,10 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
/* Group the separate objects by cryptoCode */ /* Group the separate objects by cryptoCode */
/* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */ /* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */
({ areThereAvailablePromoCodes, balances, cassettes, coins, rates }) => ({ ({ areThereAvailablePromoCodes, balances, cassettes, stackers, coins, rates }) => ({
areThereAvailablePromoCodes, areThereAvailablePromoCodes,
cassettes, cassettes,
stackers,
coins: _.flow( coins: _.flow(
_.reduce( _.reduce(
(ret, [cryptoCode, obj]) => _.update(cryptoCode, _.assign(obj), ret), (ret, [cryptoCode, obj]) => _.update(cryptoCode, _.assign(obj), ret),
@ -209,7 +225,20 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
_.set('reboot', !!pid && state.reboots?.[operatorId]?.[deviceId] === pid), _.set('reboot', !!pid && state.reboots?.[operatorId]?.[deviceId] === pid),
_.set('shutdown', !!pid && state.shutdowns?.[operatorId]?.[deviceId] === pid), _.set('shutdown', !!pid && state.shutdowns?.[operatorId]?.[deviceId] === pid),
_.set('restartServices', !!pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid), _.set('restartServices', !!pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid),
_.set('emptyUnit', !!pid && state.emptyUnit?.[operatorId]?.[deviceId] === pid),
_.set('refillUnit', !!pid && state.refillUnit?.[operatorId]?.[deviceId] === pid),
)(pq) )(pq)
// Clean up the state middleware and prevent commands from being issued more than once
if (!_.isNil(state.emptyUnit?.[operatorId]?.[deviceId])) {
delete state.emptyUnit?.[operatorId]?.[deviceId]
}
if (!_.isNil(state.refillUnit?.[operatorId]?.[deviceId])) {
delete state.refillUnit?.[operatorId]?.[deviceId]
}
return res
} }

View file

@ -30,6 +30,8 @@ type OperatorInfo {
type MachineInfo { type MachineInfo {
deviceId: String! deviceId: String!
deviceName: String deviceName: String
numberOfCassettes: Int
numberOfStackers: Int
} }
type ReceiptInfo { type ReceiptInfo {
@ -165,6 +167,13 @@ type DynamicCoinValues {
} }
type PhysicalCassette { type PhysicalCassette {
name: String!
denomination: Int!
count: Int!
}
type PhysicalStacker {
name: String!
denomination: Int! denomination: Int!
count: Int! count: Int!
} }
@ -174,13 +183,21 @@ type Cassettes {
virtual: [Int!]! virtual: [Int!]!
} }
type Stackers {
physical: [PhysicalStacker!]!
virtual: [Int!]!
}
type DynamicConfig { type DynamicConfig {
areThereAvailablePromoCodes: Boolean! areThereAvailablePromoCodes: Boolean!
cassettes: Cassettes cassettes: Cassettes
stackers: Stackers
coins: [DynamicCoinValues!]! coins: [DynamicCoinValues!]!
reboot: Boolean! reboot: Boolean!
shutdown: Boolean! shutdown: Boolean!
restartServices: Boolean! restartServices: Boolean!
emptyUnit: Boolean!
refillUnit: Boolean!
} }
type Configs { type Configs {

View file

@ -1,6 +1,5 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const pgp = require('pg-promise')() const pgp = require('pg-promise')()
const axios = require('axios')
const uuid = require('uuid') const uuid = require('uuid')
const batching = require('./cashbox-batches') const batching = require('./cashbox-batches')
@ -13,6 +12,7 @@ const settingsLoader = require('./new-settings-loader')
const notifierUtils = require('./notifier/utils') const notifierUtils = require('./notifier/utils')
const notifierQueries = require('./notifier/queries') const notifierQueries = require('./notifier/queries')
const { ApolloError } = require('apollo-server-errors'); const { ApolloError } = require('apollo-server-errors');
const { loadLatestConfig } = require('./new-settings-loader')
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' } const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' } const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
@ -21,12 +21,21 @@ const stuckStatus = { label: 'Stuck', type: 'error' }
function toMachineObject (r) { function toMachineObject (r) {
return { return {
deviceId: r.device_id, deviceId: r.device_id,
cashbox: r.cashbox, cashUnits: {
cassette1: r.cassette1, cashbox: r.cashbox,
cassette2: r.cassette2, cassette1: r.cassette1,
cassette3: r.cassette3, cassette2: r.cassette2,
cassette4: r.cassette4, cassette3: r.cassette3,
cassette4: r.cassette4,
stacker1f: r.stacker1f,
stacker1r: r.stacker1r,
stacker2f: r.stacker2f,
stacker2r: r.stacker2r,
stacker3f: r.stacker3f,
stacker3r: r.stacker3r
},
numberOfCassettes: r.number_of_cassettes, numberOfCassettes: r.number_of_cassettes,
numberOfStackers: r.number_of_stackers,
version: r.version, version: r.version,
model: r.model, model: r.model,
pairedAt: new Date(r.created), pairedAt: new Date(r.created),
@ -141,8 +150,9 @@ function renameMachine (rec) {
function resetCashOutBills (rec) { function resetCashOutBills (rec) {
const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId }) const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId })
const sql = `UPDATE devices SET cassette1=$1, cassette2=$2, cassette3=$3, cassette4=$4 WHERE device_id=$5;` const { cassette1, cassette2, cassette3, cassette4, stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r } = rec.cashUnits
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.cassettes[2], rec.cassettes[3], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance')) const sql = `UPDATE devices SET cassette1=$1, cassette2=$2, cassette3=$3, cassette4=$4, stacker1f=$5, stacker1r=$6, stacker2f=$7, stacker2r=$8, stacker3f=$9, stacker3r=$10 WHERE device_id=$11;`
return db.none(sql, [cassette1, cassette2, cassette3, cassette4, stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r, rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance'))
} }
function emptyCashInBills (rec) { function emptyCashInBills (rec) {
@ -151,17 +161,138 @@ function emptyCashInBills (rec) {
} }
function setCassetteBills (rec) { function setCassetteBills (rec) {
const { cashbox, cassette1, cassette2, cassette3, cassette4, stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r } = rec.cashUnits
return db.oneOrNone(`SELECT cashbox FROM devices WHERE device_id=$1 LIMIT 1`, [rec.deviceId]) return db.oneOrNone(`SELECT cashbox FROM devices WHERE device_id=$1 LIMIT 1`, [rec.deviceId])
.then(oldCashboxValue => { .then(oldCashboxValue => {
if (_.isNil(oldCashboxValue) || rec.cashbox === oldCashboxValue.cashbox) { if (_.isNil(oldCashboxValue) || cashbox === oldCashboxValue.cashbox) {
const sql = 'UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5 WHERE device_id=$6' const sql = 'UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5, stacker1f=$6, stacker1r=$7, stacker2f=$8, stacker2r=$9, stacker3f=$10, stacker3r=$11 WHERE device_id=$12'
return db.none(sql, [rec.cashbox, rec.cassettes[0], rec.cassettes[1], rec.cassettes[2], rec.cassettes[3], rec.deviceId]) return db.none(sql, [cashbox, cassette1, cassette2, cassette3, cassette4, stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r, rec.deviceId])
} }
return batching.updateMachineWithBatch({ ...rec, oldCashboxValue }) return batching.updateMachineWithBatch({ ...rec, oldCashboxValue })
}) })
} }
function emptyMachineUnits ({ deviceId, newUnits, fiatCode }) {
return loadLatestConfig()
.then(config => Promise.all([getMachine(deviceId), configManager.getCashOut(deviceId, config)]))
.then(([machine, cashoutSettings]) => {
const movedBills = _.reduce(
(acc, value) => ({
...acc,
[value]: {
operationName: `cash-${_.replace(/(cassette|stacker)/g, '$1-')(value)}-empty`,
delta: newUnits[value] - machine.cashUnits[value],
denomination: value !== 'cashbox' ? cashoutSettings[value] : null
}
}),
{},
_.keys(newUnits)
)
const operationNames = _.mapValues(it => it.operationName)(_.filter(it => Math.abs(it.delta) > 0)(_.omit(['cashbox'], movedBills)))
const operationsToCreate = _.map(it => ({
id: uuid.v4(),
device_id: deviceId,
operation_type: it
}))(operationNames)
const billArr = _.reduce(
(acc, value) => {
const unit = movedBills[value]
return _.concat(acc, _.times(() => ({
id: uuid.v4(),
fiat: unit.denomination,
fiat_code: fiatCode,
device_id: deviceId
// TODO: Uncomment this if we decide to keep track of bills across multiple operations. For now, we'll just create the emptying operations for each unit affected, but not relate these events with individual bills and just use the field for the cashbox batch event
// cash_unit_operation_id: _.find(it => it.operation_type === `cash-${_.replace(/(cassette|stacker)/g, '$1-')(value)}-empty`, operationsToCreate).id
}), Math.abs(unit.delta)))
},
[],
_.keys(_.omit(['cashbox'], movedBills))
)
// This occurs when an empty unit is called when the units are already empty, hence, no bills moved around
if (_.isEmpty(billArr) && _.isEmpty(operationsToCreate)) {
return Promise.resolve()
}
return db.tx(t => {
const q1Cols = ['id', 'device_id', 'operation_type']
const q1= t.none(pgp.helpers.insert(operationsToCreate, q1Cols, 'cash_unit_operation'))
const q2Cols = ['id', 'fiat', 'fiat_code', 'device_id']
const q2 = t.none(pgp.helpers.insert(billArr, q2Cols, 'empty_unit_bills'))
const q3 = t.none(`UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5, stacker1f=$6, stacker1r=$7, stacker2f=$8, stacker2r=$9, stacker3f=$10, stacker3r=$11 WHERE device_id=$12`, [
_.defaultTo(machine.cashUnits.cashbox, newUnits.cashbox),
_.defaultTo(machine.cashUnits.cassette1, newUnits.cassette1),
_.defaultTo(machine.cashUnits.cassette2, newUnits.cassette2),
_.defaultTo(machine.cashUnits.cassette3, newUnits.cassette3),
_.defaultTo(machine.cashUnits.cassette4, newUnits.cassette4),
_.defaultTo(machine.cashUnits.stacker1f, newUnits.stacker1f),
_.defaultTo(machine.cashUnits.stacker1r, newUnits.stacker1r),
_.defaultTo(machine.cashUnits.stacker2f, newUnits.stacker2f),
_.defaultTo(machine.cashUnits.stacker2r, newUnits.stacker2r),
_.defaultTo(machine.cashUnits.stacker3f, newUnits.stacker3f),
_.defaultTo(machine.cashUnits.stacker3r, newUnits.stacker3r),
deviceId
])
return t.batch([q1, q2, q3])
})
})
}
function refillMachineUnits ({ deviceId, newUnits }) {
return getMachine(deviceId)
.then(machine => {
const movedBills = _.reduce(
(acc, value) => ({
...acc,
[value]: {
operationName: `cash-${_.replace(/(stacker)/g, '$1-')(value)}-refill`,
delta: newUnits[value] - machine.cashUnits[value]
}
}),
{},
_.keys(newUnits)
)
const operationNames = _.mapValues(it => it.operationName)(_.filter(it => Math.abs(it.delta) > 0)(_.omit(['cassette1', 'cassette2', 'cassette3', 'cassette4'], movedBills)))
const operationsToCreate = _.map(it => ({
id: uuid.v4(),
device_id: deviceId,
operation_type: it
}))(operationNames)
// This occurs when a refill unit is called when the loading boxes are empty, hence, no bills moved around
if (_.isEmpty(operationsToCreate)) {
return Promise.resolve()
}
return db.tx(t => {
const q1Cols = ['id', 'device_id', 'operation_type']
const q1= t.none(pgp.helpers.insert(operationsToCreate, q1Cols, 'cash_unit_operation'))
const q2 = t.none(`UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5, stacker1f=$6, stacker1r=$7, stacker2f=$8, stacker2r=$9, stacker3f=$10, stacker3r=$11 WHERE device_id=$12`, [
_.defaultTo(machine.cashUnits.cashbox, newUnits.cashbox),
_.defaultTo(machine.cashUnits.cassette1, newUnits.cassette1),
_.defaultTo(machine.cashUnits.cassette2, newUnits.cassette2),
_.defaultTo(machine.cashUnits.cassette3, newUnits.cassette3),
_.defaultTo(machine.cashUnits.cassette4, newUnits.cassette4),
_.defaultTo(machine.cashUnits.stacker1f, newUnits.stacker1f),
_.defaultTo(machine.cashUnits.stacker1r, newUnits.stacker1r),
_.defaultTo(machine.cashUnits.stacker2f, newUnits.stacker2f),
_.defaultTo(machine.cashUnits.stacker2r, newUnits.stacker2r),
_.defaultTo(machine.cashUnits.stacker3f, newUnits.stacker3f),
_.defaultTo(machine.cashUnits.stacker3r, newUnits.stacker3r),
deviceId
])
return t.batch([q1, q2])
})
})
}
function unpair (rec) { function unpair (rec) {
return pairing.unpair(rec.deviceId) return pairing.unpair(rec.deviceId)
} }
@ -193,6 +324,24 @@ function restartServices (rec) {
)]) )])
} }
function emptyUnit (rec) {
return db.none('NOTIFY $1:name, $2', ['machineAction', JSON.stringify(
{
action: 'emptyUnit',
value: _.pick(['deviceId', 'operatorId', 'action'], rec)
}
)])
}
function refillUnit (rec) {
return db.none('NOTIFY $1:name, $2', ['machineAction', JSON.stringify(
{
action: 'refillUnit',
value: _.pick(['deviceId', 'operatorId', 'action'], rec)
}
)])
}
function setMachine (rec, operatorId) { function setMachine (rec, operatorId) {
rec.operatorId = operatorId rec.operatorId = operatorId
switch (rec.action) { switch (rec.action) {
@ -204,6 +353,8 @@ function setMachine (rec, operatorId) {
case 'reboot': return reboot(rec) case 'reboot': return reboot(rec)
case 'shutdown': return shutdown(rec) case 'shutdown': return shutdown(rec)
case 'restartServices': return restartServices(rec) case 'restartServices': return restartServices(rec)
case 'emptyUnit': return emptyUnit(rec)
case 'refillUnit': return refillUnit(rec)
default: throw new Error('No such action: ' + rec.action) default: throw new Error('No such action: ' + rec.action)
} }
} }
@ -277,5 +428,7 @@ module.exports = {
updateNetworkHeartbeat, updateNetworkHeartbeat,
getNetworkPerformance, getNetworkPerformance,
getNetworkHeartbeat, getNetworkHeartbeat,
getConfig getConfig,
emptyMachineUnits,
refillMachineUnits
} }

View file

@ -37,6 +37,14 @@ function machineAction (type, value) {
logger.debug(`Restarting services of machine '${deviceId}' from operator ${operatorId}`) logger.debug(`Restarting services of machine '${deviceId}' from operator ${operatorId}`)
state.restartServicesMap[operatorId] = { [deviceId]: pid } state.restartServicesMap[operatorId] = { [deviceId]: pid }
break break
case 'emptyUnit':
logger.debug(`Emptying units from machine '${deviceId}' from operator ${operatorId}`)
state.emptyUnit[operatorId] = { [deviceId]: pid }
break
case 'refillUnit':
logger.debug(`Refilling stackers from machine '${deviceId}' from operator ${operatorId}`)
state.refillUnit[operatorId] = { [deviceId]: pid }
break
default: default:
break break
} }

View file

@ -15,6 +15,8 @@ module.exports = (function () {
reboots: {}, reboots: {},
shutdowns: {}, shutdowns: {},
restartServicesMap: {}, restartServicesMap: {},
emptyUnit: {},
refillUnit: {},
mnemonic: null mnemonic: null
} }
}()) }())

View file

@ -19,8 +19,8 @@ const resolvers = {
unpairedMachines: () => machineLoader.getUnpairedMachines() unpairedMachines: () => machineLoader.getUnpairedMachines()
}, },
Mutation: { Mutation: {
machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context]) => machineAction: (...[, { deviceId, action, cashUnits, newName }, context]) =>
machineAction({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context) machineAction({ deviceId, action, cashUnits, newName }, context)
} }
} }

View file

@ -4,9 +4,10 @@ const typeDef = gql`
type Bill { type Bill {
id: ID id: ID
fiat: Int fiat: Int
fiatCode: String
deviceId: ID deviceId: ID
created: Date created: Date
cashboxBatchId: ID cashUnitOperationId: ID
} }
type Query { type Query {

View file

@ -14,12 +14,9 @@ const typeDef = gql`
pairedAt: Date pairedAt: Date
version: String version: String
model: String model: String
cashbox: Int cashUnits: CashUnits
cassette1: Int
cassette2: Int
cassette3: Int
cassette4: Int
numberOfCassettes: Int numberOfCassettes: Int
numberOfStackers: Int
statuses: [MachineStatus] statuses: [MachineStatus]
latestEvent: MachineEvent latestEvent: MachineEvent
downloadSpeed: String downloadSpeed: String
@ -27,6 +24,34 @@ const typeDef = gql`
packetLoss: String packetLoss: String
} }
type CashUnits {
cashbox: Int
cassette1: Int
cassette2: Int
cassette3: Int
cassette4: Int
stacker1f: Int
stacker1r: Int
stacker2f: Int
stacker2r: Int
stacker3f: Int
stacker3r: Int
}
input CashUnitsInput {
cashbox: Int
cassette1: Int
cassette2: Int
cassette3: Int
cassette4: Int
stacker1f: Int
stacker1r: Int
stacker2f: Int
stacker2r: Int
stacker3f: Int
stacker3r: Int
}
type UnpairedMachine { type UnpairedMachine {
id: ID! id: ID!
deviceId: ID! deviceId: ID!
@ -55,6 +80,8 @@ const typeDef = gql`
reboot reboot
shutdown shutdown
restartServices restartServices
emptyUnit
refillUnit
} }
type Query { type Query {
@ -64,7 +91,7 @@ const typeDef = gql`
} }
type Mutation { type Mutation {
machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, cassette3: Int, cassette4: Int, newName: String): Machine @auth machineAction(deviceId:ID!, action: MachineAction!, cashUnits: CashUnitsInput, newName: String): Machine @auth
} }
` `

View file

@ -18,10 +18,12 @@ const getBills = filters => {
const sql = `SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, cit.device_id AS device_id FROM bills b LEFT OUTER JOIN ( const sql = `SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, cit.device_id AS device_id FROM bills b LEFT OUTER JOIN (
SELECT id, device_id FROM cash_in_txs ${deviceStatement} SELECT id, device_id FROM cash_in_txs ${deviceStatement}
) AS cit ON cit.id = b.cash_in_txs_id ${batchStatement(filters.batch)}` ) AS cit ON cit.id = b.cash_in_txs_id ${batchStatement(filters.batch)} ${_.isNil(batchStatement(filters.batch)) ? `WHERE` : `AND`} b.destination_unit = 'cashbox'`
return db.any(sql) const sql2 = `SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, b.device_id FROM empty_unit_bills b ${deviceStatement} ${!_.isNil(filters.deviceId) && !_.isNil(filters.batch) ? `AND ${_.replace('WHERE', '', batchStatement(filters.batch))}` : `${batchStatement(filters.batch)}`}`
.then(res => _.map(_.mapKeys(_.camelCase), res))
return Promise.all([db.any(sql), db.any(sql2)])
.then(([bills, operationalBills]) => _.map(_.mapKeys(_.camelCase), _.concat(bills, operationalBills)))
} }
module.exports = { module.exports = {

View file

@ -6,14 +6,14 @@ function getMachine (machineId) {
.then(machines => machines.find(({ deviceId }) => deviceId === machineId)) .then(machines => machines.find(({ deviceId }) => deviceId === machineId))
} }
function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context) { function machineAction ({ deviceId, action, cashUnits, newName }, context) {
const operatorId = context.res.locals.operatorId const operatorId = context.res.locals.operatorId
return getMachine(deviceId) return getMachine(deviceId)
.then(machine => { .then(machine => {
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId }) if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
return machine return machine
}) })
.then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName }, operatorId)) .then(machineLoader.setMachine({ deviceId, action, cashUnits, newName }, operatorId))
.then(getMachine(deviceId)) .then(getMachine(deviceId))
} }

View file

@ -153,9 +153,11 @@ function advancedBatch (data) {
'cryptoCode', 'fiat', 'fiatCode', 'fee', 'status', 'fiatProfit', 'cryptoAmount', 'cryptoCode', 'fiat', 'fiatCode', 'fee', 'status', 'fiatProfit', 'cryptoAmount',
'dispense', 'notified', 'redeem', 'phone', 'error', 'dispense', 'notified', 'redeem', 'phone', 'error',
'created', 'confirmedAt', 'hdIndex', 'swept', 'timedout', 'created', 'confirmedAt', 'hdIndex', 'swept', 'timedout',
'dispenseConfirmed', 'provisioned1', 'provisioned2', 'dispenseConfirmed', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4',
'denomination1', 'denomination2', 'errorCode', 'customerId', 'provisioned1f', 'provisioned1r', 'provisioned2f', 'provisioned2r', 'provisioned3f', 'provisioned3r',
'txVersion', 'publishedAt', 'termsAccepted', 'layer2Address', 'denomination1', 'denomination2', 'denomination3', 'denomination4',
'denomination1f', 'denomination1r', 'denomination2f', 'denomination2r', 'denomination3f', 'denomination3r',
'errorCode', 'customerId', 'txVersion', 'publishedAt', 'termsAccepted', 'layer2Address',
'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms', 'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms',
'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber', 'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber',
'customerIdCardDataExpiration', 'customerIdCardData', 'customerName', 'sendTime', 'customerIdCardDataExpiration', 'customerIdCardData', 'customerName', 'sendTime',

View file

@ -15,6 +15,8 @@ const CODES_DISPLAY = {
HIGH_CRYPTO_BALANCE: 'High Crypto Balance', HIGH_CRYPTO_BALANCE: 'High Crypto Balance',
CASH_BOX_FULL: 'Cash box full', CASH_BOX_FULL: 'Cash box full',
LOW_CASH_OUT: 'Low Cash-out', LOW_CASH_OUT: 'Low Cash-out',
LOW_RECYCLER_STACKER: 'Low Recycler Stacker',
HIGH_RECYCLER_STACKER: 'High Recycler Stacker',
CASHBOX_REMOVED: 'Cashbox removed' CASHBOX_REMOVED: 'Cashbox removed'
} }

View file

@ -10,6 +10,7 @@ const CA_PATH = process.env.CA_PATH
// A machine on an older version (no multicassette code) could be paired with a server with multicassette code. // A machine on an older version (no multicassette code) could be paired with a server with multicassette code.
// This makes sure that the server stores a default value // This makes sure that the server stores a default value
const DEFAULT_NUMBER_OF_CASSETTES = 2 const DEFAULT_NUMBER_OF_CASSETTES = 2
const DEFAULT_NUMBER_OF_STACKERS = 0
function pullToken (token) { function pullToken (token) {
const sql = `delete from pairing_tokens const sql = `delete from pairing_tokens
@ -36,16 +37,16 @@ function unpair (deviceId) {
) )
} }
function pair (token, deviceId, machineModel, numOfCassettes = DEFAULT_NUMBER_OF_CASSETTES) { function pair (token, deviceId, machineModel, numOfCassettes = DEFAULT_NUMBER_OF_CASSETTES, numOfStackers = DEFAULT_NUMBER_OF_STACKERS) {
return pullToken(token) return pullToken(token)
.then(r => { .then(r => {
if (r.expired) return false if (r.expired) return false
const insertSql = `insert into devices (device_id, name, number_of_cassettes) values ($1, $2, $3) const insertSql = `insert into devices (device_id, name, number_of_cassettes, number_of_stackers) values ($1, $2, $3, $4)
on conflict (device_id) on conflict (device_id)
do update set paired=TRUE, display=TRUE` do update set paired=TRUE, display=TRUE`
return db.none(insertSql, [deviceId, r.name, numOfCassettes]) return db.none(insertSql, [deviceId, r.name, numOfCassettes, numOfStackers])
.then(() => true) .then(() => true)
}) })
.catch(err => { .catch(err => {

View file

@ -27,7 +27,7 @@ const loyalty = require('./loyalty')
const transactionBatching = require('./tx-batching') const transactionBatching = require('./tx-batching')
const state = require('./middlewares/state') 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') const notifier = require('./notifier')
@ -116,7 +116,7 @@ function plugins (settings, deviceId) {
const sumTxs = (sum, tx) => { const sumTxs = (sum, tx) => {
// cash-out-helper sends 0 as fallback value, need to filter it out as there are no '0' denominations // 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 bills = _.filter(it => _.includes('cassette', it.name) && it.denomination > 0, tx.bills)
const sameDenominations = a => a[0]?.denomination === a[1]?.denomination const sameDenominations = a => a[0]?.denomination === a[1]?.denomination
const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills)) const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills))
@ -139,6 +139,7 @@ function plugins (settings, deviceId) {
const computedCassettes = [] const computedCassettes = []
_.forEach(it => { _.forEach(it => {
computedCassettes.push({ computedCassettes.push({
name: cassettes[it].name,
denomination: cassettes[it].denomination, denomination: cassettes[it].denomination,
count: counts[it] count: counts[it]
}) })
@ -147,37 +148,75 @@ function plugins (settings, deviceId) {
return computedCassettes 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 => _.includes('stacker', it.name) && 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({
name: stackers[it].name,
denomination: stackers[it].denomination,
count: counts[it]
})
}, _.times(_.identity(), _.size(stackers)))
return computedStackers
}
function buildAvailableCassettes (excludeTxId) { function buildAvailableCassettes (excludeTxId) {
const cashOutConfig = configManager.getCashOut(deviceId, settings.config) const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
if (!cashOutConfig.active) return Promise.resolve() if (!cashOutConfig.active) return Promise.resolve()
return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)]) return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
.then(([rec, _redeemableTxs]) => { .then(([_cassettes, _redeemableTxs]) => {
const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs) const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs)
const denominations = [] const denominations = []
_.forEach(it => { _.forEach(it => {
denominations.push(cashOutConfig[`cassette${it + 1}`]) denominations.push(cashOutConfig[`cassette${it + 1}`])
}, _.times(_.identity(), rec.numberOfCassettes)) }, _.times(_.identity(), _cassettes.numberOfCassettes))
const virtualCassettes = [Math.max(...denominations) * 2] const virtualCassettes = [Math.max(...denominations) * 2]
const counts = argv.cassettes const counts = argv.cassettes
? argv.cassettes.split(',') ? 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!') throw new Error('Denominations and respective counts do not match!')
} }
const cassettes = [] const cassettes = []
_.forEach(it => { _.forEach(it => {
cassettes.push({ cassettes.push({
name: `cassette${it + 1}`,
denomination: parseInt(denominations[it], 10), denomination: parseInt(denominations[it], 10),
count: parseInt(counts[it], 10) count: parseInt(counts[it], 10)
}) })
}, _.times(_.identity(), rec.numberOfCassettes)) }, _.times(_.identity(), _cassettes.numberOfCassettes))
try { try {
return { return {
@ -194,6 +233,62 @@ 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(..._.flatten(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({
name: `stacker${it + 1}f`,
denomination: parseInt(denominations[it][0], 10),
count: parseInt(counts[it][0], 10)
})
stackers.push({
name: `stacker${it + 1}r`,
denomination: parseInt(denominations[it][1], 10),
count: parseInt(counts[it][1], 10)
})
}, _.times(_.identity(), _stackers.numberOfStackers))
try {
return {
stackers: computeAvailableStackers(stackers, redeemableTxs),
virtualStackers
}
} catch (err) {
logger.error(err)
return {
stackers,
virtualStackers
}
}
})
}
function buildAvailableUnits (excludeTxId) {
return Promise.all([buildAvailableCassettes(excludeTxId), buildAvailableStackers(excludeTxId)])
.then(([cassettes, stackers]) => ({ cassettes: cassettes.cassettes, stackers: stackers.stackers }))
}
function fetchCurrentConfigVersion () { function fetchCurrentConfigVersion () {
const sql = `select id from user_config const sql = `select id from user_config
where type=$1 where type=$1
@ -240,6 +335,7 @@ function plugins (settings, deviceId) {
return Promise.all([ return Promise.all([
buildAvailableCassettes(), buildAvailableCassettes(),
buildAvailableStackers(),
fetchCurrentConfigVersion(), fetchCurrentConfigVersion(),
millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone)), millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone)),
loyalty.getNumberOfAvailablePromoCodes(), loyalty.getNumberOfAvailablePromoCodes(),
@ -250,6 +346,7 @@ function plugins (settings, deviceId) {
]) ])
.then(([ .then(([
cassettes, cassettes,
stackers,
configVersion, configVersion,
timezone, timezone,
numberOfAvailablePromoCodes, numberOfAvailablePromoCodes,
@ -273,6 +370,7 @@ function plugins (settings, deviceId) {
return { return {
cassettes, cassettes,
stackers,
rates: buildRates(tickers), rates: buildRates(tickers),
balances: buildBalances(balances), balances: buildBalances(balances),
coins, coins,
@ -645,71 +743,164 @@ function plugins (settings, deviceId) {
const denomination2 = cashOutConfig.cassette2 const denomination2 = cashOutConfig.cassette2
const denomination3 = cashOutConfig.cassette3 const denomination3 = cashOutConfig.cassette3
const denomination4 = cashOutConfig.cassette4 const denomination4 = cashOutConfig.cassette4
const denomination1f = cashOutConfig.stacker1f
const denomination1r = cashOutConfig.stacker1r
const denomination2f = cashOutConfig.stacker2f
const denomination2r = cashOutConfig.stacker2r
const denomination3f = cashOutConfig.stacker3f
const denomination3r = cashOutConfig.stacker3r
const cashOutEnabled = cashOutConfig.active 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) const notifications = configManager.getNotifications(null, device.deviceId, settings.config)
const machineName = device.name const machineName = device.name
const cashInAlert = device.cashbox > notifications.cashInAlertThreshold const cashInAlert = device.cashUnits.cashbox > notifications.cashInAlertThreshold
? { ? {
code: 'CASH_BOX_FULL', code: 'CASH_BOX_FULL',
machineName, machineName,
deviceId: device.deviceId, deviceId: device.deviceId,
notes: device.cashbox notes: device.cashUnits.cashbox
} }
: null : null
const cassette1Alert = device.numberOfCassettes >= 1 && isCassetteLow(device.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', code: 'LOW_CASH_OUT',
cassette: 1, cassette: 1,
machineName, machineName,
deviceId: device.deviceId, deviceId: device.deviceId,
notes: device.cassette1, notes: device.cashUnits.cassette1,
denomination: denomination1, denomination: denomination1,
fiatCode fiatCode
} }
: null : null
const cassette2Alert = device.numberOfCassettes >= 2 && isCassetteLow(device.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', code: 'LOW_CASH_OUT',
cassette: 2, cassette: 2,
machineName, machineName,
deviceId: device.deviceId, deviceId: device.deviceId,
notes: device.cassette2, notes: device.cashUnits.cassette2,
denomination: denomination2, denomination: denomination2,
fiatCode fiatCode
} }
: null : null
const cassette3Alert = device.numberOfCassettes >= 3 && isCassetteLow(device.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', code: 'LOW_CASH_OUT',
cassette: 3, cassette: 3,
machineName, machineName,
deviceId: device.deviceId, deviceId: device.deviceId,
notes: device.cassette3, notes: device.cashUnits.cassette3,
denomination: denomination3, denomination: denomination3,
fiatCode fiatCode
} }
: null : null
const cassette4Alert = device.numberOfCassettes >= 4 && isCassetteLow(device.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', code: 'LOW_CASH_OUT',
cassette: 4, cassette: 4,
machineName, machineName,
deviceId: device.deviceId, deviceId: device.deviceId,
notes: device.cassette4, notes: device.cashUnits.cassette4,
denomination: denomination4, denomination: denomination4,
fiatCode fiatCode
} }
: null : null
return _.compact([cashInAlert, cassette1Alert, cassette2Alert, cassette3Alert, cassette4Alert]) const stacker1fAlert = device.numberOfStackers >= 1 && isUnitLow(device.cashUnits.stacker1f, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker1f)
? {
code: 'LOW_RECYCLER_STACKER',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker1f,
denomination: denomination1f,
fiatCode
}
: null
const stacker1rAlert = device.numberOfStackers >= 1 && isUnitLow(device.cashUnits.stacker1r, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker1r)
? {
code: 'LOW_RECYCLER_STACKER',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker1r,
denomination: denomination1r,
fiatCode
}
: null
const stacker2fAlert = device.numberOfStackers >= 2 && isUnitLow(device.cashUnits.stacker2f, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker2f)
? {
code: 'LOW_RECYCLER_STACKER',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker2f,
denomination: denomination2f,
fiatCode
}
: null
const stacker2rAlert = device.numberOfStackers >= 2 && isUnitLow(device.cashUnits.stacker2r, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker2r)
? {
code: 'LOW_RECYCLER_STACKER',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker2r,
denomination: denomination2r,
fiatCode
}
: null
const stacker3fAlert = device.numberOfStackers >= 3 && isUnitLow(device.cashUnits.stacker3f, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker3f)
? {
code: 'LOW_RECYCLER_STACKER',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker3f,
denomination: denomination3f,
fiatCode
}
: null
const stacker3rAlert = device.numberOfStackers >= 3 && isUnitLow(device.cashUnits.stacker3r, CASH_UNIT_CAPACITY[device.model]['stacker'], notifications.fillingPercentageStacker3r)
? {
code: 'LOW_RECYCLER_STACKER',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker3r,
denomination: denomination3r,
fiatCode
}
: null
return _.compact([
cashInAlert,
cassette1Alert,
cassette2Alert,
cassette3Alert,
cassette4Alert,
stacker1fAlert,
stacker1rAlert,
stacker2fAlert,
stacker2rAlert,
stacker3fAlert,
stacker3rAlert
])
} }
function checkCryptoBalances (fiatCode, devices) { function checkCryptoBalances (fiatCode, devices) {
@ -874,7 +1065,6 @@ function plugins (settings, deviceId) {
sendMessage, sendMessage,
checkBalances, checkBalances,
getMachineNames, getMachineNames,
buildAvailableCassettes,
buy, buy,
sell, sell,
getNotificationConfig, getNotificationConfig,
@ -885,7 +1075,8 @@ function plugins (settings, deviceId) {
isValidWalletScore, isValidWalletScore,
getTransactionHash, getTransactionHash,
getInputAddresses, getInputAddresses,
isWalletScoringEnabled isWalletScoringEnabled,
buildAvailableUnits
} }
} }

View file

@ -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 // Note: since we only prune on insert, we'll always have
// last known state. // last known state.
exports.machineEvent = function machineEvent (rec) { exports.machineEvent = function machineEvent (rec) {

View file

@ -16,6 +16,7 @@ const populateDeviceId = require('./middlewares/populateDeviceId')
const populateSettings = require('./middlewares/populateSettings') const populateSettings = require('./middlewares/populateSettings')
const recordPing = require('./middlewares/recordPing') const recordPing = require('./middlewares/recordPing')
const unitsRoutes = require('./routes/unitsRoutes')
const cashboxRoutes = require('./routes/cashboxRoutes') const cashboxRoutes = require('./routes/cashboxRoutes')
const customerRoutes = require('./routes/customerRoutes') const customerRoutes = require('./routes/customerRoutes')
const logsRoutes = require('./routes/logsRoutes') const logsRoutes = require('./routes/logsRoutes')
@ -82,6 +83,7 @@ app.use('/customer', customerRoutes)
app.use('/tx', txRoutes) app.use('/tx', txRoutes)
app.use('/logs', logsRoutes) app.use('/logs', logsRoutes)
app.use('/units', unitsRoutes)
graphQLServer.applyMiddleware({ app }) graphQLServer.applyMiddleware({ app })

View file

@ -26,7 +26,7 @@ function notifyCashboxRemoval (req, res, next) {
} }
logger.info('** DEBUG ** - Cashbox removal - Cashbox reset is set to automatic. A cashbox batch WILL be created') logger.info('** DEBUG ** - Cashbox removal - Cashbox reset is set to automatic. A cashbox batch WILL be created')
logger.info('** DEBUG ** - Cashbox removal - Creating new batch...') logger.info('** DEBUG ** - Cashbox removal - Creating new batch...')
return cashbox.createCashboxBatch(req.deviceId, machine.cashbox) return cashbox.createCashboxBatch(req.deviceId, machine.cashUnits.cashbox)
.then(() => { .then(() => {
logger.info(`** DEBUG ** - Cashbox removal - Finished creating the new cashbox batch`) logger.info(`** DEBUG ** - Cashbox removal - Finished creating the new cashbox batch`)
logger.info(`** DEBUG ** - Cashbox removal - Resetting the cashbox counter on device ${req.deviceId}`) logger.info(`** DEBUG ** - Cashbox removal - Resetting the cashbox counter on device ${req.deviceId}`)

View file

@ -11,8 +11,9 @@ function pair (req, res, next) {
const deviceId = req.deviceId const deviceId = req.deviceId
const model = req.query.model const model = req.query.model
const numOfCassettes = req.query.numOfCassettes const numOfCassettes = req.query.numOfCassettes
const numOfStackers = req.query.numOfStackers
return pairing.pair(token, deviceId, model, numOfCassettes) return pairing.pair(token, deviceId, model, numOfCassettes, numOfStackers)
.then(isValid => { .then(isValid => {
if (isValid) return res.json({ status: 'paired' }) if (isValid) return res.json({ status: 'paired' })
throw httpError('Pairing failed') throw httpError('Pairing failed')

View file

@ -96,6 +96,8 @@ function poll (req, res, next) {
const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid
const shutdown = pid && state.shutdowns?.[operatorId]?.[deviceId] === pid const shutdown = pid && state.shutdowns?.[operatorId]?.[deviceId] === pid
const restartServices = pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid const restartServices = pid && state.restartServicesMap?.[operatorId]?.[deviceId] === pid
const emptyUnit = pid && state.emptyUnit?.[operatorId]?.[deviceId] === pid
const refillUnit = pid && state.refillUnit?.[operatorId]?.[deviceId] === pid
const langs = localeConfig.languages const langs = localeConfig.languages
const locale = { const locale = {
@ -119,6 +121,8 @@ function poll (req, res, next) {
reboot, reboot,
shutdown, shutdown,
restartServices, restartServices,
emptyUnit,
refillUnit,
hasLightning, hasLightning,
receipt, receipt,
operatorInfo, operatorInfo,

26
lib/routes/unitsRoutes.js Normal file
View file

@ -0,0 +1,26 @@
const express = require('express')
const { emptyMachineUnits, refillMachineUnits } = require('../machine-loader')
const router = express.Router()
const emptyUnitUpdateCounts = (req, res, next) => {
const deviceId = req.deviceId
const { units: newUnits, fiatCode } = req.body
return emptyMachineUnits({ deviceId, newUnits: newUnits, fiatCode })
.then(() => res.status(200).send({ status: 'OK' }))
.catch(next)
}
const refillUnitUpdateCounts = (req, res, next) => {
const deviceId = req.deviceId
const { units: newUnits } = req.body
return refillMachineUnits({ deviceId, newUnits: newUnits })
.then(() => res.status(200).send({ status: 'OK' }))
.catch(next)
}
router.post('/empty', emptyUnitUpdateCounts)
router.post('/refill', refillUnitUpdateCounts)
module.exports = router

9
lib/utils.js Normal file
View file

@ -0,0 +1,9 @@
const _ = require('lodash')
const camelize = obj =>
_.transform(obj, (acc, value, key, target) => {
const camelKey = _.isArray(target) ? key : _.camelCase(key.toString())
acc[camelKey] = _.isObject(value) && !(value instanceof Date) ? camelize(value) : value
})
module.exports = camelize

View file

@ -1,6 +1,6 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader') const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
const { CASSETTE_MAX_CAPACITY } = require('../lib/constants') const CASSETTE_MAX_CAPACITY = 500
exports.up = function (next) { exports.up = function (next) {
return loadLatest() return loadLatest()

View file

@ -0,0 +1,117 @@
const db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE cash_out_actions
ADD COLUMN provisioned_1f INTEGER,
ADD COLUMN provisioned_1r INTEGER,
ADD COLUMN provisioned_2f INTEGER,
ADD COLUMN provisioned_2r INTEGER,
ADD COLUMN provisioned_3f INTEGER,
ADD COLUMN provisioned_3r INTEGER,
ADD COLUMN dispensed_1f INTEGER,
ADD COLUMN dispensed_1r INTEGER,
ADD COLUMN dispensed_2f INTEGER,
ADD COLUMN dispensed_2r INTEGER,
ADD COLUMN dispensed_3f INTEGER,
ADD COLUMN dispensed_3r INTEGER,
ADD COLUMN rejected_1f INTEGER,
ADD COLUMN rejected_1r INTEGER,
ADD COLUMN rejected_2f INTEGER,
ADD COLUMN rejected_2r INTEGER,
ADD COLUMN rejected_3f INTEGER,
ADD COLUMN rejected_3r INTEGER,
ADD COLUMN denomination_1f INTEGER,
ADD COLUMN denomination_1r INTEGER,
ADD COLUMN denomination_2f INTEGER,
ADD COLUMN denomination_2r INTEGER,
ADD COLUMN denomination_3f INTEGER,
ADD COLUMN denomination_3r INTEGER`,
`ALTER TABLE devices
ADD COLUMN stacker1f INTEGER NOT NULL DEFAULT 0,
ADD COLUMN stacker1r INTEGER NOT NULL DEFAULT 0,
ADD COLUMN stacker2f INTEGER NOT NULL DEFAULT 0,
ADD COLUMN stacker2r INTEGER NOT NULL DEFAULT 0,
ADD COLUMN stacker3f INTEGER NOT NULL DEFAULT 0,
ADD COLUMN stacker3r INTEGER NOT NULL DEFAULT 0,
ADD COLUMN number_of_stackers INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE cash_out_txs
ADD COLUMN provisioned_1f INTEGER,
ADD COLUMN provisioned_1r INTEGER,
ADD COLUMN provisioned_2f INTEGER,
ADD COLUMN provisioned_2r INTEGER,
ADD COLUMN provisioned_3f INTEGER,
ADD COLUMN provisioned_3r INTEGER,
ADD COLUMN denomination_1f INTEGER,
ADD COLUMN denomination_1r INTEGER,
ADD COLUMN denomination_2f INTEGER,
ADD COLUMN denomination_2r INTEGER,
ADD COLUMN denomination_3f INTEGER,
ADD COLUMN denomination_3r INTEGER`,
`CREATE TYPE cash_unit AS ENUM (
'cashbox',
'cassette1',
'cassette2',
'cassette3',
'cassette4',
'stacker1f',
'stacker1r',
'stacker2f',
'stacker2r',
'stacker3f',
'stacker3r'
)`,
`ALTER TABLE bills ADD COLUMN destination_unit cash_unit NOT NULL DEFAULT 'cashbox'`,
`CREATE TYPE cash_unit_operation_type AS ENUM(
'cash-box-empty',
'cash-box-refill',
'cash-cassette-1-refill',
'cash-cassette-1-empty',
'cash-cassette-1-count-change',
'cash-cassette-2-refill',
'cash-cassette-2-empty',
'cash-cassette-2-count-change',
'cash-cassette-3-refill',
'cash-cassette-3-empty',
'cash-cassette-3-count-change',
'cash-cassette-4-refill',
'cash-cassette-4-empty',
'cash-cassette-4-count-change',
'cash-stacker-1f-refill',
'cash-stacker-1f-empty',
'cash-stacker-1f-count-change',
'cash-stacker-1r-refill',
'cash-stacker-1r-empty',
'cash-stacker-1r-count-change',
'cash-stacker-2f-refill',
'cash-stacker-2f-empty',
'cash-stacker-2f-count-change',
'cash-stacker-2r-refill',
'cash-stacker-2r-empty',
'cash-stacker-2r-count-change',
'cash-stacker-3f-refill',
'cash-stacker-3f-empty',
'cash-stacker-3f-count-change',
'cash-stacker-3r-refill',
'cash-stacker-3r-empty',
'cash-stacker-3r-count-change'
)`,
`ALTER TABLE cashbox_batches ALTER COLUMN operation_type TYPE cash_unit_operation_type USING operation_type::text::cash_unit_operation_type`,
`ALTER TABLE cashbox_batches RENAME TO cash_unit_operation`,
`DROP TYPE cashbox_batch_type`,
`CREATE TABLE empty_unit_bills (
id UUID PRIMARY KEY,
fiat INTEGER NOT NULL,
fiat_code TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
device_id TEXT REFERENCES devices (device_id),
cashbox_batch_id UUID REFERENCES cash_unit_operation (id)
)`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -120,6 +120,7 @@ const ActionCol = ({ disabled, editing }) => {
const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => { const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
const { const {
name, name,
names,
bypassField, bypassField,
input, input,
editable = true, editable = true,
@ -128,6 +129,8 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
width, width,
textAlign, textAlign,
editingAlign = textAlign, editingAlign = textAlign,
prefix,
PrefixComponent = Label2,
suffix, suffix,
SuffixComponent = Label2, SuffixComponent = Label2,
textStyle = it => {}, textStyle = it => {},
@ -136,6 +139,8 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
inputProps = {} inputProps = {}
} = config } = config
const fields = names ?? [name]
const { values } = useFormikContext() const { values } = useFormikContext()
const isEditable = editable => { const isEditable = editable => {
if (typeof editable === 'function') return editable(values) if (typeof editable === 'function') return editable(values)
@ -159,36 +164,48 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
} }
return ( return (
<Td <div className={classes.fields}>
className={{ {R.map(f => (
[classes.extraPaddingRight]: extraPaddingRight, <Td
[classes.extraPadding]: extraPadding, className={{
[classes.withSuffix]: suffix [classes.extraPaddingRight]: extraPaddingRight,
}} [classes.extraPadding]: extraPadding,
width={width} [classes.withSuffix]: suffix,
size={size} [classes.withPrefix]: prefix
bold={bold} }}
textAlign={textAlign}> width={width}
{isEditing && isField && !isHidden(values) && ( size={size}
<Field name={name} component={input} {...innerProps} /> bold={bold}
)} textAlign={textAlign}>
{isEditing && !isField && !isHidden(values) && ( {prefix && !isHidden(values) && (
<config.input name={name} /> <PrefixComponent
)} className={classes.prefix}
{!isEditing && values && !isHidden(values) && ( style={isEditing ? {} : textStyle(values, isEditing)}>
<div style={textStyle(values, isEditing)}> {typeof prefix === 'function' ? prefix(f) : prefix}
{view(values[name], values)} </PrefixComponent>
</div> )}
)} {isEditing && isField && !isHidden(values) && (
{suffix && !isHidden(values) && ( <Field name={f} component={input} {...innerProps} />
<SuffixComponent )}
className={classes.suffix} {isEditing && !isField && !isHidden(values) && (
style={isEditing ? {} : textStyle(values, isEditing)}> <config.input name={f} />
{suffix} )}
</SuffixComponent> {!isEditing && values && !isHidden(values) && (
)} <div style={textStyle(values, isEditing)}>
{isHidden(values) && <StripesSvg />} {view(values[f], values)}
</Td> </div>
)}
{suffix && !isHidden(values) && (
<SuffixComponent
className={classes.suffix}
style={isEditing ? {} : textStyle(values, isEditing)}>
{suffix}
</SuffixComponent>
)}
{isHidden(values) && <StripesSvg />}
</Td>
))(fields)}
</div>
) )
} }

View file

@ -25,6 +25,21 @@ export default {
suffix: { suffix: {
margin: [[0, 0, 0, 7]] margin: [[0, 0, 0, 7]]
}, },
withPrefix: ({ textAlign }) => {
const justifyContent = textAlign === 'right' ? 'flex-end' : textAlign
return {
display: 'flex',
alignItems: 'center',
justifyContent
}
},
prefix: {
margin: [[0, 7, 0, 0]]
},
size: ({ size }) => bySize(size), size: ({ size }) => bySize(size),
bold bold,
fields: {
display: 'flex',
flexDirection: 'column'
}
} }

View file

@ -15,6 +15,7 @@ const Cashbox = ({
percent = 0, percent = 0,
cashOut = false, cashOut = false,
width, width,
height,
className, className,
emptyPartClassName, emptyPartClassName,
labelClassName, labelClassName,
@ -27,6 +28,7 @@ const Cashbox = ({
percent, percent,
cashOut, cashOut,
width, width,
height,
applyColorVariant, applyColorVariant,
isLow isLow
}) })
@ -55,35 +57,17 @@ const Cashbox = ({
// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte // https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte
// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box // Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box
const CashIn = ({ currency, notes, total }) => { const CashIn = ({
const classes = gridClasses()
return (
<>
<div className={classes.row}>
<div>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{total} {currency.code}
</Label1>
</div>
</div>
</div>
</>
)
}
const CashOut = ({
capacity = 500, capacity = 500,
denomination = 0,
currency, currency,
notes, notes,
className, className,
editingMode = false, editingMode = false,
threshold, threshold,
width width,
height,
total,
omitInnerPercentage
}) => { }) => {
const percent = (100 * notes) / capacity const percent = (100 * notes) / capacity
const isLow = percent < threshold const isLow = percent < threshold
@ -98,6 +82,54 @@ const CashOut = ({
cashOut cashOut
isLow={isLow} isLow={isLow}
width={width} width={width}
height={height}
omitInnerPercentage={omitInnerPercentage}
/>
</div>
{!editingMode && (
<div className={classes.col2}>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{total} {currency.code}
</Label1>
</div>
</div>
)}
</div>
</>
)
}
const CashOut = ({
capacity = 500,
denomination = 0,
currency,
notes,
className,
editingMode = false,
threshold,
width,
height,
omitInnerPercentage
}) => {
const percent = (100 * notes) / capacity
const isLow = percent < threshold
const classes = gridClasses()
return (
<>
<div className={classes.row}>
<div className={classes.col}>
<Cashbox
className={className}
percent={percent}
cashOut
isLow={isLow}
width={width}
height={height}
omitInnerPercentage={omitInnerPercentage}
/> />
</div> </div>
{!editingMode && ( {!editingMode && (
@ -121,4 +153,30 @@ const CashOut = ({
) )
} }
export { Cashbox, CashIn, CashOut } const CashOutLite = ({
capacity = 500,
denomination = 0,
currency,
notes,
threshold,
width
}) => {
const percent = (100 * notes) / capacity
const isLow = percent < threshold
const classes = gridClasses()
return (
<div className={classes.col}>
<Cashbox
percent={percent}
cashOut
isLow={isLow}
width={width}
height={15}
omitInnerPercentage
/>
<Chip label={`${denomination} ${currency.code}`} />
</div>
)
}
export { Cashbox, CashIn, CashOut, CashOutLite }

View file

@ -21,7 +21,7 @@ const cashboxStyles = {
cashbox: { cashbox: {
borderColor: colorPicker, borderColor: colorPicker,
backgroundColor: colorPicker, backgroundColor: colorPicker,
height: 118, height: ({ height }) => height ?? 118,
width: ({ width }) => width ?? 80, width: ({ width }) => width ?? 80,
border: '2px solid', border: '2px solid',
textAlign: 'end', textAlign: 'end',
@ -58,7 +58,13 @@ const cashboxStyles = {
const gridStyles = { const gridStyles = {
row: { row: {
display: 'flex' display: 'flex',
alignItems: 'center'
},
col: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}, },
innerRow: { innerRow: {
display: 'flex', display: 'flex',

View file

@ -185,6 +185,42 @@ const MachineActions = memo(({ machine, onActionSuccess }) => {
}}> }}>
Restart Services Restart Services
</ActionButton> </ActionButton>
{machine.model === 'aveiro' && (
<ActionButton
color="primary"
className={classes.inlineChip}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
setAction({
command: 'emptyUnit',
display: 'Empty',
message:
"Triggering this action will move all cash inside the machine towards its cashbox (if possible), allowing for the collection of cash from the machine via only its cashbox. Depending on how full the cash units are, it's possible that this action will need to be used more than once to ensure that the unit is left completely empty."
})
}}>
Empty Unit
</ActionButton>
)}
{machine.model === 'aveiro' && (
<ActionButton
color="primary"
className={classes.inlineChip}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
setAction({
command: 'refillUnit',
display: 'Refill',
message:
'Triggering this action will refill the stackers in this machine, by using bills present in its cassettes. This action may require manual operation of the cassettes and close attention to make sure that the denominations in the cassettes match the denominations in the stackers.'
})
}}>
Refill Unit
</ActionButton>
)}
</div> </div>
<ConfirmDialog <ConfirmDialog
disabled={disabled} disabled={disabled}

View file

@ -81,7 +81,7 @@ const Row = ({
)} )}
</Tr> </Tr>
</div> </div>
{expandable && expanded && ( {expanded && (
<div className={classes.after}> <div className={classes.after}>
<Tr className={classnames({ [classes.expanded]: expanded })}> <Tr className={classnames({ [classes.expanded]: expanded })}>
<Td width={width}> <Td width={width}>
@ -99,6 +99,7 @@ const DataTable = ({
data = [], data = [],
Details, Details,
className, className,
tableClassName,
expandable, expandable,
initialExpanded, initialExpanded,
onClick, onClick,
@ -169,7 +170,7 @@ const DataTable = ({
return ( return (
<Box display="flex" flex="1" flexDirection="column"> <Box display="flex" flex="1" flexDirection="column">
<Table className={classes.table}> <Table className={classnames(classes.table, tableClassName)}>
<THead> <THead>
{elements.map(({ width, className, textAlign, header }, idx) => ( {elements.map(({ width, className, textAlign, header }, idx) => (
<Th <Th

View file

@ -40,12 +40,21 @@ const GET_INFO = gql`
machines { machines {
name name
deviceId deviceId
cashbox cashUnits {
cassette1 cashbox
cassette2 cassette1
cassette3 cassette2
cassette4 cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
numberOfCassettes numberOfCassettes
numberOfStackers
} }
config config
} }
@ -113,9 +122,9 @@ const CashOut = ({ name: SCREEN_KEY }) => {
data={config} data={config}
stripeWhen={wasNeverEnabled} stripeWhen={wasNeverEnabled}
enableEdit enableEdit
editWidth={134} editWidth={95}
enableToggle enableToggle
toggleWidth={109} toggleWidth={100}
onToggle={onToggle} onToggle={onToggle}
save={save} save={save}
error={error?.message} error={error?.message}

View file

@ -17,7 +17,8 @@ const MODAL_WIDTH = 554
const MODAL_HEIGHT = 520 const MODAL_HEIGHT = 520
const Wizard = ({ machine, locale, onClose, save, error }) => { const Wizard = ({ machine, locale, onClose, save, error }) => {
const LAST_STEP = machine.numberOfCassettes + 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({ const [{ step, config }, setState] = useState({
step: 0, step: 0,
config: { active: true } config: { active: true }
@ -46,37 +47,105 @@ const Wizard = ({ machine, locale, onClose, save, error }) => {
}) })
} }
const steps = R.map( const steps = R.concat(
it => ({ R.map(
type: `cassette${it}`, it => ({
display: `Cassette ${it}`, type: `cassette${it}`,
component: Autocomplete, display: `Cassette ${it}`,
inputProps: { component: Autocomplete,
options: options, inputProps: {
labelProp: 'display', options: options,
valueProp: 'code' labelProp: 'display',
} valueProp: 'code'
}), }
R.range(1, machine.numberOfCassettes + 1) }),
R.range(1, machine.numberOfCassettes + 1)
),
R.chain(
it => [
{
type: `stacker${it}f`,
display: `Stacker ${it}F`,
component: Autocomplete,
inputProps: {
options: options,
labelProp: 'display',
valueProp: 'code'
}
},
{
type: `stacker${it}r`,
display: `Stacker ${it}R`,
component: Autocomplete,
inputProps: {
options: options,
labelProp: 'display',
valueProp: 'code'
}
}
],
R.range(1, machine.numberOfStackers + 1)
)
) )
const schema = () => const schema = () =>
Yup.object().shape({ Yup.object().shape({
cassette1: Yup.number().required(), cassette1:
machine.numberOfCassettes >= 1 && step >= 1
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
cassette2: cassette2:
machine.numberOfCassettes > 1 && step >= 2 machine.numberOfCassettes >= 2 && step >= 2
? Yup.number().required() ? Yup.number().required()
: Yup.number() : Yup.number()
.transform(transformNumber) .transform(transformNumber)
.nullable(), .nullable(),
cassette3: cassette3:
machine.numberOfCassettes > 2 && step >= 3 machine.numberOfCassettes >= 3 && step >= 3
? Yup.number().required() ? Yup.number().required()
: Yup.number() : Yup.number()
.transform(transformNumber) .transform(transformNumber)
.nullable(), .nullable(),
cassette4: 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().required()
: Yup.number() : Yup.number()
.transform(transformNumber) .transform(transformNumber)

View file

@ -22,6 +22,9 @@ import styles from './WizardStep.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const getCassetesArtworks = () => ({ const getCassetesArtworks = () => ({
1: {
1: cassetteOne
},
2: { 2: {
1: cassetteOne, 1: cassetteOne,
2: cassetteTwo 2: cassetteTwo

View file

@ -8,10 +8,13 @@ import { getBillOptions } from 'src/utils/bill-options'
import { CURRENCY_MAX } from 'src/utils/constants' import { CURRENCY_MAX } from 'src/utils/constants'
import { transformNumber } from 'src/utils/number' import { transformNumber } from 'src/utils/number'
const widthsByNumberOfCassettes = { const widthsByNumberOfUnits = {
2: { machine: 300, cassette: 225, zeroConf: 200 }, 2: { machine: 325, cassette: 340 },
3: { machine: 210, cassette: 180, zeroConf: 200 }, 3: { machine: 300, cassette: 235 },
4: { machine: 200, cassette: 150, zeroConf: 150 } 4: { machine: 205, cassette: 200 },
5: { machine: 180, cassette: 165 },
6: { machine: 165, cassette: 140 },
7: { machine: 130, cassette: 125 }
} }
const DenominationsSchema = Yup.object().shape({ const DenominationsSchema = Yup.object().shape({
@ -22,9 +25,10 @@ const DenominationsSchema = Yup.object().shape({
.max(CURRENCY_MAX), .max(CURRENCY_MAX),
cassette2: Yup.number() cassette2: Yup.number()
.label('Cassette 2') .label('Cassette 2')
.required()
.min(1) .min(1)
.max(CURRENCY_MAX), .max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
cassette3: Yup.number() cassette3: Yup.number()
.label('Cassette 3') .label('Cassette 3')
.min(1) .min(1)
@ -36,6 +40,42 @@ const DenominationsSchema = Yup.object().shape({
.min(1) .min(1)
.max(CURRENCY_MAX) .max(CURRENCY_MAX)
.nullable() .nullable()
.transform(transformNumber),
stacker1f: Yup.number()
.label('Stacker 1')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
stacker1r: Yup.number()
.label('Stacker 1')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
stacker2f: Yup.number()
.label('Stacker 2')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
stacker2r: Yup.number()
.label('Stacker 2')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
stacker3f: Yup.number()
.label('Stacker 3')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
stacker3r: Yup.number()
.label('Stacker 3')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber) .transform(transformNumber)
}) })
@ -45,6 +85,11 @@ const getElements = (machines, locale = {}, classes) => {
...R.map(it => it.numberOfCassettes, machines), ...R.map(it => it.numberOfCassettes, machines),
0 0
) )
const maxNumberOfStackers = Math.max(
...R.map(it => it.numberOfStackers, machines),
0
)
const numberOfCashUnits = maxNumberOfCassettes + maxNumberOfStackers
const options = getBillOptions(locale, denominations) const options = getBillOptions(locale, denominations)
const cassetteProps = const cassetteProps =
@ -61,7 +106,7 @@ const getElements = (machines, locale = {}, classes) => {
{ {
name: 'id', name: 'id',
header: 'Machine', header: 'Machine',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine, width: widthsByNumberOfUnits[numberOfCashUnits]?.machine,
view: it => machines.find(({ deviceId }) => deviceId === it).name, view: it => machines.find(({ deviceId }) => deviceId === it).name,
size: 'sm', size: 'sm',
editable: false editable: false
@ -77,13 +122,13 @@ const getElements = (machines, locale = {}, classes) => {
size: 'sm', size: 'sm',
stripe: true, stripe: true,
textAlign: 'right', textAlign: 'right',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette, width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
suffix: fiatCurrency, suffix: fiatCurrency,
bold: bold, bold: bold,
view: it => it, view: it => it,
input: options?.length > 0 ? Autocomplete : NumberInput, input: options?.length > 0 ? Autocomplete : NumberInput,
inputProps: cassetteProps, inputProps: cassetteProps,
doubleHeader: 'Denominations', doubleHeader: 'Denominations of Cassettes & Recyclers',
isHidden: machine => isHidden: machine =>
it > it >
machines.find(({ deviceId }) => deviceId === machine.id) machines.find(({ deviceId }) => deviceId === machine.id)
@ -94,6 +139,32 @@ const getElements = (machines, locale = {}, classes) => {
1 1
) )
R.until(
R.gt(R.__, maxNumberOfStackers),
it => {
elements.push({
names: [`stacker${it}f`, `stacker${it}r`],
header: `Stacker ${it}`,
size: 'sm',
stripe: true,
textAlign: 'right',
width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
prefix: it => (R.last(it) === 'f' ? 'F' : 'R'),
suffix: fiatCurrency,
bold: bold,
input: options?.length > 0 ? Autocomplete : NumberInput,
inputProps: cassetteProps,
doubleHeader: 'Denominations of Cassettes & Recyclers',
isHidden: machine =>
it >
machines.find(({ deviceId }) => deviceId === machine.id)
.numberOfStackers
})
return R.add(1, it)
},
1
)
return elements return elements
} }

View file

@ -147,7 +147,10 @@ const MachinesTable = ({ machines = [], numToRender }) => {
it => it =>
machine.numberOfCassettes >= it ? ( machine.numberOfCassettes >= it ? (
<StyledCell align="left"> <StyledCell align="left">
{makePercentageText(it, machine[`cassette${it}`])} {makePercentageText(
it,
machine.cashUnits[`cassette${it}`]
)}
</StyledCell> </StyledCell>
) : ( ) : (
<StyledCell align="left"> <StyledCell align="left">

View file

@ -24,12 +24,21 @@ const GET_DATA = gql`
machines { machines {
name name
deviceId deviceId
cashbox cashUnits {
cassette1 cashbox
cassette2 cassette1
cassette3 cassette2
cassette4 cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
numberOfCassettes numberOfCassettes
numberOfStackers
statuses { statuses {
label label
type type

View file

@ -3,85 +3,49 @@ import { makeStyles } from '@material-ui/core'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import * as Yup from 'yup'
import { IconButton } from 'src/components/buttons' import DataTable from 'src/components/tables/DataTable'
import { Table as EditableTable } from 'src/components/editableTable' import CashUnitDetails from 'src/pages/Maintenance/CashUnitDetails'
import { CashOut, CashIn } from 'src/components/inputs/cashbox/Cashbox'
import { NumberInput, CashCassetteInput } from 'src/components/inputs/formik'
import Wizard from 'src/pages/Maintenance/Wizard/Wizard' import Wizard from 'src/pages/Maintenance/Wizard/Wizard'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg' import helper from 'src/pages/Maintenance/helper'
import { fromNamespace } from 'src/utils/config' import { fromNamespace } from 'src/utils/config'
import styles from './Cassettes.styles' import styles from './Cassettes.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const widthsByNumberOfCassettes = {
2: { cashbox: 203, cassette: 280, cassetteGraph: 80, editWidth: 87 },
3: { cashbox: 164, cassette: 200, cassetteGraph: 60, editWidth: 87 },
4: { cashbox: 131, cassette: 158, cassetteGraph: 40, editWidth: 87 }
}
const ValidationSchema = Yup.object().shape({
name: Yup.string().required('Required'),
cashbox: Yup.number()
.label('Cash box')
.required()
.integer()
.min(0)
.max(1000),
cassette1: Yup.number()
.required('Required')
.integer()
.min(0)
.max(500),
cassette2: Yup.number()
.required('Required')
.integer()
.min(0)
.max(500),
cassette3: Yup.number()
.required('Required')
.integer()
.min(0)
.max(500),
cassette4: Yup.number()
.required('Required')
.integer()
.min(0)
.max(500)
})
const SET_CASSETTE_BILLS = gql` const SET_CASSETTE_BILLS = gql`
mutation MachineAction( mutation MachineAction(
$deviceId: ID! $deviceId: ID!
$action: MachineAction! $action: MachineAction!
$cashbox: Int! $cashUnits: CashUnitsInput
$cassette1: Int!
$cassette2: Int!
$cassette3: Int!
$cassette4: Int!
) { ) {
machineAction( machineAction(deviceId: $deviceId, action: $action, cashUnits: $cashUnits) {
deviceId: $deviceId
action: $action
cashbox: $cashbox
cassette1: $cassette1
cassette2: $cassette2
cassette3: $cassette3
cassette4: $cassette4
) {
deviceId deviceId
cashbox cashUnits {
cassette1 cashbox
cassette2 cassette1
cassette3 cassette2
cassette4 cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
} }
} }
` `
const widths = {
name: 0,
cashbox: 175,
cassettes: 585,
edit: 90
}
const CashCassettes = ({ machine, config, refetchData, bills }) => { const CashCassettes = ({ machine, config, refetchData, bills }) => {
const classes = useStyles() const classes = useStyles()
@ -89,115 +53,47 @@ const CashCassettes = ({ machine, config, refetchData, bills }) => {
const cashout = config && fromNamespace('cashOut')(config) const cashout = config && fromNamespace('cashOut')(config)
const locale = config && fromNamespace('locale')(config) const locale = config && fromNamespace('locale')(config)
const fillingPercentageSettings =
config && fromNamespace('notifications', config)
const fiatCurrency = locale?.fiatCurrency const fiatCurrency = locale?.fiatCurrency
const numberOfCassettes = machine.numberOfCassettes
const getCashoutSettings = deviceId => fromNamespace(deviceId)(cashout) const getCashoutSettings = deviceId => fromNamespace(deviceId)(cashout)
const isCashOutDisabled = ({ deviceId }) =>
!getCashoutSettings(deviceId).active
const elements = [ const elements = R.filter(it => it.name !== 'name')(
{ helper.getElements(classes, config, bills, setWizard, widths)
name: 'cashbox',
header: 'Cash box',
width: widthsByNumberOfCassettes[numberOfCassettes].cashbox,
stripe: false,
view: value => (
<CashIn
currency={{ code: fiatCurrency }}
notes={value}
total={R.sum(R.map(it => it.fiat)(bills))}
/>
),
input: NumberInput,
inputProps: {
decimalPlaces: 0
}
}
]
R.until(
R.gt(R.__, numberOfCassettes),
it => {
elements.push({
name: `cassette${it}`,
header: `Cash cassette ${it}`,
width: widthsByNumberOfCassettes[numberOfCassettes].cassette,
stripe: true,
doubleHeader: 'Cash-out',
view: value => {
return (
<CashOut
className={classes.cashbox}
denomination={
getCashoutSettings(machine.deviceId)?.[`cassette${it}`]
}
currency={{ code: fiatCurrency }}
notes={value}
width={widthsByNumberOfCassettes[numberOfCassettes].cassetteGraph}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
/>
)
},
isHidden: ({ numberOfCassettes }) => it > numberOfCassettes,
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0,
width: widthsByNumberOfCassettes[numberOfCassettes].cassetteGraph,
inputClassName: classes.cashbox
}
})
return R.add(1, it)
},
1
) )
elements.push({
name: 'edit',
header: 'Edit',
width: widthsByNumberOfCassettes[numberOfCassettes].editWidth,
view: () => {
return (
<IconButton
onClick={() => {
setWizard(true)
}}>
<EditIcon />
</IconButton>
)
}
})
const [setCassetteBills, { error }] = useMutation(SET_CASSETTE_BILLS, { const [setCassetteBills, { error }] = useMutation(SET_CASSETTE_BILLS, {
refetchQueries: () => refetchData() refetchQueries: () => refetchData()
}) })
const onSave = (_, cashbox, cassettes) => const onSave = (_, cashUnits) =>
setCassetteBills({ setCassetteBills({
variables: { variables: {
action: 'setCassetteBills', action: 'setCassetteBills',
deviceId: machine.deviceId, deviceId: machine.deviceId,
cashbox, cashUnits
...cassettes
} }
}) })
const InnerCashUnitDetails = ({ it }) => (
<CashUnitDetails
machine={it}
bills={bills[it.deviceId] ?? []}
currency={fiatCurrency}
config={config}
hideMachineData
widths
/>
)
return machine.name ? ( return machine.name ? (
<> <>
<EditableTable <DataTable
error={error?.message}
editWidth={widthsByNumberOfCassettes[numberOfCassettes].editWidth}
stripeWhen={isCashOutDisabled}
disableRowEdit={isCashOutDisabled}
name="cashboxes"
elements={elements} elements={elements}
data={[machine]} data={[machine]}
save={onSave} Details={InnerCashUnitDetails}
validationSchema={ValidationSchema} emptyText="No machines so far"
initialExpanded={0}
tableClassName={classes.dataTable}
/> />
{wizard && ( {wizard && (
<Wizard <Wizard

View file

@ -1,6 +1,34 @@
import { offDarkColor } from 'src/styling/variables'
const styles = { const styles = {
cashbox: { unitsRow: {
height: 36 display: 'flex',
flexDirection: 'row',
margin: [[10, 0]],
'& > *': {
marginRight: 30
},
'& > *:last-child': {
marginRight: 0
}
},
units: {
display: 'flex',
flexDirection: 'row',
'& > *': {
marginRight: 10
},
'& > *:last-child': {
marginRight: 0
}
},
verticalLine: {
height: '100%',
width: 1,
backgroundColor: offDarkColor
},
dataTable: {
minHeight: 290
} }
} }

View file

@ -29,12 +29,21 @@ const GET_INFO = gql`
pairedAt pairedAt
version version
model model
cashbox cashUnits {
cassette1 cashbox
cassette2 cassette1
cassette3 cassette2
cassette4 cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
numberOfCassettes numberOfCassettes
numberOfStackers
statuses { statuses {
label label
type type
@ -100,7 +109,7 @@ const Machines = ({ data, refetch, reload }) => {
const machine = R.path(['machine'])(data) ?? {} const machine = R.path(['machine'])(data) ?? {}
const config = R.path(['config'])(data) ?? {} const config = R.path(['config'])(data) ?? {}
const bills = R.path(['bills'])(data) ?? [] const bills = R.groupBy(bill => bill.deviceId)(R.path(['bills'])(data) ?? [])
const machineName = R.path(['name'])(machine) ?? null const machineName = R.path(['name'])(machine) ?? null
const machineID = R.path(['deviceId'])(machine) ?? null const machineID = R.path(['deviceId'])(machine) ?? null

View file

@ -1,30 +0,0 @@
import { offColor } from 'src/styling/variables'
export default {
cashbox: {
height: 36
},
tBody: {
maxHeight: '65vh',
overflow: 'auto'
},
tableWidth: {
display: 'flex',
alignItems: 'center',
marginRight: 1
},
descriptions: {
color: offColor,
marginTop: 0
},
cashboxReset: {
color: offColor,
margin: [[13, 0, -5, 20]]
},
selection: {
marginRight: 12
},
downloadLogsButton: {
marginLeft: 13
}
}

View file

@ -0,0 +1,229 @@
import { makeStyles } from '@material-ui/core/styles'
import * as R from 'ramda'
import React from 'react'
import Chip from 'src/components/Chip'
import { CashOut } from 'src/components/inputs'
import { Label1, TL2 } from 'src/components/typography'
import { offDarkColor } from 'src/styling/variables'
import { fromNamespace } from 'src/utils/config'
import { cashUnitCapacity, modelPrettifier } from 'src/utils/machine'
const styles = {
wrapper: {
display: 'flex',
flexDirection: 'row',
marginTop: 12,
marginBottom: 16,
'& > *': {
marginRight: 40
},
'& > *:last-child': {
marginRight: 0
},
minHeight: 120
},
row: {
display: 'flex',
flexDirection: 'row'
},
col: {
display: 'flex',
flexDirection: 'column'
},
machineData: {
display: 'flex',
flexDirection: 'column',
minWidth: 210
},
billList: ({ hideMachineData }) => ({
display: 'flex',
flexDirection: 'column',
minWidth: hideMachineData ? 60 : 160,
'& > span': {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
'& > p': {
minWidth: 30
}
}
}),
unitList: {
display: 'flex',
flexDirection: 'row',
'& > *': {
marginRight: 20
},
'& > *:last-child': {
marginRight: 0
},
marginTop: 10
},
verticalLine: {
height: '100%',
width: 1,
backgroundColor: offDarkColor
},
label: {
marginBottom: 10
},
loadingBoxes: {
display: 'flex',
flexDirection: 'column',
'& > *': {
marginBottom: 20
},
'& > *:last-child': {
marginBottom: 0
}
}
}
const useStyles = makeStyles(styles)
const CashUnitDetails = ({
machine,
bills,
currency,
config,
hideMachineData = false
}) => {
const classes = useStyles({ hideMachineData })
const billCount = R.countBy(it => it.fiat)(bills)
const fillingPercentageSettings = fromNamespace('notifications', config)
const cashout = fromNamespace('cashOut')(config)
const getCashoutSettings = id => fromNamespace(id)(cashout)
return (
<div className={classes.wrapper}>
{!hideMachineData && (
<div className={classes.machineData}>
<Label1>Machine Model</Label1>
<span>{modelPrettifier[machine.model]}</span>
</div>
)}
<div className={classes.billList}>
<Label1>Cash box</Label1>
{R.isEmpty(billCount) && <TL2 noMargin>Empty</TL2>}
{R.map(it => (
<span>
<TL2 noMargin>{billCount[it]}</TL2>
<Chip label={`${it} ${currency}`} />
</span>
))(R.keys(billCount))}
</div>
<div className={classes.unitList}>
{machine.numberOfStackers === 0 &&
R.map(it => (
<>
<div className={classes.col}>
<Label1
noMargin
className={classes.label}>{`Cassette ${it}`}</Label1>
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`cassette${it}`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`cassette${it}`
]
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
capacity={cashUnitCapacity[machine.model].cassette}
/>
</div>
{it !== machine.numberOfCassettes && (
<span className={classes.verticalLine} />
)}
</>
))(R.range(1, machine.numberOfCassettes + 1))}
{machine.numberOfStackers > 0 && (
<>
<div className={classes.col}>
<Label1
noMargin
className={classes.label}>{`Loading boxes`}</Label1>
<div className={classes.loadingBoxes}>
{R.map(it => (
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`cassette${it}`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`cassette${it}`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageCassette${it}`
]
}
capacity={cashUnitCapacity[machine.model].cassette}
/>
))(R.range(1, machine.numberOfCassettes + 1))}
</div>
</div>
<span className={classes.verticalLine} />
{R.map(it => (
<>
<div className={classes.col}>
<Label1
noMargin
className={classes.label}>{`Stacker ${it}`}</Label1>
<div className={classes.loadingBoxes}>
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`stacker${it}f`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`stacker${it}f`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageStacker${it}f`
]
}
capacity={cashUnitCapacity[machine.model].stacker}
/>
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`stacker${it}r`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`stacker${it}r`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageStacker${it}r`
]
}
capacity={cashUnitCapacity[machine.model].stacker}
/>
</div>
</div>
{it !== machine.numberOfStackers && (
<span className={classes.verticalLine} />
)}
</>
))(R.range(1, machine.numberOfStackers + 1))}
</>
)}
</div>
</div>
)
}
export default CashUnitDetails

View file

@ -3,101 +3,52 @@ import { DialogActions, makeStyles, Box } from '@material-ui/core'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import * as Yup from 'yup'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper' import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { IconButton, Button } from 'src/components/buttons' import { IconButton, Button } from 'src/components/buttons'
import { Table as EditableTable } from 'src/components/editableTable'
import { RadioGroup } from 'src/components/inputs' import { RadioGroup } from 'src/components/inputs'
import { CashOut, CashIn } from 'src/components/inputs/cashbox/Cashbox'
import { NumberInput, CashCassetteInput } from 'src/components/inputs/formik'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import { EmptyTable } from 'src/components/table' import { EmptyTable } from 'src/components/table'
import DataTable from 'src/components/tables/DataTable'
import { P, Label1 } from 'src/components/typography' import { P, Label1 } from 'src/components/typography'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg' import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as ReverseHistoryIcon } from 'src/styling/icons/circle buttons/history/white.svg' import { ReactComponent as ReverseHistoryIcon } from 'src/styling/icons/circle buttons/history/white.svg'
import { ReactComponent as HistoryIcon } from 'src/styling/icons/circle buttons/history/zodiac.svg' import { ReactComponent as HistoryIcon } from 'src/styling/icons/circle buttons/history/zodiac.svg'
import { fromNamespace, toNamespace } from 'src/utils/config' import { fromNamespace, toNamespace } from 'src/utils/config'
import { MANUAL, AUTOMATIC } from 'src/utils/constants.js' import { MANUAL, AUTOMATIC } from 'src/utils/constants'
import { onlyFirstToUpper } from 'src/utils/string' import { onlyFirstToUpper } from 'src/utils/string'
import styles from './CashCassettes.styles.js' import CashUnitDetails from './CashUnitDetails'
import CashCassettesFooter from './CashCassettesFooter' import styles from './CashUnits.styles'
import CashCassettesFooter from './CashUnitsFooter'
import CashboxHistory from './CashboxHistory' import CashboxHistory from './CashboxHistory'
import Wizard from './Wizard/Wizard' import Wizard from './Wizard/Wizard'
import helper from './helper'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const widthsByNumberOfCassettes = {
2: {
machine: 250,
cashbox: 260,
cassette: 300,
cassetteGraph: 80,
editWidth: 90
},
3: {
machine: 220,
cashbox: 215,
cassette: 225,
cassetteGraph: 60,
editWidth: 90
},
4: {
machine: 190,
cashbox: 180,
cassette: 185,
cassetteGraph: 50,
editWidth: 90
}
}
const ValidationSchema = Yup.object().shape({
name: Yup.string().required(),
cashbox: Yup.number()
.label('Cash box')
.required()
.integer()
.min(0)
.max(1000),
cassette1: Yup.number()
.label('Cassette 1')
.required()
.integer()
.min(0)
.max(500),
cassette2: Yup.number()
.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)
.max(500)
})
const GET_MACHINES_AND_CONFIG = gql` const GET_MACHINES_AND_CONFIG = gql`
query getData($billFilters: JSONObject) { query getData($billFilters: JSONObject) {
machines { machines {
name name
id: deviceId id: deviceId
cashbox model
cassette1 cashUnits {
cassette2 cashbox
cassette3 cassette1
cassette4 cassette2
cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
numberOfCassettes numberOfCassettes
numberOfStackers
} }
unpairedMachines { unpairedMachines {
id: deviceId id: deviceId
@ -123,27 +74,23 @@ const SET_CASSETTE_BILLS = gql`
mutation MachineAction( mutation MachineAction(
$deviceId: ID! $deviceId: ID!
$action: MachineAction! $action: MachineAction!
$cashbox: Int! $cashUnits: CashUnitsInput
$cassette1: Int!
$cassette2: Int!
$cassette3: Int!
$cassette4: Int!
) { ) {
machineAction( machineAction(deviceId: $deviceId, action: $action, cashUnits: $cashUnits) {
deviceId: $deviceId
action: $action
cashbox: $cashbox
cassette1: $cassette1
cassette2: $cassette2
cassette3: $cassette3
cassette4: $cassette4
) {
deviceId deviceId
cashbox cashUnits {
cassette1 cashbox
cassette2 cassette1
cassette3 cassette2
cassette4 cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
} }
} }
` `
@ -154,6 +101,13 @@ const GET_BATCHES_CSV = gql`
} }
` `
const widths = {
name: 250,
cashbox: 200,
cassettes: 575,
edit: 90
}
const CashCassettes = () => { const CashCassettes = () => {
const classes = useStyles() const classes = useStyles()
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
@ -173,7 +127,6 @@ const CashCassettes = () => {
const machines = R.path(['machines'])(data) ?? [] const machines = R.path(['machines'])(data) ?? []
const unpairedMachines = R.path(['unpairedMachines'])(data) ?? [] const unpairedMachines = R.path(['unpairedMachines'])(data) ?? []
const config = R.path(['config'])(data) ?? {} const config = R.path(['config'])(data) ?? {}
const fillingPercentageSettings = fromNamespace('notifications', config)
const [setCassetteBills, { error }] = useMutation(SET_CASSETTE_BILLS, { const [setCassetteBills, { error }] = useMutation(SET_CASSETTE_BILLS, {
refetchQueries: () => ['getData'] refetchQueries: () => ['getData']
}) })
@ -191,21 +144,15 @@ const CashCassettes = () => {
const cashout = data?.config && fromNamespace('cashOut')(data.config) const cashout = data?.config && fromNamespace('cashOut')(data.config)
const locale = data?.config && fromNamespace('locale')(data.config) const locale = data?.config && fromNamespace('locale')(data.config)
const fiatCurrency = locale?.fiatCurrency const fiatCurrency = locale?.fiatCurrency
const maxNumberOfCassettes = Math.max(
...R.map(it => it.numberOfCassettes, machines),
0
)
const getCashoutSettings = id => fromNamespace(id)(cashout) const getCashoutSettings = id => fromNamespace(id)(cashout)
const isCashOutDisabled = ({ id }) => !getCashoutSettings(id).active
const onSave = (id, cashbox, cassettes) => { const onSave = (id, cashUnits) => {
return setCassetteBills({ return setCassetteBills({
variables: { variables: {
action: 'setCassetteBills', action: 'setCassetteBills',
deviceId: id, deviceId: id,
cashbox, cashUnits
...cassettes
} }
}) })
} }
@ -235,85 +182,23 @@ const CashCassettes = () => {
setSelectedRadio(selectedRadio) setSelectedRadio(selectedRadio)
} }
const elements = [ const elements = helper.getElements(
{ classes,
name: 'name', config,
header: 'Machine', bills,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine, setWizard,
view: name => <>{name}</>, widths,
input: ({ field: { value: name } }) => <>{name}</> setMachineId
},
{
name: 'cashbox',
header: 'Cash box',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cashbox,
view: (value, { id }) => (
<CashIn
currency={{ code: fiatCurrency }}
notes={value}
total={R.sum(R.map(it => it.fiat, bills[id] ?? []))}
/>
),
input: NumberInput,
inputProps: {
decimalPlaces: 0
}
}
]
R.until(
R.gt(R.__, maxNumberOfCassettes),
it => {
elements.push({
name: `cassette${it}`,
header: `Cassette ${it}`,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette,
stripe: true,
doubleHeader: 'Cash-out',
view: (value, { id }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.[`cassette${it}`]}
currency={{ code: fiatCurrency }}
notes={value}
width={
widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
/>
),
isHidden: ({ numberOfCassettes }) => it > numberOfCassettes,
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph,
inputClassName: classes.cashbox
}
})
return R.add(1, it)
},
1
) )
elements.push({ const InnerCashUnitDetails = ({ it }) => (
name: 'edit', <CashUnitDetails
header: 'Edit', machine={it}
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.editWidth, bills={bills[it.id] ?? []}
textAlign: 'center', currency={fiatCurrency}
view: (value, { id }) => { config={config}
return ( />
<IconButton )
onClick={() => {
setMachineId(id)
setWizard(true)
}}>
<EditIcon />
</IconButton>
)
}
})
return ( return (
!dataLoading && ( !dataLoading && (
@ -369,14 +254,13 @@ const CashCassettes = () => {
</TitleSection> </TitleSection>
{!showHistory && ( {!showHistory && (
<> <>
<EditableTable <DataTable
error={error?.message} loading={dataLoading}
name="cashboxes"
stripeWhen={isCashOutDisabled}
elements={elements} elements={elements}
data={machines} data={machines}
validationSchema={ValidationSchema} Details={InnerCashUnitDetails}
tbodyWrapperClass={classes.tBody} emptyText="No machines so far"
expandable
/> />
{data && R.isEmpty(machines) && ( {data && R.isEmpty(machines) && (

View file

@ -0,0 +1,56 @@
import { offColor, offDarkColor } from 'src/styling/variables'
export default {
cashbox: {
height: 36
},
tBody: {
maxHeight: '65vh',
overflow: 'auto'
},
tableWidth: {
display: 'flex',
alignItems: 'center',
marginRight: 1
},
descriptions: {
color: offColor,
marginTop: 0
},
cashboxReset: {
color: offColor,
margin: [[13, 0, -5, 20]]
},
selection: {
marginRight: 12
},
downloadLogsButton: {
marginLeft: 13
},
unitsRow: {
display: 'flex',
flexDirection: 'row',
margin: [[10, 0]],
'& > *': {
marginRight: 30
},
'& > *:last-child': {
marginRight: 0
}
},
units: {
display: 'flex',
flexDirection: 'row',
'& > *': {
marginRight: 10
},
'& > *:last-child': {
marginRight: 0
}
},
verticalLine: {
height: '100%',
width: 1,
backgroundColor: offDarkColor
}
}

View file

@ -9,7 +9,8 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou
import { fromNamespace } from 'src/utils/config' import { fromNamespace } from 'src/utils/config'
import { numberToFiatAmount } from 'src/utils/number.js' import { numberToFiatAmount } from 'src/utils/number.js'
import styles from './CashCassettesFooter.styles.js' import styles from './CashUnitsFooter.styles.js'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const CashCassettesFooter = ({ const CashCassettesFooter = ({
@ -22,9 +23,9 @@ const CashCassettesFooter = ({
const classes = useStyles() const classes = useStyles()
const cashout = config && fromNamespace('cashOut')(config) const cashout = config && fromNamespace('cashOut')(config)
const getCashoutSettings = id => fromNamespace(id)(cashout) const getCashoutSettings = id => fromNamespace(id)(cashout)
const reducerFn = ( const cashoutReducerFn = (
acc, acc,
{ cassette1, cassette2, cassette3, cassette4, id } { cashUnits: { cassette1, cassette2, cassette3, cassette4 }, id }
) => { ) => {
const cassette1Denomination = getCashoutSettings(id).cassette1 ?? 0 const cassette1Denomination = getCashoutSettings(id).cassette1 ?? 0
const cassette2Denomination = getCashoutSettings(id).cassette2 ?? 0 const cassette2Denomination = getCashoutSettings(id).cassette2 ?? 0
@ -38,11 +39,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 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 ( return (
<div className={classes.footerContainer}> <div className={classes.footerContainer}>
@ -62,6 +101,13 @@ const CashCassettesFooter = ({
{numberToFiatAmount(totalInCassettes)} {currencyCode} {numberToFiatAmount(totalInCassettes)} {currencyCode}
</Info1> </Info1>
</div> </div>
<div className={classes.flex}>
<TxOutIcon className={classes.icon} />
<Info2 className={classes.iconLabel}>Recycle:</Info2>
<Info1 className={classes.valueDisplay}>
{numberToFiatAmount(totalInRecyclers)} {currencyCode}
</Info1>
</div>
<div className={classes.flex}> <div className={classes.flex}>
<Info2 className={classes.iconLabel}>Total:</Info2> <Info2 className={classes.iconLabel}>Total:</Info2>
<Info1 className={classes.valueDisplay}> <Info1 className={classes.valueDisplay}>

View file

@ -26,8 +26,7 @@ const GET_BATCHES = gql`
performedBy performedBy
bills { bills {
fiat fiat
deviceId fiatCode
created
} }
} }
} }

View file

@ -25,9 +25,19 @@ const GET_MACHINES = gql`
pairedAt pairedAt
version version
paired paired
cashbox cashUnits {
cassette1 cashbox
cassette2 cassette1
cassette2
cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
version version
model model
statuses { statuses {

View file

@ -4,6 +4,7 @@ import * as Yup from 'yup'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { MAX_NUMBER_OF_CASSETTES } from 'src/utils/constants' import { MAX_NUMBER_OF_CASSETTES } from 'src/utils/constants'
import { cashUnitCapacity, modelPrettifier } from 'src/utils/machine'
import { defaultToZero } from 'src/utils/number' import { defaultToZero } from 'src/utils/number'
import WizardSplash from './WizardSplash' import WizardSplash from './WizardSplash'
@ -11,13 +12,21 @@ import WizardStep from './WizardStep'
const MODAL_WIDTH = 554 const MODAL_WIDTH = 554
const MODAL_HEIGHT = 535 const MODAL_HEIGHT = 535
const CASHBOX_DEFAULT_CAPACITY = 500
const CASSETTE_FIELDS = R.map( const CASSETTE_FIELDS = R.map(
it => `cassette${it}`, it => `cassette${it}`,
R.range(1, MAX_NUMBER_OF_CASSETTES + 1) R.range(1, MAX_NUMBER_OF_CASSETTES + 1)
) )
const STACKER_FIELDS = [
'stacker1f',
'stacker1r',
'stacker2f',
'stacker2r',
'stacker3f',
'stacker3r'
]
const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => { const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
const [{ step, config }, setState] = useState({ const [{ step, config }, setState] = useState({
step: 0, step: 0,
@ -28,7 +37,9 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
R.isEmpty(cashoutSettings) || !cashoutSettings?.active R.isEmpty(cashoutSettings) || !cashoutSettings?.active
const numberOfCassettes = isCashOutDisabled ? 0 : machine.numberOfCassettes const numberOfCassettes = isCashOutDisabled ? 0 : machine.numberOfCassettes
const numberOfStackers = machine.numberOfStackers
// const LAST_STEP = numberOfCassettes + numberOfStackers * 2 + 1
const LAST_STEP = numberOfCassettes + 1 const LAST_STEP = numberOfCassettes + 1
const title = `Update counts` const title = `Update counts`
@ -45,6 +56,18 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
) )
} }
const buildStackerObj = cassetteInput => {
return R.reduce(
(acc, value) => {
acc[value] = machine.cashUnits[value]
// acc[value] = defaultToZero(cassetteInput[value])
return acc
},
{},
STACKER_FIELDS
)
}
const onContinue = it => { const onContinue = it => {
const newConfig = R.merge(config, it) const newConfig = R.merge(config, it)
if (isLastStep) { if (isLastStep) {
@ -53,10 +76,16 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
it?.wasCashboxEmptied it?.wasCashboxEmptied
].includes('YES') ].includes('YES')
const cashbox = wasCashboxEmptied ? 0 : machine?.cashbox
const cassettes = buildCassetteObj(it) const cassettes = buildCassetteObj(it)
const stackers = buildStackerObj(it)
save(machine.id, cashbox, cassettes) const cashUnits = {
cashbox: wasCashboxEmptied ? 0 : machine?.cashUnits.cashbox,
...cassettes,
...stackers
}
save(machine.id, cashUnits)
return onClose() return onClose()
} }
@ -78,12 +107,69 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
.integer() .integer()
.required() .required()
.min(0) .min(0)
.max(CASHBOX_DEFAULT_CAPACITY) .max(
cashUnitCapacity[machine.model].cassette,
`${modelPrettifier[machine.model]} maximum cassette capacity is ${
cashUnitCapacity[machine.model].cassette
} bills`
)
}) })
})) }))
) )
const makeInitialValues = () => // 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(
// i === 1
// ? cashUnitCapacity[machine.model].stacker -
// cashUnitCapacity[machine.model].escrow
// : cashUnitCapacity[machine.model].stacker,
// i === 1
// ? `${
// modelPrettifier[machine.model]
// } maximum stacker capacity for the escrow unit is ${cashUnitCapacity[
// machine.model
// ].stacker - cashUnitCapacity[machine.model].escrow} bills`
// : `${
// 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 makeCassettesInitialValues = () =>
!R.isEmpty(cashoutSettings) !R.isEmpty(cashoutSettings)
? R.reduce( ? R.reduce(
(acc, value) => { (acc, value) => {
@ -95,16 +181,35 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
) )
: {} : {}
const steps = R.prepend( const makeStackersInitialValues = () =>
{ !R.isEmpty(cashoutSettings)
type: 'cashbox', ? R.reduce(
schema: Yup.object().shape({ (acc, value) => {
wasCashboxEmptied: Yup.string().required('Select one option.') acc[`stacker${value}f`] = ''
}), acc[`stacker${value}r`] = ''
cashoutRequired: false return acc
}, },
makeCassetteSteps(numberOfCassettes) {},
) R.range(1, numberOfStackers + 1)
)
: {}
const makeInitialValues = () =>
R.merge(makeCassettesInitialValues(), makeStackersInitialValues())
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 ( return (
<Modal <Modal
@ -122,7 +227,6 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
name={machine?.name} name={machine?.name}
machine={machine} machine={machine}
cashoutSettings={cashoutSettings} cashoutSettings={cashoutSettings}
cassetteCapacity={CASHBOX_DEFAULT_CAPACITY}
error={error} error={error}
lastStep={isLastStep} lastStep={isLastStep}
steps={steps} steps={steps}

View file

@ -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 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 { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { comet, errorColor } from 'src/styling/variables' import { comet, errorColor } from 'src/styling/variables'
import { cashUnitCapacity } from 'src/utils/machine'
import { numberToFiatAmount } from 'src/utils/number' import { numberToFiatAmount } from 'src/utils/number'
import { startCase } from 'src/utils/string'
const styles = { const styles = {
content: { content: {
@ -104,19 +106,41 @@ const styles = {
const useStyles = makeStyles(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], [cassetteOne, cassetteTwo],
[tejo3CassetteOne, tejo3CassetteTwo, tejo3CassetteThree], [tejo3CassetteOne, tejo3CassetteTwo, tejo3CassetteThree],
[tejo4CassetteOne, tejo4CassetteTwo, tejo4CassetteThree, tejo4CassetteFour] [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 = ({ const WizardStep = ({
step, step,
name, name,
machine, machine,
cashoutSettings, cashoutSettings,
cassetteCapacity,
error, error,
lastStep, lastStep,
steps, steps,
@ -133,16 +157,20 @@ const WizardStep = ({
{ display: 'No', code: 'NO' } { display: 'No', code: 'NO' }
] ]
const cassetteField = `cassette${step - 1}`
const numberOfCassettes = machine.numberOfCassettes const numberOfCassettes = machine.numberOfCassettes
const originalCassetteCount = machine?.[cassetteField] const numberOfStackers = machine.numberOfStackers
const cassetteDenomination = cashoutSettings?.[cassetteField] 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 cassetteCount = values => values[cashUnitField] || originalCashUnitCount
const cassetteTotal = values => cassetteCount(values) * cassetteDenomination const cassetteTotal = values => cassetteCount(values) * cashUnitDenomination
const getPercentage = R.pipe( const getPercentage = R.pipe(
cassetteCount, cassetteCount,
count => 100 * (count / cassetteCapacity), count => 100 * (count / cashUnitCapacity[machine.model][cashUnitCategory]),
R.clamp(0, 100) R.clamp(0, 100)
) )
@ -161,7 +189,7 @@ const WizardStep = ({
initialValues={{ wasCashboxEmptied: '' }} initialValues={{ wasCashboxEmptied: '' }}
enableReinitialize enableReinitialize
validationSchema={steps[0].schema}> validationSchema={steps[0].schema}>
{({ values, errors }) => ( {({ errors }) => (
<Form> <Form>
<div <div
className={classnames(classes.horizontalAlign, classes.form)}> className={classnames(classes.horizontalAlign, classes.form)}>
@ -206,7 +234,7 @@ const WizardStep = ({
classes.lineAlignment classes.lineAlignment
)}> )}>
<Info1 noMargin className={classes.cashboxBills}> <Info1 noMargin className={classes.cashboxBills}>
{machine?.cashbox} {machine?.cashUnits.cashbox}
</Info1> </Info1>
<P noMargin>accepted bills</P> <P noMargin>accepted bills</P>
</div> </div>
@ -236,7 +264,11 @@ const WizardStep = ({
<img <img
className={classes.stepImage} className={classes.stepImage}
alt="cassette" alt="cassette"
src={cassetesArtworks(numberOfCassettes, step)}></img> src={cassetesArtworks(
step,
numberOfCassettes,
numberOfStackers
)}></img>
<div className={classes.formWrapper}> <div className={classes.formWrapper}>
<div <div
className={classnames( className={classnames(
@ -257,7 +289,13 @@ const WizardStep = ({
<H4 <H4
className={classes.cassetteFormTitleContent} className={classes.cassetteFormTitleContent}
noMargin> noMargin>
Cash cassette {step - 1} (dispenser) {startCase(cashUnitField)} (
{R.includes('cassette', cashUnitField)
? `dispenser`
: R.includes('stacker', cashUnitField)
? `recycler`
: ``}
)
</H4> </H4>
</div> </div>
<Cashbox <Cashbox
@ -276,13 +314,13 @@ const WizardStep = ({
component={NumberInput} component={NumberInput}
decimalPlaces={0} decimalPlaces={0}
width={50} width={50}
placeholder={originalCassetteCount.toString()} placeholder={originalCashUnitCount.toString()}
name={cassetteField} name={cashUnitField}
className={classes.cashboxBills} className={classes.cashboxBills}
autoFocus autoFocus
/> />
<P> <P>
{cassetteDenomination} {fiatCurrency} bills loaded {cashUnitDenomination} {fiatCurrency} bills loaded
</P> </P>
</div> </div>
<P noMargin className={classes.fiatTotal}> <P noMargin className={classes.fiatTotal}>

View file

@ -0,0 +1,141 @@
import * as R from 'ramda'
import { IconButton } from 'src/components/buttons'
import { CashIn, CashOutLite } from 'src/components/inputs/cashbox/Cashbox'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { fromNamespace } from 'src/utils/config'
import { cashUnitCapacity } from 'src/utils/machine'
const getElements = (
classes,
config,
bills,
setWizard,
widths,
setMachineId
) => {
console.log(config)
const fillingPercentageSettings = fromNamespace('notifications', config)
const locale = fromNamespace('locale')(config)
const cashout = fromNamespace('cashOut')(config)
const fiatCurrency = locale?.fiatCurrency
const getCashoutSettings = id => fromNamespace(id)(cashout)
const elements = [
{
name: 'name',
header: 'Machine',
width: widths.name,
view: m => <>{m.name}</>,
input: ({ field: { value: name } }) => <>{name}</>
},
{
name: 'cashbox',
header: 'Cashbox',
width: widths.cashbox,
view: m => (
<CashIn
currency={{ code: fiatCurrency }}
notes={m.cashUnits.cashbox}
total={R.sum(R.map(it => it.fiat, bills[m.id ?? m.deviceId] ?? []))}
width={25}
height={45}
omitInnerPercentage
className={classes.padding}
/>
),
inputProps: {
decimalPlaces: 0
}
},
{
name: 'cassettes',
header: 'Cassettes & Recyclers',
width: widths.cassettes,
view: m => {
return (
<div className={classes.unitsRow}>
<div className={classes.units}>
{R.map(it => (
<CashOutLite
width={'100%'}
currency={{ code: fiatCurrency }}
notes={m.cashUnits[`cassette${it}`]}
denomination={
getCashoutSettings(m.id ?? m.deviceId)[`cassette${it}`]
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
capacity={cashUnitCapacity[m.model].cassette}
/>
))(R.range(1, m.numberOfCassettes + 1))}
</div>
<div className={classes.units}>
{R.map(it => (
<>
<CashOutLite
width={'100%'}
currency={{ code: fiatCurrency }}
notes={m.cashUnits[`stacker${it}f`]}
denomination={
getCashoutSettings(m.id ?? m.deviceId)[`stacker${it}f`]
}
threshold={
fillingPercentageSettings[
`fillingPercentageStacker${it}f`
]
}
capacity={cashUnitCapacity[m.model].stacker}
/>
<CashOutLite
width={'100%'}
currency={{ code: fiatCurrency }}
notes={m.cashUnits[`stacker${it}r`]}
denomination={
getCashoutSettings(m.id ?? m.deviceId)[`stacker${it}r`]
}
threshold={
fillingPercentageSettings[
`fillingPercentageStacker${it}r`
]
}
capacity={cashUnitCapacity[m.model].stacker}
/>
{it !== m.numberOfStackers && (
<span className={classes.verticalLine} />
)}
</>
))(R.range(1, m.numberOfStackers + 1))}
</div>
</div>
)
},
inputProps: {
decimalPlaces: 0
}
},
{
name: 'edit',
header: 'Edit',
width: widths.edit,
textAlign: 'center',
view: m => {
return (
<IconButton
onClick={() => {
!R.isNil(setMachineId) && setMachineId(m.id ?? m.deviceId)
setWizard(true)
}}>
<EditIcon />
</IconButton>
)
}
}
]
return elements
}
export default { getElements }

View file

@ -28,6 +28,7 @@ const GET_INFO = gql`
name name
deviceId deviceId
numberOfCassettes numberOfCassettes
numberOfStackers
} }
cryptoCurrencies { cryptoCurrencies {
code code

View file

@ -19,7 +19,9 @@ const useStyles = makeStyles(styles)
const CASH_IN_KEY = 'fiatBalanceAlertsCashIn' const CASH_IN_KEY = 'fiatBalanceAlertsCashIn'
const CASH_OUT_KEY = 'fiatBalanceAlertsCashOut' const CASH_OUT_KEY = 'fiatBalanceAlertsCashOut'
const RECYCLER_STACKER_KEY = 'fiatBalanceAlertsRecyclerStacker'
const DEFAULT_NUMBER_OF_CASSETTES = 2 const DEFAULT_NUMBER_OF_CASSETTES = 2
const DEFAULT_NUMBER_OF_STACKERS = 0
const notesMin = 0 const notesMin = 0
const notesMax = 9999999 const notesMax = 9999999
@ -39,6 +41,11 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
DEFAULT_NUMBER_OF_CASSETTES DEFAULT_NUMBER_OF_CASSETTES
) )
const maxNumberOfStackers = Math.max(
...R.map(it => it.numberOfStackers, machines),
DEFAULT_NUMBER_OF_STACKERS
)
const schema = Yup.object().shape({ const schema = Yup.object().shape({
cashInAlertThreshold: Yup.number() cashInAlertThreshold: Yup.number()
.transform(transformNumber) .transform(transformNumber)
@ -160,6 +167,76 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
)} )}
</div> </div>
</Form> </Form>
<Form className={classes.form}>
<PromptWhenDirty />
<Header
title="Cash recycling (stackers)"
editing={isEditing(RECYCLER_STACKER_KEY)}
disabled={isDisabled(RECYCLER_STACKER_KEY)}
setEditing={it => setEditing(RECYCLER_STACKER_KEY, it)}
/>
<div className={classes.wrapper}>
{R.chain(
it => [
<>
<div className={classes.row}>
<Cashbox
labelClassName={classes.cashboxLabel}
emptyPartClassName={classes.cashboxEmptyPart}
percent={
values[`fillingPercentageStacker${it + 1}f`] ??
data[`stacker${it + 1}f`]
}
applyColorVariant
applyFiatBalanceAlertsStyling
omitInnerPercentage
cashOut
/>
<div className={classes.col2}>
<TL2 className={classes.title}>Stacker {it + 1}F</TL2>
<EditableNumber
label="Alert me under"
name={`fillingPercentageStacker${it + 1}f`}
editing={isEditing(RECYCLER_STACKER_KEY)}
displayValue={x => (x === '' ? '-' : x)}
decoration="%"
width={fieldWidth}
/>
</div>
</div>
</>,
<>
<div className={classes.row}>
<Cashbox
labelClassName={classes.cashboxLabel}
emptyPartClassName={classes.cashboxEmptyPart}
percent={
values[`fillingPercentageStacker${it + 1}r`] ??
data[`stacker${it + 1}r`]
}
applyColorVariant
applyFiatBalanceAlertsStyling
omitInnerPercentage
cashOut
/>
<div className={classes.col2}>
<TL2 className={classes.title}>Stacker {it + 1}R</TL2>
<EditableNumber
label="Alert me under"
name={`fillingPercentageStacker${it + 1}r`}
editing={isEditing(RECYCLER_STACKER_KEY)}
displayValue={x => (x === '' ? '-' : x)}
decoration="%"
width={fieldWidth}
/>
</div>
</div>
</>
],
R.times(R.identity, maxNumberOfStackers)
)}
</div>
</Form>
</> </>
)} )}
</Formik> </Formik>

View file

@ -11,7 +11,7 @@ import Locales from 'src/pages/Locales'
import IndividualDiscounts from 'src/pages/LoyaltyPanel/IndividualDiscounts' import IndividualDiscounts from 'src/pages/LoyaltyPanel/IndividualDiscounts'
import PromoCodes from 'src/pages/LoyaltyPanel/PromoCodes' import PromoCodes from 'src/pages/LoyaltyPanel/PromoCodes'
import MachineLogs from 'src/pages/MachineLogs' import MachineLogs from 'src/pages/MachineLogs'
import CashCassettes from 'src/pages/Maintenance/CashCassettes' import CashUnits from 'src/pages/Maintenance/CashUnits'
import MachineStatus from 'src/pages/Maintenance/MachineStatus' import MachineStatus from 'src/pages/Maintenance/MachineStatus'
import Notifications from 'src/pages/Notifications/Notifications' import Notifications from 'src/pages/Notifications/Notifications'
import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
@ -48,11 +48,11 @@ const getLamassuRoutes = () => [
}, },
children: [ children: [
{ {
key: 'cash_cassettes', key: 'cash_units',
label: 'Cash Cassettes', label: 'Cash Units',
route: '/maintenance/cash-cassettes', route: '/maintenance/cash-units',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CashCassettes component: CashUnits
}, },
{ {
key: 'funding', key: 'funding',

View file

@ -13,7 +13,7 @@ import Locales from 'src/pages/Locales'
import IndividualDiscounts from 'src/pages/LoyaltyPanel/IndividualDiscounts' import IndividualDiscounts from 'src/pages/LoyaltyPanel/IndividualDiscounts'
import PromoCodes from 'src/pages/LoyaltyPanel/PromoCodes' import PromoCodes from 'src/pages/LoyaltyPanel/PromoCodes'
import MachineLogs from 'src/pages/MachineLogs' import MachineLogs from 'src/pages/MachineLogs'
import CashCassettes from 'src/pages/Maintenance/CashCassettes' import CashUnits from 'src/pages/Maintenance/CashUnits'
import MachineStatus from 'src/pages/Maintenance/MachineStatus' import MachineStatus from 'src/pages/Maintenance/MachineStatus'
import Notifications from 'src/pages/Notifications/Notifications' import Notifications from 'src/pages/Notifications/Notifications'
import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
@ -48,11 +48,11 @@ const getPazuzRoutes = () => [
}, },
children: [ children: [
{ {
key: 'cash_cassettes', key: 'cash_units',
label: 'Cash Cassettes', label: 'Cash Units',
route: '/maintenance/cash-cassettes', route: '/maintenance/cash-units',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CashCassettes component: CashUnits
}, },
{ {
key: 'logs', key: 'logs',

View file

@ -2,7 +2,34 @@ const modelPrettifier = {
douro1: 'Douro', douro1: 'Douro',
sintra: 'Sintra', sintra: 'Sintra',
gaia: 'Gaia', gaia: 'Gaia',
tejo: 'Tejo' tejo: 'Tejo',
aveiro: 'Aveiro',
grandola: 'Grândola'
} }
export { modelPrettifier } const cashUnitCapacity = {
grandola: {
cashbox: 2000,
recycler: 2800
},
aveiro: {
cashbox: 1500,
stacker: 60,
escrow: 20,
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
}
}
export { modelPrettifier, cashUnitCapacity }

745
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
"@ethereumjs/common": "^2.6.4", "@ethereumjs/common": "^2.6.4",
"@ethereumjs/tx": "^3.5.1", "@ethereumjs/tx": "^3.5.1",
"@graphql-tools/merge": "^6.2.5", "@graphql-tools/merge": "^6.2.5",
"@haensl/subset-sum": "^3.0.5",
"@lamassu/coins": "1.3.0", "@lamassu/coins": "1.3.0",
"@simplewebauthn/server": "^3.0.0", "@simplewebauthn/server": "^3.0.0",
"apollo-server-express": "2.25.1", "apollo-server-express": "2.25.1",

View file

@ -4,10 +4,10 @@
"main.js.map": "/static/js/main.900511f9.chunk.js.map", "main.js.map": "/static/js/main.900511f9.chunk.js.map",
"runtime-main.js": "/static/js/runtime-main.5b925903.js", "runtime-main.js": "/static/js/runtime-main.5b925903.js",
"runtime-main.js.map": "/static/js/runtime-main.5b925903.js.map", "runtime-main.js.map": "/static/js/runtime-main.5b925903.js.map",
"static/js/2.4b3df17b.chunk.js": "/static/js/2.4b3df17b.chunk.js", "static/js/2.01291e3c.chunk.js": "/static/js/2.01291e3c.chunk.js",
"static/js/2.4b3df17b.chunk.js.map": "/static/js/2.4b3df17b.chunk.js.map", "static/js/2.01291e3c.chunk.js.map": "/static/js/2.01291e3c.chunk.js.map",
"index.html": "/index.html", "index.html": "/index.html",
"static/js/2.4b3df17b.chunk.js.LICENSE.txt": "/static/js/2.4b3df17b.chunk.js.LICENSE.txt", "static/js/2.01291e3c.chunk.js.LICENSE.txt": "/static/js/2.01291e3c.chunk.js.LICENSE.txt",
"static/media/3-cassettes-open-1-left.d6d9aa73.svg": "/static/media/3-cassettes-open-1-left.d6d9aa73.svg", "static/media/3-cassettes-open-1-left.d6d9aa73.svg": "/static/media/3-cassettes-open-1-left.d6d9aa73.svg",
"static/media/3-cassettes-open-2-left.a9ee8d4c.svg": "/static/media/3-cassettes-open-2-left.a9ee8d4c.svg", "static/media/3-cassettes-open-2-left.a9ee8d4c.svg": "/static/media/3-cassettes-open-2-left.a9ee8d4c.svg",
"static/media/3-cassettes-open-3-left.08fed660.svg": "/static/media/3-cassettes-open-3-left.08fed660.svg", "static/media/3-cassettes-open-3-left.08fed660.svg": "/static/media/3-cassettes-open-3-left.08fed660.svg",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long