feat: add stacker counter field to the machine

feat: use the stacker amount to properly render the cash cassettes table
feat: add stacker notifications to the plugins backend
This commit is contained in:
Sérgio Salgado 2023-04-17 18:46:24 +01:00
parent 211c4b1ea7
commit 2638bd1717
14 changed files with 519 additions and 138 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

@ -35,6 +35,7 @@ function toMachineObject (r) {
stacker3r: r.stacker3r
},
numberOfCassettes: r.number_of_cassettes,
numberOfStackers: r.number_of_stackers,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),

View file

@ -16,6 +16,7 @@ const typeDef = gql`
model: String
cashUnits: CashUnits
numberOfCassettes: Int
numberOfStackers: Int
statuses: [MachineStatus]
latestEvent: MachineEvent
downloadSpeed: String

View file

@ -645,6 +645,12 @@ 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
@ -708,8 +714,92 @@ function plugins (settings, deviceId) {
fiatCode
}
: null
const stacker1fAlert = device.numberOfStackers >= 1 && isCassetteLow(device.cashUnits.stacker1f, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker1f)
? {
code: 'LOW_CASH_OUT',
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 && isCassetteLow(device.cashUnits.stacker1r, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker1r)
? {
code: 'LOW_CASH_OUT',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker1r,
denomination: denomination1r,
fiatCode
}
: null
const stacker2fAlert = device.numberOfStackers >= 2 && isCassetteLow(device.cashUnits.stacker2f, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker2f)
? {
code: 'LOW_CASH_OUT',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker1f,
denomination: denomination1f,
fiatCode
}
: null
const stacker2rAlert = device.numberOfStackers >= 2 && isCassetteLow(device.cashUnits.stacker2r, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker2r)
? {
code: 'LOW_CASH_OUT',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker2r,
denomination: denomination2r,
fiatCode
}
: null
const stacker3fAlert = device.numberOfStackers >= 3 && isCassetteLow(device.cashUnits.stacker3f, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker3f)
? {
code: 'LOW_CASH_OUT',
cassette: 4,
machineName,
deviceId: device.deviceId,
notes: device.cashUnits.stacker3f,
denomination: denomination3f,
fiatCode
}
: null
const stacker3rAlert = device.numberOfStackers >= 3 && isCassetteLow(device.cashUnits.stacker3r, CASSETTE_MAX_CAPACITY, notifications.fillingPercentageStacker3r)
? {
code: 'LOW_CASH_OUT',
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) {

View file

@ -33,7 +33,8 @@ exports.up = function (next) {
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 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,

View file

@ -54,6 +54,7 @@ const GET_INFO = gql`
stacker3r
}
numberOfCassettes
numberOfStackers
}
config
}

View file

