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 uuid = require('uuid')
const sumService = require('@haensl/subset-sum')
const MAX_AMOUNT_OF_SOLUTIONS = 10000
const MAX_BRUTEFORCE_ITERATIONS = 10000000
function newSolution(cassettes, c0, c1, c2, c3, shouldFlip) {
return [
{
provisioned: shouldFlip ? cassettes[0].count - c0 : c0,
denomination: cassettes[0].denomination
},
{
provisioned: shouldFlip ? cassettes[1].count - c1 : c1,
denomination: cassettes[1].denomination
},
{
provisioned: shouldFlip ? cassettes[2].count - c2 : c2,
denomination: cassettes[2].denomination
},
{
provisioned: shouldFlip ? cassettes[3].count - c3 : c3,
denomination: cassettes[3].denomination
}
]
const BILL_LIST_MODES = {
LAST_UNIT_FIRST: 0,
FIRST_UNIT_FIRST: 1,
LOWEST_VALUE_FIRST: 2,
HIGHEST_VALUE_FIRST: 3,
UNIT_ROUND_ROBIN: 4,
VALUE_ROUND_ROBIN: 5
}
function mergeCassettes(cassettes) {
const map = {}
_.forEach(it => {
if (!map[it.denomination]) {
map[it.denomination] = 0
}
map[it.denomination] += it.count
}, cassettes)
return _.map(it => ({ denomination: it, count: map[it] }), _.keys(map))
}
function unmergeCassettes(cassettes, output) {
const map = {}
_.forEach(it => {
if (!map[it.denomination]) {
map[it.denomination] = 0
}
map[it.denomination] += it.provisioned
}, output)
const response = []
_.forEach(it => {
const value = {
denomination: it.denomination,
id: uuid.v4()
}
const amountNeeded = map[it.denomination]
if (!amountNeeded) {
return response.push({ provisioned: 0, ...value })
}
if (amountNeeded < it.count) {
map[it.denomination] = 0
return response.push({ provisioned: amountNeeded, ...value })
}
map[it.denomination] -= it.count
return response.push({ provisioned: it.count, ...value })
}, cassettes)
return response
}
function makeChangeDuo(cassettes, amount) {
// 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 [
const buildBillList = (units, mode) => {
switch (mode) {
case BILL_LIST_MODES.LAST_UNIT_FIRST:
return _.reduce(
(acc, value) => {
acc.push(..._.times(_.constant(value.denomination), value.count))
return acc
},
[],
_.reverse(units)
)
case BILL_LIST_MODES.FIRST_UNIT_FIRST:
return _.reduce(
(acc, value) => {
acc.push(..._.times(_.constant(value.denomination), value.count))
return acc
},
[],
units
)
case BILL_LIST_MODES.LOWEST_VALUE_FIRST:
return _.reduce(
(acc, value) => {
acc.push(..._.times(_.constant(value.denomination), value.count))
return acc
},
[],
_.orderBy(['denomination'], ['asc'])(units)
)
case BILL_LIST_MODES.HIGHEST_VALUE_FIRST:
return _.reduce(
(acc, value) => {
acc.push(..._.times(_.constant(value.denomination), value.count))
return acc
},
[],
_.orderBy(['denomination'], ['desc'])(units)
)
case BILL_LIST_MODES.UNIT_ROUND_ROBIN:
{
provisioned: smallCount,
denomination: small.denomination,
id: uuid.v4()
},
{ provisioned: i, denomination: largeDenom, id: uuid.v4() }
]
}
const amountOfBills = _.reduce(
(acc, value) => acc + value.count,
0,
units
)
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 []
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 []
}
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) {
const available = _.reduce(
(res, val) => res + val.count * val.denomination,
0,
outCassettes
)
if (available < amount) {
console.log(`Tried to dispense more than was available for amount ${amount.toNumber()} with cassettes ${JSON.stringify(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 []
const solution = getSolution(outCassettes, amount, BILL_LIST_MODES.VALUE_ROUND_ROBIN)
return solutionToOriginalUnits(solution, outCassettes)
}
module.exports = { makeChange }

View file

@ -41,13 +41,24 @@ function insertNewBills (t, billRows, machineTx) {
if (_.isEmpty(bills)) return Promise.resolve([])
const dbBills = _.map(cashInLow.massage, bills)
const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time']
const 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 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`
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(() => {
return t.none(sql)
})

View file

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

View file

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

View file

@ -17,6 +17,32 @@ case
else 'Pending'
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 }
const mapValuesWithKey = _.mapValues.convert({cap: false})
@ -43,23 +69,37 @@ function convertBigNumFields (obj) {
}
function convertField (key) {
return _.snakeCase(key)
return _.includes('denomination', key) || _.includes('provisioned', key) ? key : _.snakeCase(key)
}
function addDbBills (tx) {
const bills = tx.bills
if (_.isEmpty(bills)) return tx
const billsObj = {
provisioned1: bills[0]?.provisioned ?? 0,
provisioned2: bills[1]?.provisioned ?? 0,
provisioned3: bills[2]?.provisioned ?? 0,
provisioned4: bills[3]?.provisioned ?? 0,
denomination1: bills[0]?.denomination ?? 0,
denomination2: bills[1]?.denomination ?? 0,
denomination3: bills[2]?.denomination ?? 0,
denomination4: bills[3]?.denomination ?? 0
}
const billFields = _.map(it => _.replace(/(denomination|provisioned)/g, '$1_')(it), BILL_FIELDS)
const billsObj = _.flow(
_.reduce(
(acc, value) => {
const suffix = value.name.replace(/cassette|stacker/gi, '')
return {
...acc,
[`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)
}
@ -78,7 +118,7 @@ function toObj (row) {
let newObj = {}
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]) {
newObj[objKey] = new BN(row[key])
return
@ -93,35 +133,28 @@ function toObj (row) {
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
if (_.some(_.isNil, _.at(billFields, newObj))) throw new Error('Missing cassette values')
const billFieldsArr = [
{
denomination: newObj.denomination1,
provisioned: newObj.provisioned1
},
{
denomination: newObj.denomination2,
provisioned: newObj.provisioned2
},
{
denomination: newObj.denomination3,
provisioned: newObj.provisioned3
},
{
denomination: newObj.denomination4,
provisioned: newObj.provisioned4
}
]
const billFieldsArr = _.concat(
_.map(it => ({ name: `cassette${it + 1}`, denomination: newObj[`denomination${it + 1}`], provisioned: newObj[`provisioned${it + 1}`] }))(_.range(0, MAX_CASSETTES)),
_.reduce(
(acc, value) => {
acc.push(
{ name: `stacker${value + 1}f`, denomination: newObj[`denomination${value + 1}f`], provisioned: newObj[`provisioned${value + 1}f`] },
{ name: `stacker${value + 1}r`, denomination: newObj[`denomination${value + 1}r`], provisioned: newObj[`provisioned${value + 1}r`] }
)
return acc
},
[]
)(_.range(0, MAX_STACKERS))
)
// There can't be bills with denomination === 0.
// If a bill has denomination === 0, then that cassette is not set and should be filtered out.
const bills = _.filter(it => it.denomination > 0, billFieldsArr)
return _.set('bills', bills, _.omit(billFields, newObj))
return _.set('bills', bills, _.omit(BILL_FIELDS, newObj))
}
function redeemableTxs (deviceId) {
@ -129,7 +162,10 @@ function redeemableTxs (deviceId) {
where device_id=$1
and redeem=$2
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`
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)) {
return pi.buildAvailableCassettes(newTx.id)
.then(cassettes => {
return pi.buildAvailableUnits(newTx.id)
.then(_units => {
const units = _.concat(_units.cassettes, _units.stackers)
logger.silly('Computing bills to dispense:', {
txId: newTx.id,
cassettes: cassettes.cassettes,
units: units,
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))
if (!bills) throw httpError('Out of bills', INSUFFICIENT_FUNDS_CODE)
@ -73,8 +74,9 @@ function postProcess (txVector, justAuthorized, pi) {
const rec = {}
_.forEach(it => {
rec[`provisioned_${it + 1}`] = bills[it].provisioned
rec[`denomination_${it + 1}`] = bills[it].denomination
const suffix = bills[it].name.replace(/cassette|stacker/gi, '')
rec[`provisioned_${suffix}`] = bills[it].provisioned
rec[`denomination_${suffix}`] = bills[it].denomination
}, _.times(_.identity(), _.size(bills)))
return cashOutActions.logAction(db, 'provisionNotes', rec, newTx)

View file

@ -2,26 +2,37 @@ const constants = require('./constants')
const db = require('./db')
const _ = require('lodash/fp')
const uuid = require('uuid')
const camelize = require('./utils')
function createCashboxBatch (deviceId, cashboxCount) {
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 = `
UPDATE bills SET cashbox_batch_id=$1
FROM cash_in_txs
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
`
return db.tx(async t => {
const newBatch = await t.oneOrNone(sql, [uuid.v4(), deviceId])
return t.oneOrNone(sql2, [newBatch.id, newBatch.device_id])
const sql3 = `
UPDATE empty_unit_bills SET cashbox_batch_id=$1
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) {
const isValidContext = _.has(['deviceId', 'cashbox', 'cassettes'], machineContext)
const isCassetteAmountWithinRange = _.inRange(constants.CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES, constants.CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES + 1, _.size(machineContext.cassettes))
const cashUnits = machineContext.cashUnits
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)
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.')
@ -29,43 +40,59 @@ function updateMachineWithBatch (machineContext, oldCashboxCount) {
return db.tx(t => {
const deviceId = machineContext.deviceId
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
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])
const q3 = t.none(`UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5 WHERE device_id=$6`, [
machineContext.cashbox,
machineContext.cassettes[0],
machineContext.cassettes[1],
machineContext.cassettes[2],
machineContext.cassettes[3],
const q3 = t.none(`UPDATE empty_unit_bills SET cashbox_batch_id=$1
WHERE empty_unit_bills.device_id = $2 AND empty_unit_bills.cashbox_batch_id IS NULL`, [batchId, deviceId])
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`, [
cashUnits.cashbox,
cashUnits.cassette1,
cashUnits.cassette2,
cashUnits.cassette3,
cashUnits.cassette4,
cashUnits.stacker1f,
cashUnits.stacker1r,
cashUnits.stacker2f,
cashUnits.stacker2r,
cashUnits.stacker3f,
cashUnits.stacker3r,
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()) {
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
FROM cashbox_batches AS cb
LEFT JOIN bills AS b ON cb.id = b.cashbox_batch_id
WHERE cb.created >= $1 AND cb.created <= $2
GROUP BY cb.id
ORDER BY cb.created DESC
SELECT cuo.id, cuo.device_id, cuo.created, cuo.operation_type, cuo.bill_count_override, cuo.performed_by, json_agg(bi.*) AS bills
FROM cash_unit_operation AS cuo
LEFT JOIN (
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 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) {
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])
}
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])
}

View file

@ -13,7 +13,29 @@ const anonymousCustomer = {
name: 'anonymous'
}
const CASSETTE_MAX_CAPACITY = 500
const CASH_UNIT_CAPACITY = {
grandola: {
cashbox: 2000,
recycler: 2800
},
aveiro: {
cashbox: 1500,
stacker: 60,
cassette: 500
},
tejo: {
// TODO: add support for the different cashbox configuration in Tejo
cashbox: 1000,
cassette: 500
},
gaia: {
cashbox: 600
},
sintra: {
cashbox: 1000,
cassette: 500
}
}
const CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES = 2
const CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES = 4
@ -39,7 +61,7 @@ const BALANCE_FETCH_SPEED_MULTIPLIER = {
module.exports = {
anonymousCustomer,
CASSETTE_MAX_CAPACITY,
CASH_UNIT_CAPACITY,
AUTHENTICATOR_ISSUER_ENTITY,
AUTH_TOKEN_EXPIRATION_TIME,
REGISTRATION_TOKEN_EXPIRATION_TIME,

View file

@ -5,6 +5,7 @@ const plugins = require('../plugins')
const configManager = require('../new-config-manager')
const { batchGetCustomInfoRequest, getCustomInfoRequests } = require('../new-admin/services/customInfoRequests')
const state = require('../middlewares/state')
const { getMachine } = require('../machine-loader')
const VERSION = require('../../package.json').version
@ -114,6 +115,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
configManager.getOperatorInfo(settings.config),
configManager.getReceipt(settings.config),
!!configManager.getCashOut(deviceId, settings.config).active,
getMachine(deviceId, currentConfigVersion),
])
.then(([
enablePaperWalletOnly,
@ -124,6 +126,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
operatorInfo,
receiptInfo,
twoWayMode,
{ numberOfCassettes, numberOfStackers },
]) =>
(currentConfigVersion && currentConfigVersion >= staticConf.configVersion) ?
null :
@ -138,7 +141,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
languages: localeInfo.languages,
fiatCode: localeInfo.fiatCurrency
},
machineInfo: { deviceId, deviceName },
machineInfo: { deviceId, deviceName, numberOfCassettes, numberOfStackers },
twoWayMode,
speedtestFiles,
urlsToPing,
@ -167,13 +170,25 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
)(cassettes) :
null
const massageStackers = stackers =>
stackers ?
_.flow(
stackers => _.set('physical', _.get('stackers', stackers), stackers),
stackers => _.set('virtual', _.get('virtualStackers', stackers), stackers),
_.unset('stackers'),
_.unset('virtualStackers')
)(stackers) :
null
state.pids = _.update(operatorId, _.set(deviceId, { pid, ts: Date.now() }), state.pids)
return _.flow(
_.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'coins', 'rates']),
const res = _.flow(
_.pick(['areThereAvailablePromoCodes', 'balances', 'cassettes', 'stackers', 'coins', 'rates']),
_.update('cassettes', massageCassettes),
_.update('stackers', massageStackers),
/* [{ cryptoCode, rates }, ...] => [[cryptoCode, rates], ...] */
_.update('coins', _.map(({ cryptoCode, rates }) => [cryptoCode, rates])),
@ -185,9 +200,10 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
/* Group the separate objects by cryptoCode */
/* { balances, coins, rates } => { cryptoCode: { balance, ask, bid, cashIn, cashOut }, ... } */
({ areThereAvailablePromoCodes, balances, cassettes, coins, rates }) => ({
({ areThereAvailablePromoCodes, balances, cassettes, stackers, coins, rates }) => ({
areThereAvailablePromoCodes,
cassettes,
stackers,
coins: _.flow(
_.reduce(
(ret, [cryptoCode, obj]) => _.update(cryptoCode, _.assign(obj), ret),
@ -209,7 +225,20 @@ const dynamicConfig = ({ deviceId, operatorId, pid, pq, settings, }) => {
_.set('reboot', !!pid && state.reboots?.[operatorId]?.[deviceId] === pid),
_.set('shutdown', !!pid && state.shutdowns?.[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)
// 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 {
deviceId: String!
deviceName: String
numberOfCassettes: Int
numberOfStackers: Int
}
type ReceiptInfo {
@ -165,6 +167,13 @@ type DynamicCoinValues {
}
type PhysicalCassette {
name: String!
denomination: Int!
count: Int!
}
type PhysicalStacker {
name: String!
denomination: Int!
count: Int!
}
@ -174,13 +183,21 @@ type Cassettes {
virtual: [Int!]!
}
type Stackers {
physical: [PhysicalStacker!]!
virtual: [Int!]!
}
type DynamicConfig {
areThereAvailablePromoCodes: Boolean!
cassettes: Cassettes
stackers: Stackers
coins: [DynamicCoinValues!]!
reboot: Boolean!
shutdown: Boolean!
restartServices: Boolean!
emptyUnit: Boolean!
refillUnit: Boolean!
}
type Configs {

View file

@ -1,6 +1,5 @@
const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const axios = require('axios')
const uuid = require('uuid')
const batching = require('./cashbox-batches')
@ -13,6 +12,7 @@ const settingsLoader = require('./new-settings-loader')
const notifierUtils = require('./notifier/utils')
const notifierQueries = require('./notifier/queries')
const { ApolloError } = require('apollo-server-errors');
const { loadLatestConfig } = require('./new-settings-loader')
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
@ -21,12 +21,21 @@ const stuckStatus = { label: 'Stuck', type: 'error' }
function toMachineObject (r) {
return {
deviceId: r.device_id,
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
cassette3: r.cassette3,
cassette4: r.cassette4,
cashUnits: {
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
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,
numberOfStackers: r.number_of_stackers,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),
@ -141,8 +150,9 @@ function renameMachine (rec) {
function resetCashOutBills (rec) {
const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId })
const sql = `UPDATE devices SET cassette1=$1, cassette2=$2, cassette3=$3, cassette4=$4 WHERE device_id=$5;`
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.cassettes[2], rec.cassettes[3], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance'))
const { cassette1, cassette2, cassette3, cassette4, stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r } = rec.cashUnits
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) {
@ -151,17 +161,138 @@ function emptyCashInBills (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])
.then(oldCashboxValue => {
if (_.isNil(oldCashboxValue) || rec.cashbox === oldCashboxValue.cashbox) {
const sql = 'UPDATE devices SET cashbox=$1, cassette1=$2, cassette2=$3, cassette3=$4, cassette4=$5 WHERE device_id=$6'
return db.none(sql, [rec.cashbox, rec.cassettes[0], rec.cassettes[1], rec.cassettes[2], rec.cassettes[3], rec.deviceId])
if (_.isNil(oldCashboxValue) || cashbox === oldCashboxValue.cashbox) {
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, [cashbox, cassette1, cassette2, cassette3, cassette4, stacker1f, stacker1r, stacker2f, stacker2r, stacker3f, stacker3r, rec.deviceId])
}
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) {
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) {
rec.operatorId = operatorId
switch (rec.action) {
@ -204,6 +353,8 @@ function setMachine (rec, operatorId) {
case 'reboot': return reboot(rec)
case 'shutdown': return shutdown(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)
}
}
@ -277,5 +428,7 @@ module.exports = {
updateNetworkHeartbeat,
getNetworkPerformance,
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}`)
state.restartServicesMap[operatorId] = { [deviceId]: pid }
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:
break
}

View file

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

View file

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

View file

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

View file

@ -14,12 +14,9 @@ const typeDef = gql`
pairedAt: Date
version: String
model: String
cashbox: Int
cassette1: Int
cassette2: Int
cassette3: Int
cassette4: Int
cashUnits: CashUnits
numberOfCassettes: Int
numberOfStackers: Int
statuses: [MachineStatus]
latestEvent: MachineEvent
downloadSpeed: String
@ -27,6 +24,34 @@ const typeDef = gql`
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 {
id: ID!
deviceId: ID!
@ -55,6 +80,8 @@ const typeDef = gql`
reboot
shutdown
restartServices
emptyUnit
refillUnit
}
type Query {
@ -64,7 +91,7 @@ const typeDef = gql`
}
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 (
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)
.then(res => _.map(_.mapKeys(_.camelCase), res))
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)}`}`
return Promise.all([db.any(sql), db.any(sql2)])
.then(([bills, operationalBills]) => _.map(_.mapKeys(_.camelCase), _.concat(bills, operationalBills)))
}
module.exports = {

View file

@ -6,14 +6,14 @@ function getMachine (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
return getMachine(deviceId)
.then(machine => {
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
return machine
})
.then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName }, operatorId))
.then(machineLoader.setMachine({ deviceId, action, cashUnits, newName }, operatorId))
.then(getMachine(deviceId))
}

View file

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

View file

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

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.
// This makes sure that the server stores a default value
const DEFAULT_NUMBER_OF_CASSETTES = 2
const DEFAULT_NUMBER_OF_STACKERS = 0
function pullToken (token) {
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)
.then(r => {
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)
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)
})
.catch(err => {

View file

@ -27,7 +27,7 @@ const loyalty = require('./loyalty')
const transactionBatching = require('./tx-batching')
const state = require('./middlewares/state')
const { CASSETTE_MAX_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants')
const { CASH_UNIT_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants')
const notifier = require('./notifier')
@ -116,7 +116,7 @@ function plugins (settings, deviceId) {
const sumTxs = (sum, tx) => {
// cash-out-helper sends 0 as fallback value, need to filter it out as there are no '0' denominations
const bills = _.filter(it => it.denomination > 0, tx.bills)
const bills = _.filter(it => _.includes('cassette', it.name) && it.denomination > 0, tx.bills)
const sameDenominations = a => a[0]?.denomination === a[1]?.denomination
const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills))
@ -139,6 +139,7 @@ function plugins (settings, deviceId) {
const computedCassettes = []
_.forEach(it => {
computedCassettes.push({
name: cassettes[it].name,
denomination: cassettes[it].denomination,
count: counts[it]
})
@ -147,37 +148,75 @@ function plugins (settings, deviceId) {
return computedCassettes
}
function computeAvailableStackers (stackers, redeemableTxs) {
if (_.isEmpty(redeemableTxs)) return stackers
const sumTxs = (sum, tx) => {
// cash-out-helper sends 0 as fallback value, need to filter it out as there are no '0' denominations
const bills = _.filter(it => _.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) {
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
if (!cashOutConfig.active) return Promise.resolve()
return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
.then(([rec, _redeemableTxs]) => {
.then(([_cassettes, _redeemableTxs]) => {
const redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), _redeemableTxs)
const denominations = []
_.forEach(it => {
denominations.push(cashOutConfig[`cassette${it + 1}`])
}, _.times(_.identity(), rec.numberOfCassettes))
}, _.times(_.identity(), _cassettes.numberOfCassettes))
const virtualCassettes = [Math.max(...denominations) * 2]
const counts = argv.cassettes
? argv.cassettes.split(',')
: rec.counts
: _cassettes.counts
if (rec.counts.length !== denominations.length) {
if (_cassettes.counts.length !== denominations.length) {
throw new Error('Denominations and respective counts do not match!')
}
const cassettes = []
_.forEach(it => {
cassettes.push({
name: `cassette${it + 1}`,
denomination: parseInt(denominations[it], 10),
count: parseInt(counts[it], 10)
})
}, _.times(_.identity(), rec.numberOfCassettes))
}, _.times(_.identity(), _cassettes.numberOfCassettes))
try {
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 () {
const sql = `select id from user_config
where type=$1
@ -240,6 +335,7 @@ function plugins (settings, deviceId) {
return Promise.all([
buildAvailableCassettes(),
buildAvailableStackers(),
fetchCurrentConfigVersion(),
millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone)),
loyalty.getNumberOfAvailablePromoCodes(),
@ -250,6 +346,7 @@ function plugins (settings, deviceId) {
])
.then(([
cassettes,
stackers,
configVersion,
timezone,
numberOfAvailablePromoCodes,
@ -273,6 +370,7 @@ function plugins (settings, deviceId) {
return {
cassettes,
stackers,
rates: buildRates(tickers),
balances: buildBalances(balances),
coins,
@ -645,71 +743,164 @@ function plugins (settings, deviceId) {
const denomination2 = cashOutConfig.cassette2
const denomination3 = cashOutConfig.cassette3
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 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 machineName = device.name
const cashInAlert = device.cashbox > notifications.cashInAlertThreshold
const cashInAlert = device.cashUnits.cashbox > notifications.cashInAlertThreshold
? {
code: 'CASH_BOX_FULL',
machineName,
deviceId: device.deviceId,
notes: device.cashbox
notes: device.cashUnits.cashbox
}
: 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',
cassette: 1,
machineName,
deviceId: device.deviceId,
notes: device.cassette1,
notes: device.cashUnits.cassette1,
denomination: denomination1,
fiatCode
}
: 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',
cassette: 2,
machineName,
deviceId: device.deviceId,
notes: device.cassette2,
notes: device.cashUnits.cassette2,
denomination: denomination2,
fiatCode
}
: 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',
cassette: 3,
machineName,
deviceId: device.deviceId,
notes: device.cassette3,
notes: device.cashUnits.cassette3,
denomination: denomination3,
fiatCode
}
: 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',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cassette4,
notes: device.cashUnits.cassette4,
denomination: denomination4,
fiatCode
}
: null
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
return _.compact([cashInAlert, cassette1Alert, cassette2Alert, cassette3Alert, cassette4Alert])
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) {
@ -874,7 +1065,6 @@ function plugins (settings, deviceId) {
sendMessage,
checkBalances,
getMachineNames,
buildAvailableCassettes,
buy,
sell,
getNotificationConfig,
@ -885,7 +1075,8 @@ function plugins (settings, deviceId) {
isValidWalletScore,
getTransactionHash,
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
// last known state.
exports.machineEvent = function machineEvent (rec) {

View file

@ -16,6 +16,7 @@ const populateDeviceId = require('./middlewares/populateDeviceId')
const populateSettings = require('./middlewares/populateSettings')
const recordPing = require('./middlewares/recordPing')
const unitsRoutes = require('./routes/unitsRoutes')
const cashboxRoutes = require('./routes/cashboxRoutes')
const customerRoutes = require('./routes/customerRoutes')
const logsRoutes = require('./routes/logsRoutes')
@ -82,6 +83,7 @@ app.use('/customer', customerRoutes)
app.use('/tx', txRoutes)
app.use('/logs', logsRoutes)
app.use('/units', unitsRoutes)
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 - Creating new batch...')
return cashbox.createCashboxBatch(req.deviceId, machine.cashbox)
return cashbox.createCashboxBatch(req.deviceId, machine.cashUnits.cashbox)
.then(() => {
logger.info(`** DEBUG ** - Cashbox removal - Finished creating the new cashbox batch`)
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 model = req.query.model
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 => {
if (isValid) return res.json({ status: 'paired' })
throw httpError('Pairing failed')

View file

@ -96,6 +96,8 @@ function poll (req, res, next) {
const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid
const shutdown = pid && state.shutdowns?.[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 locale = {
@ -119,6 +121,8 @@ function poll (req, res, next) {
reboot,
shutdown,
restartServices,
emptyUnit,
refillUnit,
hasLightning,
receipt,
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 { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
const { CASSETTE_MAX_CAPACITY } = require('../lib/constants')
const CASSETTE_MAX_CAPACITY = 500
exports.up = function (next) {
return loadLatest()

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

View file

@ -15,6 +15,7 @@ const Cashbox = ({
percent = 0,
cashOut = false,
width,
height,
className,
emptyPartClassName,
labelClassName,
@ -27,6 +28,7 @@ const Cashbox = ({
percent,
cashOut,
width,
height,
applyColorVariant,
isLow
})
@ -55,35 +57,17 @@ const Cashbox = ({
// 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
const CashIn = ({ currency, notes, total }) => {
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 = ({
const CashIn = ({
capacity = 500,
denomination = 0,
currency,
notes,
className,
editingMode = false,
threshold,
width
width,
height,
total,
omitInnerPercentage
}) => {
const percent = (100 * notes) / capacity
const isLow = percent < threshold
@ -98,6 +82,54 @@ const CashOut = ({
cashOut
isLow={isLow}
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>
{!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: {
borderColor: colorPicker,
backgroundColor: colorPicker,
height: 118,
height: ({ height }) => height ?? 118,
width: ({ width }) => width ?? 80,
border: '2px solid',
textAlign: 'end',
@ -58,7 +58,13 @@ const cashboxStyles = {
const gridStyles = {
row: {
display: 'flex'
display: 'flex',
alignItems: 'center'
},
col: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
},
innerRow: {
display: 'flex',

View file

@ -185,6 +185,42 @@ const MachineActions = memo(({ machine, onActionSuccess }) => {
}}>
Restart Services
</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>
<ConfirmDialog
disabled={disabled}

View file

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

View file

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

View file

@ -17,7 +17,8 @@ const MODAL_WIDTH = 554
const MODAL_HEIGHT = 520
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({
step: 0,
config: { active: true }
@ -46,37 +47,105 @@ const Wizard = ({ machine, locale, onClose, save, error }) => {
})
}
const steps = R.map(
it => ({
type: `cassette${it}`,
display: `Cassette ${it}`,
component: Autocomplete,
inputProps: {
options: options,
labelProp: 'display',
valueProp: 'code'
}
}),
R.range(1, machine.numberOfCassettes + 1)
const steps = R.concat(
R.map(
it => ({
type: `cassette${it}`,
display: `Cassette ${it}`,
component: Autocomplete,
inputProps: {
options: options,
labelProp: 'display',
valueProp: 'code'
}
}),
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 = () =>
Yup.object().shape({
cassette1: Yup.number().required(),
cassette1:
machine.numberOfCassettes >= 1 && step >= 1
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
cassette2:
machine.numberOfCassettes > 1 && step >= 2
machine.numberOfCassettes >= 2 && step >= 2
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
cassette3:
machine.numberOfCassettes > 2 && step >= 3
machine.numberOfCassettes >= 3 && step >= 3
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
cassette4:
machine.numberOfCassettes > 3 && step >= 4
machine.numberOfCassettes >= 4 && step >= 4
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
stacker1f:
machine.numberOfStackers >= 1 && step >= machine.numberOfCassettes + 1
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
stacker1r:
machine.numberOfStackers >= 1 && step >= machine.numberOfCassettes + 2
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
stacker2f:
machine.numberOfStackers >= 2 && step >= machine.numberOfCassettes + 3
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
stacker2r:
machine.numberOfStackers >= 2 && step >= machine.numberOfCassettes + 4
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
stacker3f:
machine.numberOfStackers >= 3 && step >= machine.numberOfCassettes + 5
? Yup.number().required()
: Yup.number()
.transform(transformNumber)
.nullable(),
stacker3r:
machine.numberOfStackers >= 3 && step >= machine.numberOfCassettes + 6
? Yup.number().required()
: Yup.number()
.transform(transformNumber)

View file

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

View file

@ -8,10 +8,13 @@ import { getBillOptions } from 'src/utils/bill-options'
import { CURRENCY_MAX } from 'src/utils/constants'
import { transformNumber } from 'src/utils/number'
const widthsByNumberOfCassettes = {
2: { machine: 300, cassette: 225, zeroConf: 200 },
3: { machine: 210, cassette: 180, zeroConf: 200 },
4: { machine: 200, cassette: 150, zeroConf: 150 }
const widthsByNumberOfUnits = {
2: { machine: 325, cassette: 340 },
3: { machine: 300, cassette: 235 },
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({
@ -22,9 +25,10 @@ const DenominationsSchema = Yup.object().shape({
.max(CURRENCY_MAX),
cassette2: Yup.number()
.label('Cassette 2')
.required()
.min(1)
.max(CURRENCY_MAX),
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
cassette3: Yup.number()
.label('Cassette 3')
.min(1)
@ -36,6 +40,42 @@ const DenominationsSchema = Yup.object().shape({
.min(1)
.max(CURRENCY_MAX)
.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)
})
@ -45,6 +85,11 @@ const getElements = (machines, locale = {}, classes) => {
...R.map(it => it.numberOfCassettes, machines),
0
)
const maxNumberOfStackers = Math.max(
...R.map(it => it.numberOfStackers, machines),
0
)
const numberOfCashUnits = maxNumberOfCassettes + maxNumberOfStackers
const options = getBillOptions(locale, denominations)
const cassetteProps =
@ -61,7 +106,7 @@ const getElements = (machines, locale = {}, classes) => {
{
name: 'id',
header: 'Machine',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine,
width: widthsByNumberOfUnits[numberOfCashUnits]?.machine,
view: it => machines.find(({ deviceId }) => deviceId === it).name,
size: 'sm',
editable: false
@ -77,13 +122,13 @@ const getElements = (machines, locale = {}, classes) => {
size: 'sm',
stripe: true,
textAlign: 'right',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette,
width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
suffix: fiatCurrency,
bold: bold,
view: it => it,
input: options?.length > 0 ? Autocomplete : NumberInput,
inputProps: cassetteProps,
doubleHeader: 'Denominations',
doubleHeader: 'Denominations of Cassettes & Recyclers',
isHidden: machine =>
it >
machines.find(({ deviceId }) => deviceId === machine.id)
@ -94,6 +139,32 @@ const getElements = (machines, locale = {}, classes) => {
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
}

View file

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

View file

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

View file

@ -3,85 +3,49 @@ import { makeStyles } from '@material-ui/core'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
import { IconButton } from 'src/components/buttons'
import { Table as EditableTable } from 'src/components/editableTable'
import { CashOut, CashIn } from 'src/components/inputs/cashbox/Cashbox'
import { NumberInput, CashCassetteInput } from 'src/components/inputs/formik'
import DataTable from 'src/components/tables/DataTable'
import CashUnitDetails from 'src/pages/Maintenance/CashUnitDetails'
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 styles from './Cassettes.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`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$cashbox: Int!
$cassette1: Int!
$cassette2: Int!
$cassette3: Int!
$cassette4: Int!
$cashUnits: CashUnitsInput
) {
machineAction(
deviceId: $deviceId
action: $action
cashbox: $cashbox
cassette1: $cassette1
cassette2: $cassette2
cassette3: $cassette3
cassette4: $cassette4
) {
machineAction(deviceId: $deviceId, action: $action, cashUnits: $cashUnits) {
deviceId
cashbox
cassette1
cassette2
cassette3
cassette4
cashUnits {
cashbox
cassette1
cassette2
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 classes = useStyles()
@ -89,115 +53,47 @@ const CashCassettes = ({ machine, config, refetchData, bills }) => {
const cashout = config && fromNamespace('cashOut')(config)
const locale = config && fromNamespace('locale')(config)
const fillingPercentageSettings =
config && fromNamespace('notifications', config)
const fiatCurrency = locale?.fiatCurrency
const numberOfCassettes = machine.numberOfCassettes
const getCashoutSettings = deviceId => fromNamespace(deviceId)(cashout)
const isCashOutDisabled = ({ deviceId }) =>
!getCashoutSettings(deviceId).active
const elements = [
{
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
const elements = R.filter(it => it.name !== 'name')(
helper.getElements(classes, config, bills, setWizard, widths)
)
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, {
refetchQueries: () => refetchData()
})
const onSave = (_, cashbox, cassettes) =>
const onSave = (_, cashUnits) =>
setCassetteBills({
variables: {
action: 'setCassetteBills',
deviceId: machine.deviceId,
cashbox,
...cassettes
cashUnits
}
})
const InnerCashUnitDetails = ({ it }) => (
<CashUnitDetails
machine={it}
bills={bills[it.deviceId] ?? []}
currency={fiatCurrency}
config={config}
hideMachineData
widths
/>
)
return machine.name ? (
<>
<EditableTable
error={error?.message}
editWidth={widthsByNumberOfCassettes[numberOfCassettes].editWidth}
stripeWhen={isCashOutDisabled}
disableRowEdit={isCashOutDisabled}
name="cashboxes"
<DataTable
elements={elements}
data={[machine]}
save={onSave}
validationSchema={ValidationSchema}
Details={InnerCashUnitDetails}
emptyText="No machines so far"
initialExpanded={0}
tableClassName={classes.dataTable}
/>
{wizard && (
<Wizard

View file

@ -1,6 +1,34 @@
import { offDarkColor } from 'src/styling/variables'
const styles = {
cashbox: {
height: 36
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
},
dataTable: {
minHeight: 290
}
}

View file

@ -29,12 +29,21 @@ const GET_INFO = gql`
pairedAt
version
model
cashbox
cassette1
cassette2
cassette3
cassette4
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
numberOfCassettes
numberOfStackers
statuses {
label
type
@ -100,7 +109,7 @@ const Machines = ({ data, refetch, reload }) => {
const machine = R.path(['machine'])(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 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 * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper'
import Modal from 'src/components/Modal'
import { IconButton, Button } from 'src/components/buttons'
import { Table as EditableTable } from 'src/components/editableTable'
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 { EmptyTable } from 'src/components/table'
import DataTable from 'src/components/tables/DataTable'
import { P, Label1 } from 'src/components/typography'
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 HistoryIcon } from 'src/styling/icons/circle buttons/history/zodiac.svg'
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 styles from './CashCassettes.styles.js'
import CashCassettesFooter from './CashCassettesFooter'
import CashUnitDetails from './CashUnitDetails'
import styles from './CashUnits.styles'
import CashCassettesFooter from './CashUnitsFooter'
import CashboxHistory from './CashboxHistory'
import Wizard from './Wizard/Wizard'
import helper from './helper'
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`
query getData($billFilters: JSONObject) {
machines {
name
id: deviceId
cashbox
cassette1
cassette2
cassette3
cassette4
model
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
stacker1f
stacker1r
stacker2f
stacker2r
stacker3f
stacker3r
}
numberOfCassettes
numberOfStackers
}
unpairedMachines {
id: deviceId
@ -123,27 +74,23 @@ const SET_CASSETTE_BILLS = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$cashbox: Int!
$cassette1: Int!
$cassette2: Int!
$cassette3: Int!
$cassette4: Int!
$cashUnits: CashUnitsInput
) {
machineAction(
deviceId: $deviceId
action: $action
cashbox: $cashbox
cassette1: $cassette1
cassette2: $cassette2
cassette3: $cassette3
cassette4: $cassette4
) {
machineAction(deviceId: $deviceId, action: $action, cashUnits: $cashUnits) {
deviceId
cashbox
cassette1
cassette2
cassette3
cassette4
cashUnits {
cashbox
cassette1
cassette2
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 classes = useStyles()
const [showHistory, setShowHistory] = useState(false)
@ -173,7 +127,6 @@ const CashCassettes = () => {
const machines = R.path(['machines'])(data) ?? []
const unpairedMachines = R.path(['unpairedMachines'])(data) ?? []
const config = R.path(['config'])(data) ?? {}
const fillingPercentageSettings = fromNamespace('notifications', config)
const [setCassetteBills, { error }] = useMutation(SET_CASSETTE_BILLS, {
refetchQueries: () => ['getData']
})
@ -191,21 +144,15 @@ const CashCassettes = () => {
const cashout = data?.config && fromNamespace('cashOut')(data.config)
const locale = data?.config && fromNamespace('locale')(data.config)
const fiatCurrency = locale?.fiatCurrency
const maxNumberOfCassettes = Math.max(
...R.map(it => it.numberOfCassettes, machines),
0
)
const getCashoutSettings = id => fromNamespace(id)(cashout)
const isCashOutDisabled = ({ id }) => !getCashoutSettings(id).active
const onSave = (id, cashbox, cassettes) => {
const onSave = (id, cashUnits) => {
return setCassetteBills({
variables: {
action: 'setCassetteBills',
deviceId: id,
cashbox,
...cassettes
cashUnits
}
})
}
@ -235,85 +182,23 @@ const CashCassettes = () => {
setSelectedRadio(selectedRadio)
}
const elements = [
{
name: 'name',
header: 'Machine',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine,
view: name => <>{name}</>,
input: ({ field: { value: name } }) => <>{name}</>
},
{
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
const elements = helper.getElements(
classes,
config,
bills,
setWizard,
widths,
setMachineId
)
elements.push({
name: 'edit',
header: 'Edit',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.editWidth,
textAlign: 'center',
view: (value, { id }) => {
return (
<IconButton
onClick={() => {
setMachineId(id)
setWizard(true)
}}>
<EditIcon />
</IconButton>
)
}
})
const InnerCashUnitDetails = ({ it }) => (
<CashUnitDetails
machine={it}
bills={bills[it.id] ?? []}
currency={fiatCurrency}
config={config}
/>
)
return (
!dataLoading && (
@ -369,14 +254,13 @@ const CashCassettes = () => {
</TitleSection>
{!showHistory && (
<>
<EditableTable
error={error?.message}
name="cashboxes"
stripeWhen={isCashOutDisabled}
<DataTable
loading={dataLoading}
elements={elements}
data={machines}
validationSchema={ValidationSchema}
tbodyWrapperClass={classes.tBody}
Details={InnerCashUnitDetails}
emptyText="No machines so far"
expandable
/>
{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 { numberToFiatAmount } from 'src/utils/number.js'
import styles from './CashCassettesFooter.styles.js'
import styles from './CashUnitsFooter.styles.js'
const useStyles = makeStyles(styles)
const CashCassettesFooter = ({
@ -22,9 +23,9 @@ const CashCassettesFooter = ({
const classes = useStyles()
const cashout = config && fromNamespace('cashOut')(config)
const getCashoutSettings = id => fromNamespace(id)(cashout)
const reducerFn = (
const cashoutReducerFn = (
acc,
{ cassette1, cassette2, cassette3, cassette4, id }
{ cashUnits: { cassette1, cassette2, cassette3, cassette4 }, id }
) => {
const cassette1Denomination = getCashoutSettings(id).cassette1 ?? 0
const cassette2Denomination = getCashoutSettings(id).cassette2 ?? 0
@ -38,11 +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 total = new BigNumber(totalInCassettes + totalInCashBox).toFormat(0)
const total = new BigNumber(
totalInCassettes + totalInCashBox + totalInRecyclers
).toFormat(0)
return (
<div className={classes.footerContainer}>
@ -62,6 +101,13 @@ const CashCassettesFooter = ({
{numberToFiatAmount(totalInCassettes)} {currencyCode}
</Info1>
</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}>
<Info2 className={classes.iconLabel}>Total:</Info2>
<Info1 className={classes.valueDisplay}>

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import * as Yup from 'yup'
import Modal from 'src/components/Modal'
import { MAX_NUMBER_OF_CASSETTES } from 'src/utils/constants'
import { cashUnitCapacity, modelPrettifier } from 'src/utils/machine'
import { defaultToZero } from 'src/utils/number'
import WizardSplash from './WizardSplash'
@ -11,13 +12,21 @@ import WizardStep from './WizardStep'
const MODAL_WIDTH = 554
const MODAL_HEIGHT = 535
const CASHBOX_DEFAULT_CAPACITY = 500
const CASSETTE_FIELDS = R.map(
it => `cassette${it}`,
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 [{ step, config }, setState] = useState({
step: 0,
@ -28,7 +37,9 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
R.isEmpty(cashoutSettings) || !cashoutSettings?.active
const numberOfCassettes = isCashOutDisabled ? 0 : machine.numberOfCassettes
const numberOfStackers = machine.numberOfStackers
// const LAST_STEP = numberOfCassettes + numberOfStackers * 2 + 1
const LAST_STEP = numberOfCassettes + 1
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 newConfig = R.merge(config, it)
if (isLastStep) {
@ -53,10 +76,16 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
it?.wasCashboxEmptied
].includes('YES')
const cashbox = wasCashboxEmptied ? 0 : machine?.cashbox
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()
}
@ -78,12 +107,69 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
.integer()
.required()
.min(0)
.max(CASHBOX_DEFAULT_CAPACITY)
.max(
cashUnitCapacity[machine.model].cassette,
`${modelPrettifier[machine.model]} maximum cassette capacity is ${
cashUnitCapacity[machine.model].cassette
} bills`
)
})
}))
)
const 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.reduce(
(acc, value) => {
@ -95,16 +181,35 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
)
: {}
const steps = R.prepend(
{
type: 'cashbox',
schema: Yup.object().shape({
wasCashboxEmptied: Yup.string().required('Select one option.')
}),
cashoutRequired: false
},
makeCassetteSteps(numberOfCassettes)
)
const makeStackersInitialValues = () =>
!R.isEmpty(cashoutSettings)
? R.reduce(
(acc, value) => {
acc[`stacker${value}f`] = ''
acc[`stacker${value}r`] = ''
return acc
},
{},
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 (
<Modal
@ -122,7 +227,6 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
name={machine?.name}
machine={machine}
cashoutSettings={cashoutSettings}
cassetteCapacity={CASHBOX_DEFAULT_CAPACITY}
error={error}
lastStep={isLastStep}
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 { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { comet, errorColor } from 'src/styling/variables'
import { cashUnitCapacity } from 'src/utils/machine'
import { numberToFiatAmount } from 'src/utils/number'
import { startCase } from 'src/utils/string'
const styles = {
content: {
@ -104,19 +106,41 @@ const styles = {
const useStyles = makeStyles(styles)
const cassetesArtworks = (numberOfCassettes, step) =>
[
const CASHBOX_STEP = 1
const cassetesArtworks = (step, numberOfCassettes, numberOfStackers) => {
const cassetteStepsStart = CASHBOX_STEP + 1
return [
[cassetteOne],
[cassetteOne, cassetteTwo],
[tejo3CassetteOne, tejo3CassetteTwo, tejo3CassetteThree],
[tejo4CassetteOne, tejo4CassetteTwo, tejo4CassetteThree, tejo4CassetteFour]
][numberOfCassettes - 2][step - 2]
][numberOfCassettes - cassetteStepsStart + 1][step - cassetteStepsStart]
}
const getCashUnitFieldName = (step, numberOfCassettes, numberOfStackers) => {
if (step === CASHBOX_STEP) return { name: 'cashbox', category: 'cashbox' }
const cassetteStepsStart = CASHBOX_STEP + 1
if (step < cassetteStepsStart + numberOfCassettes)
return {
name: `cassette${step - cassetteStepsStart + 1}`,
category: 'cassette'
}
const stackerStepsStart = CASHBOX_STEP + numberOfCassettes + 1
if (step < stackerStepsStart + numberOfStackers * 2)
return {
name: `stacker${Math.ceil((step - stackerStepsStart + 1) / 2)}${
(step - stackerStepsStart) % 2 === 0 ? 'f' : 'r'
}`,
category: 'stacker'
}
}
const WizardStep = ({
step,
name,
machine,
cashoutSettings,
cassetteCapacity,
error,
lastStep,
steps,
@ -133,16 +157,20 @@ const WizardStep = ({
{ display: 'No', code: 'NO' }
]
const cassetteField = `cassette${step - 1}`
const numberOfCassettes = machine.numberOfCassettes
const originalCassetteCount = machine?.[cassetteField]
const cassetteDenomination = cashoutSettings?.[cassetteField]
const numberOfStackers = machine.numberOfStackers
const {
name: cashUnitField,
category: cashUnitCategory
} = getCashUnitFieldName(step, numberOfCassettes, numberOfStackers)
const originalCashUnitCount = machine?.cashUnits?.[cashUnitField]
const cashUnitDenomination = cashoutSettings?.[cashUnitField]
const cassetteCount = values => values[cassetteField] || originalCassetteCount
const cassetteTotal = values => cassetteCount(values) * cassetteDenomination
const cassetteCount = values => values[cashUnitField] || originalCashUnitCount
const cassetteTotal = values => cassetteCount(values) * cashUnitDenomination
const getPercentage = R.pipe(
cassetteCount,
count => 100 * (count / cassetteCapacity),
count => 100 * (count / cashUnitCapacity[machine.model][cashUnitCategory]),
R.clamp(0, 100)
)
@ -161,7 +189,7 @@ const WizardStep = ({
initialValues={{ wasCashboxEmptied: '' }}
enableReinitialize
validationSchema={steps[0].schema}>
{({ values, errors }) => (
{({ errors }) => (
<Form>
<div
className={classnames(classes.horizontalAlign, classes.form)}>
@ -206,7 +234,7 @@ const WizardStep = ({
classes.lineAlignment
)}>
<Info1 noMargin className={classes.cashboxBills}>
{machine?.cashbox}
{machine?.cashUnits.cashbox}
</Info1>
<P noMargin>accepted bills</P>
</div>
@ -236,7 +264,11 @@ const WizardStep = ({
<img
className={classes.stepImage}
alt="cassette"
src={cassetesArtworks(numberOfCassettes, step)}></img>
src={cassetesArtworks(
step,
numberOfCassettes,
numberOfStackers
)}></img>
<div className={classes.formWrapper}>
<div
className={classnames(
@ -257,7 +289,13 @@ const WizardStep = ({
<H4
className={classes.cassetteFormTitleContent}
noMargin>
Cash cassette {step - 1} (dispenser)
{startCase(cashUnitField)} (
{R.includes('cassette', cashUnitField)
? `dispenser`
: R.includes('stacker', cashUnitField)
? `recycler`
: ``}
)
</H4>
</div>
<Cashbox
@ -276,13 +314,13 @@ const WizardStep = ({
component={NumberInput}
decimalPlaces={0}
width={50}
placeholder={originalCassetteCount.toString()}
name={cassetteField}
placeholder={originalCashUnitCount.toString()}
name={cashUnitField}
className={classes.cashboxBills}
autoFocus
/>
<P>
{cassetteDenomination} {fiatCurrency} bills loaded
{cashUnitDenomination} {fiatCurrency} bills loaded
</P>
</div>
<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
deviceId
numberOfCassettes
numberOfStackers
}
cryptoCurrencies {
code

View file

@ -19,7 +19,9 @@ const useStyles = makeStyles(styles)
const CASH_IN_KEY = 'fiatBalanceAlertsCashIn'
const CASH_OUT_KEY = 'fiatBalanceAlertsCashOut'
const RECYCLER_STACKER_KEY = 'fiatBalanceAlertsRecyclerStacker'
const DEFAULT_NUMBER_OF_CASSETTES = 2
const DEFAULT_NUMBER_OF_STACKERS = 0
const notesMin = 0
const notesMax = 9999999
@ -39,6 +41,11 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
DEFAULT_NUMBER_OF_CASSETTES
)
const maxNumberOfStackers = Math.max(
...R.map(it => it.numberOfStackers, machines),
DEFAULT_NUMBER_OF_STACKERS
)
const schema = Yup.object().shape({
cashInAlertThreshold: Yup.number()
.transform(transformNumber)
@ -160,6 +167,76 @@ const FiatBalance = ({ section, min = 0, max = 100, fieldWidth = 80 }) => {
)}
</div>
</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>

View file

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

View file

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

View file

@ -2,7 +2,34 @@ const modelPrettifier = {
douro1: 'Douro',
sintra: 'Sintra',
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/tx": "^3.5.1",
"@graphql-tools/merge": "^6.2.5",
"@haensl/subset-sum": "^3.0.5",
"@lamassu/coins": "1.3.0",
"@simplewebauthn/server": "^3.0.0",
"apollo-server-express": "2.25.1",

View file

@ -4,10 +4,10 @@
"main.js.map": "/static/js/main.900511f9.chunk.js.map",
"runtime-main.js": "/static/js/runtime-main.5b925903.js",
"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.4b3df17b.chunk.js.map": "/static/js/2.4b3df17b.chunk.js.map",
"static/js/2.01291e3c.chunk.js": "/static/js/2.01291e3c.chunk.js",
"static/js/2.01291e3c.chunk.js.map": "/static/js/2.01291e3c.chunk.js.map",
"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-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",

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/><meta name="robots" content="noindex"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Lamassu Admin</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="root"></div><script>!function(e){function r(r){for(var n,a,l=r[0],i=r[1],f=r[2],c=0,s=[];c<l.length;c++)a=l[c],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,f||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var i=t[l];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/";var l=this["webpackJsonplamassu-admin"]=this["webpackJsonplamassu-admin"]||[],i=l.push.bind(l);l.push=r,l=l.slice();for(var f=0;f<l.length;f++)r(l[f]);var p=i;t()}([])</script><script src="/static/js/2.4b3df17b.chunk.js"></script><script src="/static/js/main.900511f9.chunk.js"></script></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/><meta name="robots" content="noindex"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Lamassu Admin</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="root"></div><script>!function(e){function r(r){for(var n,a,l=r[0],i=r[1],f=r[2],c=0,s=[];c<l.length;c++)a=l[c],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,f||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var i=t[l];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/";var l=this["webpackJsonplamassu-admin"]=this["webpackJsonplamassu-admin"]||[],i=l.push.bind(l);l.push=r,l=l.slice();for(var f=0;f<l.length;f++)r(l[f]);var p=i;t()}([])</script><script src="/static/js/2.4b3df17b.chunk.js"></script><script src="/static/js/main.900511f9.chunk.js"></script></body></html>

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