[REF] pos: use reactivity in ClosingPopup

The "ClosingPopup" relies on an error-prone pattern of calling
a "handle" function on each input change.

In this commit we refactor it's logic such that the calculation
is done reactively. 

This leads to much simpler code.

In addition, we remove multiple pieces of dead code.

closes odoo/odoo#131171

Task: 3499250
Related: odoo/enterprise#47323
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
vlst
2023-09-13 09:38:58 +00:00
parent f1dbd84e9d
commit a869ee87f2
10 changed files with 194 additions and 204 deletions
@@ -15,7 +15,7 @@ patch(Chrome.prototype, {
limitDate.setDate(limitDate.getDate() + 1);
if (limitDate.getTime() < now) {
const info = await this.pos.getClosePosInfo();
this.popup.add(ClosePosPopup, { info, keepBehind: true });
this.popup.add(ClosePosPopup, { ...info, keepBehind: true });
}
}
});
@@ -545,7 +545,6 @@ class PosSession(models.Model):
self.ensure_one()
orders = self.order_ids.filtered(lambda o: o.state == 'paid' or o.state == 'invoiced')
payments = orders.payment_ids.filtered(lambda p: p.payment_method_id.type != "pay_later")
pay_later_payments = orders.payment_ids - payments
cash_payment_method_ids = self.payment_method_ids.filtered(lambda pm: pm.type == 'cash')
default_cash_payment_method_id = cash_payment_method_ids[0] if cash_payment_method_ids else None
total_default_cash_payment_amount = sum(payments.filtered(lambda p: p.payment_method_id == default_cash_payment_method_id).mapped('amount')) if default_cash_payment_method_id else 0
@@ -571,8 +570,6 @@ class PosSession(models.Model):
'quantity': len(orders),
'amount': sum(orders.mapped('amount_total'))
},
'payments_amount': sum(payments.mapped('amount')),
'pay_later_amount': sum(pay_later_payments.mapped('amount')),
'opening_notes': self.opening_notes,
'default_cash_details': {
'name': default_cash_payment_method_id.name,
@@ -63,7 +63,6 @@ export class Input extends Component {
return obj[key];
}
setValue(newValue, tModel = this.props.tModel) {
newValue = this.props.format(newValue);
const [obj, key] = tModel;
obj[key] = newValue;
this.props.callback?.(newValue);
@@ -5,19 +5,37 @@ import { SaleDetailsButton } from "@point_of_sale/app/navbar/sale_details_button
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
import { MoneyDetailsPopup } from "@point_of_sale/app/utils/money_details_popup/money_details_popup";
import { useService } from "@web/core/utils/hooks";
import { useState } from "@odoo/owl";
import { AlertPopup } from "@point_of_sale/app/utils/alert_popup/alert_popup";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { useState } from "@odoo/owl";
import { ConnectionLostError } from "@web/core/network/rpc_service";
import { identifyError } from "@point_of_sale/app/errors/error_handlers";
import { _t } from "@web/core/l10n/translation";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { parseFloat } from "@web/views/fields/parsers";
import { Input } from "@point_of_sale/app/generic_components/input/input";
import { useAsyncLockedMethod } from "@point_of_sale/app/utils/hooks";
export class ClosePosPopup extends AbstractAwaitablePopup {
static components = { SaleDetailsButton, Input };
static template = "point_of_sale.ClosePosPopup";
static props = [
"orders_details",
"opening_notes",
"default_cash_details",
"other_payment_methods",
"is_manager",
"amount_authorized_diff",
"state",
// TODO: set the props for all popups
"id",
"keepBehind",
"resolve",
"isActive",
"close",
"confirmKey",
"cancelKey",
];
setup() {
super.setup();
@@ -27,20 +45,33 @@ export class ClosePosPopup extends AbstractAwaitablePopup {
this.report = useService("report");
this.hardwareProxy = useService("hardware_proxy");
this.customerDisplay = useService("customer_display");
this.cashControl = this.pos.config.cash_control;
this.closeSessionClicked = false;
this.moneyDetails = null;
Object.assign(this, this.props.info);
this.state = useState({
displayMoneyDetailsPopup: false,
});
Object.assign(this.state, this.props.info.state);
this.state = useState(this.getInitialState());
this.confirm = useAsyncLockedMethod(this.confirm);
}
getInitialState() {
const initialState = { notes: "", payments: {} };
if (this.pos.config.cash_control) {
initialState.payments[this.props.default_cash_details.id] = {
counted: "0",
};
}
this.props.other_payment_methods.forEach((pm) => {
if (pm.type === "bank") {
initialState.payments[pm.id] = {
counted: this.env.utils.formatCurrency(pm.amount, false),
};
}
});
return initialState;
}
//@override
async confirm() {
if (!this.cashControl || !this.hasDifference()) {
this.closeSession();
} else if (this.hasUserAuthority()) {
if (!this.pos.config.cash_control || this.env.utils.floatIsZero(this.getMaxDifference())) {
await this.closeSession();
return;
}
if (this.hasUserAuthority()) {
const { confirmed } = await this.popup.add(ConfirmPopup, {
title: _t("Payments Difference"),
body: _t(
@@ -48,19 +79,19 @@ export class ClosePosPopup extends AbstractAwaitablePopup {
),
});
if (confirmed) {
this.closeSession();
await this.closeSession();
}
} else {
await this.popup.add(ConfirmPopup, {
title: _t("Payments Difference"),
body: _t(
"The maximum difference allowed is %s.\n\
Please contact your manager to accept the closing difference.",
this.env.utils.formatCurrency(this.amountAuthorizedDiff)
),
confirmText: _t("OK"),
});
return;
}
await this.popup.add(ConfirmPopup, {
title: _t("Payments Difference"),
body: _t(
"The maximum difference allowed is %s.\n\
Please contact your manager to accept the closing difference.",
this.env.utils.formatCurrency(this.props.amount_authorized_diff)
),
confirmText: _t("OK"),
});
}
//@override
async cancel() {
@@ -68,6 +99,11 @@ export class ClosePosPopup extends AbstractAwaitablePopup {
super.cancel();
}
}
canConfirm() {
return Object.values(this.state.payments)
.map((v) => v.counted)
.every(this.env.utils.isValidFloat);
}
async openDetailsPopup() {
const action = _t("Cash control - closing");
this.hardwareProxy.openCashbox(action);
@@ -77,12 +113,8 @@ export class ClosePosPopup extends AbstractAwaitablePopup {
});
if (confirmed) {
const { total, moneyDetailsNotes, moneyDetails } = payload;
this.state.payments[this.defaultCashDetails.id].counted = total;
this.state.payments[this.defaultCashDetails.id].difference =
this.env.utils.roundCurrency(
this.state.payments[[this.defaultCashDetails.id]].counted -
this.defaultCashDetails.amount
);
this.state.payments[this.props.default_cash_details.id].counted =
this.env.utils.formatCurrency(total, false);
if (moneyDetailsNotes) {
this.state.notes = moneyDetailsNotes;
}
@@ -92,114 +124,109 @@ export class ClosePosPopup extends AbstractAwaitablePopup {
async downloadSalesReport() {
return this.report.download("point_of_sale.sale_details_report", [this.pos.pos_session.id]);
}
handleInputChange(paymentId, amount) {
if (!this.env.utils.isValidFloat(amount)) {
return;
}
let expectedAmount;
if (paymentId === this.defaultCashDetails?.id) {
this.moneyDetails = null;
setManualCashInput(amount) {
if (this.env.utils.isValidFloat(amount) && this.moneyDetails) {
this.state.notes = "";
expectedAmount = this.defaultCashDetails.amount;
} else {
expectedAmount = this.otherPaymentMethods.find((pm) => paymentId === pm.id).amount;
this.moneyDetails = null;
}
this.state.payments[paymentId].counted = parseFloat(amount);
this.state.payments[paymentId].difference = this.env.utils.roundCurrency(
this.state.payments[paymentId].counted - expectedAmount
);
}
hasDifference() {
return Object.entries(this.state.payments).find((pm) => pm[1].difference != 0);
getDifference(paymentId) {
const counted = this.state.payments[paymentId].counted;
if (!this.env.utils.isValidFloat(counted)) {
return NaN;
}
const expectedAmount =
paymentId === this.props.default_cash_details.id
? this.props.default_cash_details.amount
: this.props.other_payment_methods.find((pm) => pm.id === paymentId).amount;
return parseFloat(counted) - expectedAmount;
}
getMaxDifference() {
return Math.max(
...Object.keys(this.state.payments).map((id) =>
Math.abs(this.getDifference(parseInt(id)))
)
);
}
hasUserAuthority() {
const absDifferences = Object.entries(this.state.payments).map((pm) =>
Math.abs(pm[1].difference)
);
return (
this.isManager ||
this.amountAuthorizedDiff == null ||
Math.max(...absDifferences) <= this.amountAuthorizedDiff
this.props.is_manager ||
this.props.amount_authorized_diff == null ||
this.getMaxDifference() <= this.props.amount_authorized_diff
);
}
canCancel() {
return true;
}
async closeSession() {
if (!this.closeSessionClicked) {
this.closeSessionClicked = true;
if (this.customerDisplay) {
this.customerDisplay.update({ closeUI: true });
}
if (this.cashControl) {
const response = await this.orm.call(
"pos.session",
"post_closing_cash_details",
[this.pos.pos_session.id],
{
counted_cash: this.state.payments[this.defaultCashDetails.id].counted,
}
);
if (!response.successful) {
return this.handleClosingError(response);
this.customerDisplay?.update({ closeUI: true });
if (this.pos.config.cash_control) {
const response = await this.orm.call(
"pos.session",
"post_closing_cash_details",
[this.pos.pos_session.id],
{
counted_cash: parseFloat(
this.state.payments[this.props.default_cash_details.id].counted
),
}
}
);
try {
await this.orm.call("pos.session", "update_closing_control_state_session", [
this.pos.pos_session.id,
this.state.notes,
]);
} catch (error) {
// We have to handle the error manually otherwise the validation check stops the script.
// In case of "rescue session", we want to display the next popup with "handleClosingError".
// FIXME
if (
!error.message &&
!error.message.data &&
error.message.data.message !== "This session is already closed."
) {
throw error;
}
}
try {
const bankPaymentMethodDiffPairs = this.otherPaymentMethods
.filter((pm) => pm.type == "bank")
.map((pm) => [pm.id, this.state.payments[pm.id].difference]);
const response = await this.orm.call("pos.session", "close_session_from_ui", [
this.pos.pos_session.id,
bankPaymentMethodDiffPairs,
]);
if (!response.successful) {
return this.handleClosingError(response);
}
window.location = "/web#action=point_of_sale.action_client_pos_menu";
} catch (error) {
if (identifyError(error) instanceof ConnectionLostError) {
// Cannot redirect to backend when offline, let error handlers show the offline popup
// FIXME POSREF: doing this means closing again when online will redo the beginning of the method
// although it's impossible to close again because this.closeSessionClicked isn't reset to false
// The application state is corrupted.
throw error;
} else {
// FIXME POSREF: why are we catching errors here but not anywhere else in this method?
await this.popup.add(ErrorPopup, {
title: _t("Closing session error"),
body: _t(
"An error has occurred when trying to close the session.\n" +
"You will be redirected to the back-end to manually close the session."
),
});
window.location = "/web#action=point_of_sale.action_client_pos_menu";
}
if (!response.successful) {
return this.handleClosingError(response);
}
}
this.closeSessionClicked = false;
try {
await this.orm.call("pos.session", "update_closing_control_state_session", [
this.pos.pos_session.id,
this.state.notes,
]);
} catch (error) {
// We have to handle the error manually otherwise the validation check stops the script.
// In case of "rescue session", we want to display the next popup with "handleClosingError".
// FIXME
if (
!error.message &&
!error.message.data &&
error.message.data.message !== "This session is already closed."
) {
throw error;
}
}
try {
const bankPaymentMethodDiffPairs = this.props.other_payment_methods
.filter((pm) => pm.type == "bank")
.map((pm) => [pm.id, this.getDifference(pm.id)]);
const response = await this.orm.call("pos.session", "close_session_from_ui", [
this.pos.pos_session.id,
bankPaymentMethodDiffPairs,
]);
if (!response.successful) {
return this.handleClosingError(response);
}
window.location = "/web#action=point_of_sale.action_client_pos_menu";
} catch (error) {
if (identifyError(error) instanceof ConnectionLostError) {
// Cannot redirect to backend when offline, let error handlers show the offline popup
// FIXME POSREF: doing this means closing again when online will redo the beginning of the method
// although it's impossible to close again because this.closeSessionClicked isn't reset to false
// The application state is corrupted.
throw error;
} else {
// FIXME POSREF: why are we catching errors here but not anywhere else in this method?
await this.popup.add(ErrorPopup, {
title: _t("Closing session error"),
body: _t(
"An error has occurred when trying to close the session.\n" +
"You will be redirected to the back-end to manually close the session."
),
});
window.location = "/web#action=point_of_sale.action_client_pos_menu";
}
}
}
async handleClosingError(response) {
const body = response.message;
@@ -213,7 +240,4 @@ export class ClosePosPopup extends AbstractAwaitablePopup {
window.location = "/web#action=point_of_sale.action_client_pos_menu";
}
}
_getShowDiff(pm) {
return pm.type == "bank" && pm.number !== 0;
}
}
@@ -6,8 +6,8 @@
<div class="modal-header">
<h4 class="modal-title">Closing Session</h4>
<div class="total-orders fw-bolder">
Total <t t-esc="ordersDetails.quantity"/> orders:
<span class="amount" t-esc="env.utils.formatCurrency(ordersDetails.amount)"/>
Total <t t-esc="props.orders_details.quantity"/> orders:
<span class="amount" t-esc="env.utils.formatCurrency(props.orders_details.amount)"/>
</div>
</div>
<main class="modal-body">
@@ -21,29 +21,29 @@
<th>Difference</th>
</tr>
</thead>
<t t-if="cashControl">
<t t-if="pos.config.cash_control">
<tbody>
<tr>
<td t-esc="defaultCashDetails.name"/>
<td t-esc="env.utils.formatCurrency(defaultCashDetails.amount)"/>
<td t-esc="props.default_cash_details.name"/>
<td t-esc="env.utils.formatCurrency(props.default_cash_details.amount)"/>
<td class="d-flex">
<Input tModel="[state.payments[defaultCashDetails.id], 'counted']"
callback.bind="(value) => this.handleInputChange(defaultCashDetails.id, value)"
<Input tModel="[state.payments[props.default_cash_details.id], 'counted']"
callback.bind="(value) => this.setManualCashInput(value)"
isValid.bind="env.utils.isValidFloat"/>
<div class="button icon ClosePosPopup btn btn-secondary" t-on-click="openDetailsPopup">
<i class="fa fa-money fa-2x" role="img" title="Open the money details popup"/>
</div>
</td>
<td t-esc="env.utils.formatCurrency(state.payments[defaultCashDetails.id].difference)"
t-att-class="{'warning text-danger fw-bolder': state.payments[defaultCashDetails.id].difference}"/>
<td t-esc="env.utils.formatCurrency(getDifference(props.default_cash_details.id))"
t-att-class="{'warning text-danger fw-bolder': !env.utils.floatIsZero(getDifference(props.default_cash_details.id))}"/>
</tr>
</tbody>
<tbody class="cash-overview border-start small text-muted">
<tr>
<td>Opening</td>
<td class="align-top" t-esc="env.utils.formatCurrency(defaultCashDetails.opening)"/>
<td class="align-top" t-esc="env.utils.formatCurrency(props.default_cash_details.opening)"/>
</tr>
<tr t-foreach="defaultCashDetails.moves" t-as="move" t-key="move_index">
<tr t-foreach="props.default_cash_details.moves" t-as="move" t-key="move_index">
<td>
<div class="flex d-flex flex-row">
<div class="cash-sign me-1" t-esc="move.amount &lt; 0 ? '-' : '+'"/>
@@ -52,37 +52,37 @@
</td>
<td class="align-top" t-esc="env.utils.formatCurrency(Math.abs(move.amount))"/>
</tr>
<tr t-if="defaultCashDetails.payment_amount">
<tr t-if="props.default_cash_details.payment_amount">
<td>
<div class="flex d-flex flex-row">
<div class="cash-sign me-1" t-esc="defaultCashDetails.payment_amount &lt; 0 ? '-' : '+'"/>
Payments in <t t-esc="defaultCashDetails.name"/>
<div class="cash-sign me-1" t-esc="props.default_cash_details.payment_amount &lt; 0 ? '-' : '+'"/>
Payments in <t t-esc="props.default_cash_details.name"/>
</div>
</td>
<td class="align-top" t-esc="env.utils.formatCurrency(Math.abs(defaultCashDetails.payment_amount))"/>
<td class="align-top" t-esc="env.utils.formatCurrency(Math.abs(props.default_cash_details.payment_amount))"/>
</tr>
</tbody>
</t>
<tbody t-if="otherPaymentMethods.length > 0">
<tr t-foreach="otherPaymentMethods" t-as="pm" t-key="pm.id">
<tbody t-if="props.other_payment_methods.length > 0">
<tr t-foreach="props.other_payment_methods" t-as="pm" t-key="pm.id">
<td t-esc="pm.name"/>
<td t-esc="env.utils.formatCurrency(pm.amount)"/>
<t t-set="_showDiff" t-value="_getShowDiff(pm)" />
<t t-set="_showDiff" t-value="pm.type === 'bank' and pm.number !== 0" />
<td t-if="_showDiff">
<Input tModel="[state.payments[pm.id], 'counted']"
isValid.bind="env.utils.isValidFloat"
callback.bind="(value) => this.handleInputChange(pm.id, value)" />
isValid.bind="env.utils.isValidFloat" />
</td>
<td t-if="_showDiff" t-esc="env.utils.formatCurrency(state.payments[pm.id].difference)" t-att-class="{'warning text-danger fw-bolder': state.payments[pm.id].difference}"/>
<td t-if="_showDiff" t-esc="env.utils.formatCurrency(getDifference(pm.id))"
t-att-class="{'warning text-danger fw-bolder': getDifference(pm.id)}"/>
</tr>
</tbody>
</table>
</div>
<div class="notes-container d-flex flex-column flex-sm-row gap-3 border-top mt-3 pt-3">
<div class="opening-notes-container d-flex flex-column flex-grow-1 align-items-start" t-if="openingNotes" >
<label class="form-label" for="openingNotes">Opening note</label>
<textarea class="opening-notes form-control" id="openingNotes">
<t t-esc="openingNotes"/>
<div class="opening-notes-container d-flex flex-column flex-grow-1 align-items-start" t-if="props.opening_notes" >
<label class="form-label" for="props.opening_notes">Opening note</label>
<textarea class="opening-notes form-control" id="props.opening_notes">
<t t-esc="props.opening_notes"/>
</textarea>
</div>
<div class="closing-notes-container d-flex flex-column flex-grow-1 align-items-start">
@@ -93,8 +93,8 @@
</main>
<footer class="footer modal-footer justify-content-between">
<div class="modal-footer-left d-flex gap-2">
<button class="button highlight btn btn-lg btn-primary" t-on-click="confirm">Close Session</button>
<button class="button btn btn-lg btn-secondary" t-att-class="{'disabled': !canCancel()}" t-on-click="cancel">Discard</button>
<button class="button highlight btn btn-lg btn-primary" t-att-disabled="!canConfirm()" t-on-click="confirm">Close Session</button>
<button class="button btn btn-lg btn-secondary" t-att-disabled="!canCancel()" t-on-click="cancel">Discard</button>
</div>
<!-- Download Sale Details -->
<div class="modal-footer-right d-flex gap-2">
@@ -102,7 +102,7 @@ export class Navbar extends Component {
async closeSession() {
const info = await this.pos.getClosePosInfo();
this.popup.add(ClosePosPopup, { info, keepBehind: true });
this.popup.add(ClosePosPopup, { ...info, keepBehind: true });
}
showBackButton() {
@@ -7,6 +7,7 @@ import { MoneyDetailsPopup } from "@point_of_sale/app/utils/money_details_popup/
import { useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { Input } from "@point_of_sale/app/generic_components/input/input";
import { parseFloat } from "@web/views/fields/parsers";
export class CashOpeningPopup extends AbstractAwaitablePopup {
static template = "point_of_sale.CashOpeningPopup";
@@ -30,7 +30,7 @@
>
Open session
<span t-if="env.utils.isValidFloat(state.openingCash)"
t-esc="env.utils.formatCurrency(state.openingCash)"/>
t-esc="env.utils.formatStrCurrency(state.openingCash)"/>
</button>
</footer>
</div>
@@ -926,53 +926,9 @@ export class PosStore extends Reactive {
};
}
async getClosePosInfo() {
const closingData = await this.orm.call("pos.session", "get_closing_control_data", [
return await this.orm.call("pos.session", "get_closing_control_data", [
[this.pos_session.id],
]);
const ordersDetails = closingData.orders_details;
const paymentsAmount = closingData.payments_amount;
const payLaterAmount = closingData.pay_later_amount;
const openingNotes = closingData.opening_notes;
const defaultCashDetails = closingData.default_cash_details;
const otherPaymentMethods = closingData.other_payment_methods;
const isManager = closingData.is_manager;
const amountAuthorizedDiff = closingData.amount_authorized_diff;
const cashControl = this.config.cash_control;
// component state and refs definition
const state = { notes: "", acceptClosing: false, payments: {} };
if (cashControl) {
state.payments[defaultCashDetails.id] = {
counted: 0,
difference: -defaultCashDetails.amount,
number: 0,
};
}
if (otherPaymentMethods.length > 0) {
otherPaymentMethods.forEach((pm) => {
if (pm.type === "bank") {
state.payments[pm.id] = {
counted: this.env.utils
.roundCurrency(pm.amount)
.toFixed(this.currency.decimal_places),
difference: 0,
number: pm.number,
};
}
});
}
return {
ordersDetails,
paymentsAmount,
payLaterAmount,
openingNotes,
defaultCashDetails,
otherPaymentMethods,
isManager,
amountAuthorizedDiff,
state,
cashControl,
};
}
set_start_order() {
if (this.orders.length && !this.selectedOrder) {
@@ -1,9 +1,14 @@
/** @odoo-module */
import { formatMonetary } from "@web/views/fields/formatters";
import { formatFloat, roundDecimals } from "@web/core/utils/numbers";
import {
formatFloat,
roundDecimals,
floatIsZero as genericFloatIsZero,
} from "@web/core/utils/numbers";
import { escapeRegExp } from "@web/core/utils/strings";
import { registry } from "@web/core/registry";
import { parseFloat } from "@web/views/fields/parsers";
/**
* This service introduces `utils` namespace in the `env` which can contain
@@ -32,13 +37,19 @@ export const contextualUtilsService = {
return formatFloat(qty, { digits: [true, productUoMDecimals] });
};
const formatStrCurrency = (valueStr, hasSymbol = true) => {
return formatCurrency(parseFloat(valueStr), hasSymbol);
};
const formatCurrency = (value, hasSymbol = true) => {
value = parseFloat(value);
return formatMonetary(value, {
currencyId: currency.id,
noSymbol: !hasSymbol,
});
};
const floatIsZero = (value) => {
return genericFloatIsZero(value, currency.decimal_places);
};
const roundCurrency = (value) => {
return roundDecimals(value, currency.decimal_places);
@@ -50,9 +61,11 @@ export const contextualUtilsService = {
env.utils = {
formatCurrency,
formatStrCurrency,
roundCurrency,
formatProductQty,
isValidFloat,
floatIsZero,
};
},
};