@ -17,7 +17,7 @@ const MODAL_WIDTH = 554
const MODAL_HEIGHT = 520
const Wizard = ({ machine, locale, onClose, save, error }) => {
const LAST_STEP = machine.numberOfCassettes + 1
const LAST_STEP = machine.numberOfCassettes + machine.numberOfStackers + 1
const [{ step, config }, setState] = useState({
step: 0,
config: { active: true }
@ -46,18 +46,48 @@ 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(
machine.numberOfCassettes + 1,
machine.numberOfCassettes + machine.numberOfStackers + 1
)
)
)
const schema = () =>

View file

@ -38,6 +38,7 @@ const GET_DATA = gql`
stacker3r
}
numberOfCassettes
numberOfStackers
statuses {
label
type

View file

@ -50,7 +50,43 @@ const ValidationSchema = Yup.object().shape({
.required('Required')
.integer()
.min(0)
.max(500)
.max(500),
stacker1f: Yup.number()
.label('Stacker 1F')
.required('Required')
.integer()
.min(0)
.max(60),
stacker1r: Yup.number()
.label('Stacker 1R')
.required('Required')
.integer()
.min(0)
.max(60),
stacker2f: Yup.number()
.label('Stacker 2F')
.required('Required')
.integer()
.min(0)
.max(60),
stacker2r: Yup.number()
.label('Stacker 2R')
.required('Required')
.integer()
.min(0)
.max(60),
stacker3f: Yup.number()
.label('Stacker 3F')
.required('Required')
.integer()
.min(0)
.max(60),
stacker3r: Yup.number()
.label('Stacker 3R')
.required('Required')
.integer()
.min(0)
.max(60)
})
const SET_CASSETTE_BILLS = gql`

View file

@ -43,6 +43,7 @@ const GET_INFO = gql`
stacker3r
}
numberOfCassettes
numberOfStackers
statuses {
label
type

View file

@ -10,8 +10,6 @@ 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 { P, Label1 } from 'src/components/typography'
@ -20,40 +18,16 @@ import { ReactComponent as ReverseHistoryIcon } from 'src/styling/icons/circle b
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'
import { hasRecycler } from 'src/utils/machine'
import { onlyFirstToUpper } from 'src/utils/string'
import styles from './CashCassettes.styles'
import CashCassettesFooter from './CashCassettesFooter'
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()
@ -85,7 +59,43 @@ const ValidationSchema = Yup.object().shape({
.required()
.integer()
.min(0)
.max(500)
.max(500),
stacker1f: Yup.number()
.label('Stacker 1F')
.required('Required')
.integer()
.min(0)
.max(60),
stacker1r: Yup.number()
.label('Stacker 1R')
.required('Required')
.integer()
.min(0)
.max(60),
stacker2f: Yup.number()
.label('Stacker 2F')
.required('Required')
.integer()
.min(0)
.max(60),
stacker2r: Yup.number()
.label('Stacker 2R')
.required('Required')
.integer()
.min(0)
.max(60),
stacker3f: Yup.number()
.label('Stacker 3F')
.required('Required')
.integer()
.min(0)
.max(60),
stacker3r: Yup.number()
.label('Stacker 3R')
.required('Required')
.integer()
.min(0)
.max(60)
})
const GET_MACHINES_AND_CONFIG = gql`
@ -108,6 +118,7 @@ const GET_MACHINES_AND_CONFIG = gql`
stacker3r
}
numberOfCassettes
numberOfStackers
}
unpairedMachines {
id: deviceId
@ -177,12 +188,11 @@ const CashCassettes = () => {
const [machineId, setMachineId] = useState('')
const machines = R.path(['machines'])(data) ?? []
const [nonRecyclerMachines, recyclerMachines] = R.partition(hasRecycler)(
machines
)
const [stackerMachines, nonStackerMachines] = R.partition(
it => it.numberOfStackers > 0
)(machines)
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']
})
@ -200,10 +210,6 @@ 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, nonRecyclerMachines),
0
)
const getCashoutSettings = id => fromNamespace(id)(cashout)
const isCashOutDisabled = ({ id }) => !getCashoutSettings(id).active
@ -243,85 +249,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: (_, { id, cashUnits }) => (
<CashIn
currency={{ code: fiatCurrency }}
notes={cashUnits.cashbox}
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: (_, { id, cashUnits }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.[`cassette${it}`]}
currency={{ code: fiatCurrency }}
notes={cashUnits[`cassette${it}`]}
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 nonStackerElements = helper.getElements(
nonStackerMachines,
classes,
config,
bills,
setMachineId,
setWizard
)
elements.push({
name: 'edit',
header: 'Edit',
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.editWidth,
textAlign: 'center',
view: (_, { id }) => {
return (
<IconButton
onClick={() => {
setMachineId(id)
setWizard(true)
}}>
<EditIcon />
</IconButton>
)
}
})
const stackerElements = helper.getElements(
stackerMachines,
classes,
config,
bills,
setMachineId,
setWizard
)
return (
!dataLoading && (
@ -381,18 +325,18 @@ const CashCassettes = () => {
error={error?.message}
name="cashboxes"
stripeWhen={isCashOutDisabled}
elements={elements}
data={nonRecyclerMachines}
elements={nonStackerElements}
data={nonStackerMachines}
validationSchema={ValidationSchema}
tbodyWrapperClass={classes.tBody}
/>
<EditableTable
error={error?.message}
name="cashboxes"
name="recyclerCashboxes"
stripeWhen={isCashOutDisabled}
elements={elements}
data={recyclerMachines}
elements={stackerElements}
data={stackerMachines}
validationSchema={ValidationSchema}
tbodyWrapperClass={classes.tBody}
/>

View file

@ -0,0 +1,241 @@
import * as R from 'ramda'
import { IconButton } from 'src/components/buttons'
import { CashOut, CashIn } from 'src/components/inputs/cashbox/Cashbox'
import { NumberInput, CashCassetteInput } from 'src/components/inputs/formik'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { fromNamespace } from 'src/utils/config'
const widthsByCashUnits = {
2: {
machine: 250,
cashbox: 260,
cassette: 300,
unitGraph: 80,
editWidth: 90
},
3: {
machine: 220,
cashbox: 215,
cassette: 225,
unitGraph: 60,
editWidth: 90
},
4: {
machine: 190,
cashbox: 180,
cassette: 185,
unitGraph: 50,
editWidth: 90
},
5: {
machine: 170,
cashbox: 140,
cassette: 160,
unitGraph: 45,
editWidth: 90
},
6: {
machine: 150,
cashbox: 130,
cassette: 142,
unitGraph: 45,
editWidth: 70
},
7: {
machine: 140,
cashbox: 115,
cassette: 125,
unitGraph: 40,
editWidth: 70
},
8: {
machine: 100,
cashbox: 115,
cassette: 122,
unitGraph: 35,
editWidth: 70
}
}
const getMaxNumberOfCassettesMap = machines =>
Math.max(...R.map(it => it.numberOfCassettes, machines), 0)
const getMaxNumberOfStackersMap = machines =>
Math.max(...R.map(it => it.numberOfStackers, machines), 0)
// Each stacker counts as two cash units (front and rear)
const getMaxNumberOfCashUnits = machines =>
Math.max(
...R.map(it => it.numberOfCassettes + it.numberOfStackers * 2, machines),
0
)
const getElements = (
machines,
classes,
config,
bills,
setMachineId,
setWizard
) => {
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: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.machine,
view: name => <>{name}</>,
input: ({ field: { value: name } }) => <>{name}</>
},
{
name: 'cashbox',
header: 'Cash box',
width: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.cashbox,
view: (_, { id, cashUnits }) => (
<CashIn
currency={{ code: fiatCurrency }}
notes={cashUnits.cashbox}
total={R.sum(R.map(it => it.fiat, bills[id] ?? []))}
/>
),
input: NumberInput,
inputProps: {
decimalPlaces: 0
}
}
]
R.until(
R.gt(R.__, getMaxNumberOfCassettesMap(machines)),
it => {
elements.push({
name: `cassette${it}`,
header: `Cassette ${it}`,
width: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.cassette,
stripe: true,
doubleHeader: 'Cash-out',
view: (_, { id, cashUnits }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.[`cassette${it}`]}
currency={{ code: fiatCurrency }}
notes={cashUnits[`cassette${it}`]}
width={
widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.unitGraph
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
/>
),
isHidden: ({ numberOfCassettes }) => it > numberOfCassettes,
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0,
width:
widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.unitGraph,
inputClassName: classes.cashbox
}
})
return R.add(1, it)
},
1
)
R.until(
R.gt(R.__, getMaxNumberOfStackersMap(machines)),
it => {
elements.push(
{
name: `stacker${it}f`,
header: `Stacker ${it}F`,
width: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.cassette,
stripe: true,
doubleHeader: 'Cash recycling',
view: (_, { id, cashUnits }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.[`stacker${it}f`]}
currency={{ code: fiatCurrency }}
notes={cashUnits[`stacker${it}f`]}
width={
widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.unitGraph
}
threshold={
fillingPercentageSettings[`fillingPercentageStacker${it}f`]
}
/>
),
isHidden: ({ numberOfStackers }) => it > numberOfStackers,
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0,
width:
widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.unitGraph,
inputClassName: classes.cashbox
}
},
{
name: `stacker${it}r`,
header: `Stacker ${it}R`,
width: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.cassette,
stripe: true,
doubleHeader: 'Cash recycling',
view: (_, { id, cashUnits }) => (
<CashOut
className={classes.cashbox}
denomination={getCashoutSettings(id)?.[`stacker${it}r`]}
currency={{ code: fiatCurrency }}
notes={cashUnits[`stacker${it}r`]}
width={
widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.unitGraph
}
threshold={
fillingPercentageSettings[`fillingPercentageStacker${it}r`]
}
/>
),
isHidden: ({ numberOfStackers }) => it > numberOfStackers,
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0,
width:
widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.unitGraph,
inputClassName: classes.cashbox
}
}
)
return R.add(1, it)
},
1
)
elements.push({
name: 'edit',
header: 'Edit',
width: widthsByCashUnits[getMaxNumberOfCashUnits(machines)]?.editWidth,
textAlign: 'center',
view: (_, { id }) => {
return (
<IconButton
onClick={() => {
setMachineId(id)
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

@ -7,9 +7,6 @@ const modelPrettifier = {
grandola: 'Grândola'
}
const hasRecycler = machine =>
machine.model === 'aveiro' || machine.model === 'grandola'
const cashUnitCapacity = {
tejo: {
cashbox: 1000,
@ -22,4 +19,4 @@ const cashUnitCapacity = {
}
}
export { modelPrettifier, cashUnitCapacity, hasRecycler }
export { modelPrettifier, cashUnitCapacity }