[REF] point_of_sale: move customer display logic to service
Previously, the customer display logic was split among multiple places: the customer display button, the hardwareProxy service and the pos global state. This commit extracts most of the logic to a new service instead, and encapsulates the behaviour of a local display (popup window) and a remote display (through an iot box) in separate classes. Part-of: odoo/odoo#117290
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { effect, Reactive } from "@point_of_sale/utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
export class LocalDisplay extends Reactive {
|
||||
status = "success";
|
||||
constructor() {
|
||||
super();
|
||||
this.setup(...arguments);
|
||||
}
|
||||
setup(globalState) {
|
||||
this.globalState = globalState;
|
||||
}
|
||||
async connect() {
|
||||
this.popupWindow = window.open("", "Customer Display", "height=600,width=900");
|
||||
this.update({ refreshResources: true });
|
||||
}
|
||||
async update({ refreshResources = false } = {}) {
|
||||
if (!this.popupWindow || this.popupWindow.closed) {
|
||||
return;
|
||||
}
|
||||
// TODO: this could probably be improved by loading owl in the popup window,
|
||||
// making it render the customer display, and simply sending messages to
|
||||
// update the state, instead of sending HTML which causes jankiness with
|
||||
// animations.
|
||||
const { body: displayBody, head: displayHead } = this.popupWindow.document;
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = await this.globalState.customerDisplayHTML();
|
||||
|
||||
if (refreshResources) {
|
||||
displayHead.textContent = "";
|
||||
displayHead.appendChild(container.querySelector(".resources"));
|
||||
}
|
||||
|
||||
displayBody.textContent = "";
|
||||
displayBody.appendChild(container.querySelector(".pos-customer_facing_display"));
|
||||
|
||||
const orderLines = displayBody.querySelector(".pos_orderlines_list");
|
||||
orderLines.scrollTop = orderLines.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteDisplay extends Reactive {
|
||||
static serviceDependencies = ["hardware_proxy"];
|
||||
status = "failure";
|
||||
constructor() {
|
||||
super();
|
||||
this.setup(...arguments);
|
||||
}
|
||||
setup(globalState, { hardware_proxy }) {
|
||||
this.hardwareProxy = hardware_proxy;
|
||||
this.globalState = globalState;
|
||||
this.updateStatus();
|
||||
}
|
||||
async connect() {
|
||||
const html = await this.globalState.customerDisplayHTML();
|
||||
try {
|
||||
const { status } = await this.hardwareProxy.message("take_control", { html });
|
||||
this.status = status === "success" ? "success" : "warning";
|
||||
this.updateStatus();
|
||||
} catch (error) {
|
||||
this.status = error === undefined ? "failure" : "not_found";
|
||||
}
|
||||
}
|
||||
async update() {
|
||||
const html = await this.globalState.customerDisplayHTML();
|
||||
if (this.isUpdatingStatus && this.hardwareProxy.connectionInfo.status === "connected") {
|
||||
return this.hardwareProxy.message("customer_facing_display", { html });
|
||||
}
|
||||
}
|
||||
async updateStatus() {
|
||||
if (!this.hardwareProxy.host || this.isUpdatingStatus) {
|
||||
return;
|
||||
}
|
||||
this.isUpdatingStatus = true;
|
||||
while (this.isUpdatingStatus) {
|
||||
try {
|
||||
const { status } = await this.hardwareProxy.message("test_ownership");
|
||||
this.status = status === "OWNER" ? "success" : "warning";
|
||||
} catch (error) {
|
||||
if (error === undefined) {
|
||||
// FIXME POSREF when does this happen?
|
||||
this.status = "failure";
|
||||
} else {
|
||||
this.status = "not_found";
|
||||
this.isUpdatingStatus = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const customerDisplayService = {
|
||||
get dependencies() {
|
||||
// getter so thet the RemoteDisplay dependencies are resolved when services start
|
||||
// and not at module definition time, this allows patches to be accounted for
|
||||
return ["pos", ...RemoteDisplay.serviceDependencies];
|
||||
},
|
||||
start(env, deps) {
|
||||
const { globalState } = deps.pos;
|
||||
|
||||
// Dummy service, does nothing until the globalState is ready, then the methods get replaced
|
||||
const service = {
|
||||
connect() {},
|
||||
};
|
||||
|
||||
globalState.ready.then(() => {
|
||||
const {
|
||||
iface_customer_facing_display: enabled,
|
||||
iface_customer_facing_display_local: local,
|
||||
iface_customer_facing_display_via_proxy: proxy,
|
||||
} = globalState.config;
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let display;
|
||||
if (local) {
|
||||
display = new LocalDisplay(globalState);
|
||||
} else if (proxy) {
|
||||
display = new RemoteDisplay(
|
||||
globalState,
|
||||
pick(deps, ...RemoteDisplay.serviceDependencies)
|
||||
);
|
||||
} else {
|
||||
// FIXME POSREF can this ever happen?
|
||||
return;
|
||||
}
|
||||
// FIXME POSREF this is very inelegant. There are a lot of places that get more complicated
|
||||
// because we want to mount the chrome before the globalState is loaded. Maybe we should
|
||||
// just make the loading screen its own thing that doesn't depend on anything, instead
|
||||
// of having it be a part of the chrome and having to propagate complexity everywhere.
|
||||
env.services.customer_display = display;
|
||||
// Register an effect to update the display automatically when anything it renders changes
|
||||
effect((display) => display.update(), [display]);
|
||||
});
|
||||
|
||||
return service;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("customer_display", customerDisplayService);
|
||||
@@ -23,7 +23,6 @@ export class HardwareProxy extends EventBus {
|
||||
this.useDebugWeight = false;
|
||||
this.host = "";
|
||||
this.keptalive = false;
|
||||
this.customerDisplayAvailable = true;
|
||||
this.connectionInfo = reactive({ status: "disconnected", drivers: {} });
|
||||
effect(
|
||||
(info) => {
|
||||
@@ -191,27 +190,6 @@ export class HardwareProxy extends EventBus {
|
||||
this.debugWeight = 0;
|
||||
}
|
||||
|
||||
updateCustomerDisplay(html) {
|
||||
if (this.customerDisplayAvailable && this.connectionInfo.status == "connected") {
|
||||
return this.message("customer_facing_display", { html }, { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @returns {Promise}
|
||||
*/
|
||||
takeControlOfCustomerDisplay(html) {
|
||||
return this.message("take_control", { html });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise}
|
||||
*/
|
||||
testOwnershipOfCustomerDisplay() {
|
||||
return this.host ? this.message("test_ownership") : Promise.reject({ abort: true });
|
||||
}
|
||||
|
||||
// asks the proxy to log some information, as with the debug.log you can provide several arguments.
|
||||
log() {
|
||||
return this.message("log", { arguments: [...arguments] });
|
||||
|
||||
@@ -2,101 +2,21 @@
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { usePos } from "@point_of_sale/app/pos_hook";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class CustomerFacingDisplayButton extends Component {
|
||||
static template = "CustomerFacingDisplayButton";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hardwareProxy = useService("hardware_proxy");
|
||||
this.local =
|
||||
this.env.pos.config.iface_customer_facing_display_local &&
|
||||
!this.env.pos.config.iface_customer_facing_display_via_proxy;
|
||||
this.state = useState({ status: this.local ? "success" : "failure" });
|
||||
this._start();
|
||||
this.pos = usePos();
|
||||
this.customerDisplay = useState(useService("customer_display"));
|
||||
}
|
||||
get message() {
|
||||
return {
|
||||
success: "",
|
||||
warning: this.env._t("Connected, Not Owned"),
|
||||
failure: this.env._t("Disconnected"),
|
||||
not_found: this.env._t("Customer Screen Unsupported. Please upgrade the IoT Box"),
|
||||
}[this.state.status];
|
||||
}
|
||||
onClick() {
|
||||
if (this.local) {
|
||||
return this.onClickLocal();
|
||||
} else {
|
||||
return this.onClickProxy();
|
||||
}
|
||||
}
|
||||
async onClickLocal() {
|
||||
this.env.pos.customer_display = window.open("", "Customer Display", "height=600,width=900");
|
||||
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
|
||||
var $renderedHtml = $("<div>").html(renderedHtml);
|
||||
$(this.env.pos.customer_display.document.body).html(
|
||||
$renderedHtml.find(".pos-customer_facing_display")
|
||||
);
|
||||
$(this.env.pos.customer_display.document.head).html(
|
||||
$renderedHtml.find(".resources").html()
|
||||
);
|
||||
}
|
||||
async onClickProxy() {
|
||||
try {
|
||||
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
|
||||
let ownership = await this.hardwareProxy.takeControlOfCustomerDisplay(renderedHtml);
|
||||
if (typeof ownership === "string") {
|
||||
ownership = JSON.parse(ownership);
|
||||
}
|
||||
if (ownership.status === "success") {
|
||||
this.state.status = "success";
|
||||
} else {
|
||||
this.state.status = "warning";
|
||||
}
|
||||
if (!this.hardwareProxy.customerDisplayAvailable) {
|
||||
this.hardwareProxy.customerDisplayAvailable = true;
|
||||
this._start();
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof error == "undefined") {
|
||||
this.state.status = "failure";
|
||||
} else {
|
||||
this.state.status = "not_found";
|
||||
}
|
||||
}
|
||||
}
|
||||
_start() {
|
||||
if (this.local) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loop = async () => {
|
||||
if (this.hardwareProxy.customerDisplayAvailable) {
|
||||
try {
|
||||
let ownership = await this.hardwareProxy.testOwnershipOfCustomerDisplay();
|
||||
if (typeof ownership === "string") {
|
||||
ownership = JSON.parse(ownership);
|
||||
}
|
||||
if (ownership.status === "OWNER") {
|
||||
this.state.status = "success";
|
||||
} else {
|
||||
this.state.status = "warning";
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.abort) {
|
||||
// Stop the loop
|
||||
return;
|
||||
}
|
||||
if (typeof error == "undefined") {
|
||||
this.state.status = "failure";
|
||||
} else {
|
||||
this.state.status = "not_found";
|
||||
this.hardwareProxy.customerDisplayAvailable = false;
|
||||
}
|
||||
}
|
||||
setTimeout(loop, 3000);
|
||||
}
|
||||
};
|
||||
loop();
|
||||
warning: _t("Connected, Not Owned"),
|
||||
failure: _t("Disconnected"),
|
||||
not_found: _t("Customer Screen Unsupported. Please upgrade the IoT Box"),
|
||||
}[this.customerDisplay.status];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,41 @@ import { markRaw, reactive } from "@odoo/owl";
|
||||
import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup";
|
||||
import { escape } from "@web/core/utils/strings";
|
||||
import { Mutex } from "@web/core/utils/concurrency";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { renderToString } from "@web/core/utils/render";
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
var round_di = utils.round_decimals;
|
||||
var round_pr = utils.round_precision;
|
||||
const Markup = utils.Markup;
|
||||
|
||||
// Container of the product images fetched during rendering
|
||||
// of customer display. There is no need to observe it, thus,
|
||||
// we are putting it outside of PosGlobalState.
|
||||
const PRODUCT_ID_TO_IMAGE_CACHE = {};
|
||||
/**
|
||||
* Gets a product image as a base64 string so that it can be sent to the
|
||||
* customer display, as the display won't be able to fetch it, since the image
|
||||
* controller requires the client to be logged. This function is memoized on the
|
||||
* product id, so that we will only do this once per product.
|
||||
*
|
||||
* @param {number} productId id of the product
|
||||
* @param {string} writeDate the write date of the product, used as a cache
|
||||
* buster in case the product image has been changed
|
||||
* @returns {string} the base64 representation of the product's image
|
||||
*/
|
||||
const getProductImage = memoize(function getProductImage(productId, writeDate) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const img = new Image();
|
||||
img.addEventListener("load", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.height = img.height;
|
||||
canvas.width = img.width;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
resolve(canvas.toDataURL("image/jpeg"));
|
||||
});
|
||||
img.addEventListener("error", reject);
|
||||
img.src = `/web/image?model=product.product&field=image_128&id=${productId}&unique=${writeDate}`;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* If optimization is needed, then we should implement this
|
||||
@@ -919,105 +943,31 @@ export class PosGlobalState extends PosModel {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
_convert_product_img_to_base64(product, url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var img = new Image();
|
||||
|
||||
img.onload = function () {
|
||||
var canvas = document.createElement("CANVAS");
|
||||
var ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.height = this.height;
|
||||
canvas.width = this.width;
|
||||
ctx.drawImage(this, 0, 0);
|
||||
|
||||
var dataURL = canvas.toDataURL("image/jpeg");
|
||||
canvas = null;
|
||||
|
||||
resolve([product, dataURL]);
|
||||
};
|
||||
img.crossOrigin = "use-credentials";
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
get customer_display() {
|
||||
return this.unwatched.customer_display;
|
||||
}
|
||||
|
||||
set customer_display(value) {
|
||||
this.unwatched.customer_display = markRaw(value);
|
||||
}
|
||||
|
||||
send_current_order_to_customer_facing_display() {
|
||||
var self = this;
|
||||
if (!this.config.iface_customer_facing_display) {
|
||||
/**
|
||||
* Renders the HTML for the customer display and returns it as a string.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
async customerDisplayHTML() {
|
||||
const order = this.get_order();
|
||||
if (!order) {
|
||||
return;
|
||||
}
|
||||
this.render_html_for_customer_facing_display().then((rendered_html) => {
|
||||
if (self.env.pos.customer_display) {
|
||||
var $renderedHtml = $("<div>").html(rendered_html);
|
||||
$(self.env.pos.customer_display.document.body).html(
|
||||
$renderedHtml.find(".pos-customer_facing_display")
|
||||
);
|
||||
var orderlines = $(self.env.pos.customer_display.document.body).find(
|
||||
".pos_orderlines_list"
|
||||
);
|
||||
orderlines.scrollTop(orderlines.prop("scrollHeight"));
|
||||
} else if (
|
||||
this.config.iface_customer_facing_display_via_proxy &&
|
||||
this.hardwareProxy.customerDisplayAvailable
|
||||
) {
|
||||
this.hardwareProxy.updateCustomerDisplay(rendered_html);
|
||||
}
|
||||
});
|
||||
}
|
||||
const orderLines = order.get_orderlines();
|
||||
const productImages = Object.fromEntries(
|
||||
await Promise.all(
|
||||
orderLines.map(async ({ product }) => [
|
||||
product.id,
|
||||
await getProductImage(product.id, product.writeDate),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
render_html_for_customer_facing_display() {
|
||||
var self = this;
|
||||
var order = this.get_order();
|
||||
|
||||
// If we're using an external device like the IoT Box, we
|
||||
// cannot get /web/image?model=product.product because the
|
||||
// IoT Box is not logged in and thus doesn't have the access
|
||||
// rights to access product.product. So instead we'll base64
|
||||
// encode it and embed it in the HTML.
|
||||
var get_image_promises = [];
|
||||
|
||||
if (order) {
|
||||
order.get_orderlines().forEach(function (orderline) {
|
||||
var product = orderline.product;
|
||||
var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
|
||||
|
||||
// only download and convert image if we haven't done it before
|
||||
if (!(product.id in PRODUCT_ID_TO_IMAGE_CACHE)) {
|
||||
get_image_promises.push(
|
||||
self._convert_product_img_to_base64(product, image_url)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all(get_image_promises).then(function (productIdImagePairs) {
|
||||
for (const [product, image] of productIdImagePairs) {
|
||||
PRODUCT_ID_TO_IMAGE_CACHE[product.id] = image;
|
||||
}
|
||||
// Collect the product images that will be used in rendering the customer display template.
|
||||
const productImages = {};
|
||||
if (order) {
|
||||
for (const line of order.get_orderlines()) {
|
||||
productImages[line.product.id] = PRODUCT_ID_TO_IMAGE_CACHE[line.product.id];
|
||||
}
|
||||
}
|
||||
return QWeb.render("CustomerFacingDisplayOrder", {
|
||||
pos: self,
|
||||
origin: window.location.origin,
|
||||
order: order,
|
||||
productImages,
|
||||
});
|
||||
return renderToString("CustomerFacingDisplayOrder", {
|
||||
pos: this,
|
||||
origin: window.location.origin,
|
||||
order,
|
||||
productImages,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ td {
|
||||
height: 0px;
|
||||
}
|
||||
.pos .subwindow .subwindow-container{
|
||||
height: 100%;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
}
|
||||
@@ -1440,6 +1441,7 @@ td {
|
||||
height:100%;
|
||||
}
|
||||
.pos .screen .content-cell{
|
||||
height: 100%;
|
||||
width:100%;
|
||||
}
|
||||
.pos .screen .content-cell .content-container{
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="CustomerFacingDisplayButton" owl="1">
|
||||
<div class="oe_status" t-on-click="onClick">
|
||||
<div class="oe_status" t-on-click="() => customerDisplay.connect()">
|
||||
<span class="message"><t t-esc="message" /></span>
|
||||
<div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange">
|
||||
<div t-if="customerDisplay.status === 'warning'" class="js_warning oe_icon oe_orange">
|
||||
<i class="fa fa-fw fa-desktop" role="img" aria-label="Customer Screen Warning" title="Customer Screen Warning"></i>
|
||||
</div>
|
||||
<div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red">
|
||||
<div t-if="customerDisplay.status === 'failure'" class="js_disconnected oe_icon oe_red">
|
||||
<i class="fa fa-fw fa-desktop" role="img" aria-label="Customer Screen Disconnected" title="Customer Screen Disconnected"></i>
|
||||
</div>
|
||||
<div t-if="state.status === 'success'" class="js_connected oe_icon oe_green">
|
||||
<div t-if="customerDisplay.status === 'success'" class="js_connected oe_icon oe_green">
|
||||
<i class="fa fa-fw fa-desktop" role="img" aria-label="Customer Screen Connected" title="Customer Screen Connected"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-7
@@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="CustomerFacingDisplayHead">
|
||||
<t t-name="CustomerFacingDisplayHead" owl="1">
|
||||
<div class="resources">
|
||||
<base t-att-href="origin"/>
|
||||
<meta http-equiv="cache-control" content="no-cache" />
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<link rel="stylesheet" type="text/css" href="/web/static/lib/bootstrap/css/bootstrap.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/web/static/lib/bootstrap/dist/css/bootstrap.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/web/static/src/libs/fontawesome/css/font-awesome.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/point_of_sale/static/src/css/customer_facing_display.css"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="CustomerFacingDisplayOrder">
|
||||
<t t-name="CustomerFacingDisplayOrder" owl="1">
|
||||
<!-- Header -->
|
||||
<t t-call="CustomerFacingDisplayHead"/>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="CustomerFacingDisplayOrderLines">
|
||||
<t t-name="CustomerFacingDisplayOrderLines" owl="1">
|
||||
<div class="pos_orderlines">
|
||||
<div class="pos_orderlines_item pos_orderlines_header">
|
||||
<div/>
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
<div class="pos_orderlines_list">
|
||||
<t t-if="order">
|
||||
<div t-foreach="order.get_orderlines()" t-as="orderline" class="pos_orderlines_item">
|
||||
<div t-foreach="order.get_orderlines()" t-as="orderline" t-key="orderline_index" class="pos_orderlines_item">
|
||||
<div><div t-attf-style="background-image:url(#{productImages[orderline.product.id]})"/></div>
|
||||
<div t-esc="orderline.get_full_product_name()"/>
|
||||
<div t-esc="orderline.get_quantity_str()"/>
|
||||
@@ -70,10 +70,10 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="CustomerFacingDisplayPaymentLines">
|
||||
<t t-name="CustomerFacingDisplayPaymentLines" owl="1">
|
||||
<div class="pos-paymentlines">
|
||||
<t t-if="order">
|
||||
<t t-foreach="order.get_paymentlines()" t-as="paymentline">
|
||||
<t t-foreach="order.get_paymentlines()" t-as="paymentline" t-key="paymentline_index">
|
||||
<div>
|
||||
<span><t t-esc="paymentline.name"/>:</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user