Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
807
addons/laundry_management/static/src/js/pos_store_patch.js
Normal file
807
addons/laundry_management/static/src/js/pos_store_patch.js
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
/** @odoo-module
|
||||||
|
*
|
||||||
|
* PosStore patch — laundry order type / attributes / delivery popup chain.
|
||||||
|
*
|
||||||
|
* Two storage paths kept in sync:
|
||||||
|
* 1. pos.laundryContext (OWL reactive) — drives panel + receipt re-render.
|
||||||
|
* 2. order.laundry_* primitives — drives serializeForORM payload.
|
||||||
|
*
|
||||||
|
* STRICT CONTRACT:
|
||||||
|
* - Does NOT override PosOrder.setup beyond seeding scalar defaults
|
||||||
|
* (see pos_order_patch.js)
|
||||||
|
* - Does NOT touch order.lines, pricing, or taxes
|
||||||
|
* - Does NOT push laundry fields through _load_pos_data_fields
|
||||||
|
*/
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { PosStore } from "@point_of_sale/app/services/pos_store";
|
||||||
|
import { ask, makeAwaitable } from "@point_of_sale/app/utils/make_awaitable_dialog";
|
||||||
|
import { LaundryContextStore } from "@laundry_management/js/laundry_context_store";
|
||||||
|
import { LaundryQuickCreatePartner } from "@laundry_management/js/quick_create_partner";
|
||||||
|
import { LaundryOrdersViewPopup } from "@laundry_management/js/view_laundry_orders";
|
||||||
|
import { LaundryOrderTypePopup } from "@laundry_management/js/popups/laundry_order_type_popup";
|
||||||
|
import { LaundryOrderAttributePopup } from "@laundry_management/js/popups/laundry_order_attribute_popup";
|
||||||
|
import { LaundryDeliveryDetailsPopup } from "@laundry_management/js/popups/laundry_delivery_details_popup";
|
||||||
|
|
||||||
|
function hasLaundryProduct(order) {
|
||||||
|
return order.lines.some(
|
||||||
|
(line) => line.product_id?.product_tmpl_id?.is_laundry_service
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLaundryServiceLine(line) {
|
||||||
|
const tmpl = line.product_id?.product_tmpl_id;
|
||||||
|
return tmpl?.is_laundry_service && !tmpl?.is_laundry_settlement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toId(v) {
|
||||||
|
if (!v) return false;
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
if (typeof v === "object" && Number.isInteger(v.id)) return v.id;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIds(arr) {
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const v of arr) {
|
||||||
|
const id = toId(v);
|
||||||
|
if (id) seen.add(id);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateTime(value) {
|
||||||
|
if (!value || typeof value !== "string") return false;
|
||||||
|
|
||||||
|
let v = value;
|
||||||
|
|
||||||
|
// Remove timezone like +03:00 or -03:00
|
||||||
|
const tzMatch = v.match(/([+-]\d{2}:\d{2})$/);
|
||||||
|
if (tzMatch) {
|
||||||
|
v = v.replace(tzMatch[0], "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Z
|
||||||
|
if (v.endsWith("Z")) {
|
||||||
|
v = v.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove milliseconds
|
||||||
|
if (v.includes(".")) {
|
||||||
|
v = v.split(".")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace T with space
|
||||||
|
v = v.replace("T", " ");
|
||||||
|
|
||||||
|
// Ensure seconds exist
|
||||||
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(v)) {
|
||||||
|
v += ":00";
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(PosStore.prototype, {
|
||||||
|
setup(...args) {
|
||||||
|
const result = super.setup(...args);
|
||||||
|
if (!this.laundryContext) {
|
||||||
|
this.laundryContext = new LaundryContextStore();
|
||||||
|
}
|
||||||
|
if (this._settleDuesInFlight === undefined) {
|
||||||
|
this._settleDuesInFlight = false;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensive override for the core configurator helper.
|
||||||
|
*
|
||||||
|
* In Odoo 19 core, `doHaveConflictWith` accesses `value.id` and
|
||||||
|
* `selectedValues.map(v => v.id)` with no null-check. When the
|
||||||
|
* loaded POS data has even ONE incomplete PTAV reference (most
|
||||||
|
* commonly after a partial product re-seed, attribute rename, or
|
||||||
|
* orphan ir_model_data row), the configurator render throws:
|
||||||
|
*
|
||||||
|
* TypeError: can't access property "id", value is undefined
|
||||||
|
* at doHaveConflictWith
|
||||||
|
*
|
||||||
|
* The exception leaves the POS in an unrecoverable state mid-popup
|
||||||
|
* and the cashier can't add the laundry product at all.
|
||||||
|
*
|
||||||
|
* This guard filters out null/undefined entries and short-circuits
|
||||||
|
* to "no conflict" when `value` itself is malformed. It changes
|
||||||
|
* NO business behavior — exclusions still apply for valid PTAVs.
|
||||||
|
*/
|
||||||
|
doHaveConflictWith(value, selectedValues) {
|
||||||
|
if (!value || value.id === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const safeSelected = Array.isArray(selectedValues)
|
||||||
|
? selectedValues.filter((v) => v && v.id !== undefined)
|
||||||
|
: [];
|
||||||
|
return super.doHaveConflictWith(value, safeSelected);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── POS Mode (settle_due isolation) ──────────────────────────────────
|
||||||
|
isSettleDueOrder(order) {
|
||||||
|
return !!order?.uiState?.is_laundry_settle_due;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reactive accessor for templates: "sale" | "settle_due". */
|
||||||
|
get currentLaundryMode() {
|
||||||
|
return this.isSettleDueOrder(this.getOrder()) ? "settle_due" : "sale";
|
||||||
|
},
|
||||||
|
|
||||||
|
_markSettleDue(order) {
|
||||||
|
if (order?.uiState) {
|
||||||
|
order.uiState.is_laundry_settle_due = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearSettleDue(order) {
|
||||||
|
if (order?.uiState) {
|
||||||
|
order.uiState.is_laundry_settle_due = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier-initiated exit from settle mode.
|
||||||
|
* ALWAYS drops the settlement order AND creates a fresh clean sale
|
||||||
|
* order — never leaves the POS in a zero-order state.
|
||||||
|
*
|
||||||
|
* Confirmation tone differs based on what the cashier has staged:
|
||||||
|
* • No payment lines yet → light confirm ("Cancel settlement?").
|
||||||
|
* • Amount already entered → stronger confirm warning that the
|
||||||
|
* payment lines on this settle order will be discarded.
|
||||||
|
* Cleanup is the same in both cases — the settle order is removed
|
||||||
|
* locally so any payment lines on it go with it.
|
||||||
|
*/
|
||||||
|
async exitSettleDueMode() {
|
||||||
|
const order = this.getOrder();
|
||||||
|
if (!this.isSettleDueOrder(order)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasPaymentLines = (order.payment_ids || []).some(
|
||||||
|
(p) => (p.amount || 0) > 0
|
||||||
|
);
|
||||||
|
const dialogProps = hasPaymentLines
|
||||||
|
? {
|
||||||
|
title: _t("Discard settlement payment?"),
|
||||||
|
body: _t(
|
||||||
|
"Settlement has payment lines entered. Leaving now "
|
||||||
|
+ "discards them. Continue?"
|
||||||
|
),
|
||||||
|
confirmLabel: _t("Discard & Exit"),
|
||||||
|
cancelLabel: _t("Stay"),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: _t("Cancel settlement and return to POS?"),
|
||||||
|
body: _t(
|
||||||
|
"No payment entered yet. The settlement will be "
|
||||||
|
+ "cancelled and you will return to the product screen."
|
||||||
|
),
|
||||||
|
confirmLabel: _t("Exit"),
|
||||||
|
cancelLabel: _t("Stay"),
|
||||||
|
};
|
||||||
|
const confirmed = await ask(this.env.services.dialog, dialogProps);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._clearSettleDue(order);
|
||||||
|
this.removeOrder(order, false);
|
||||||
|
// Always hand the cashier a clean new sale order — no stranded state.
|
||||||
|
this.addNewOrder();
|
||||||
|
this.navigate("ProductScreen");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nuke every open order in the POS except an optional keeper. Used
|
||||||
|
* when entering settle-due mode to enforce single-order isolation.
|
||||||
|
*
|
||||||
|
* DATA-LOSS PROTECTION: if any of the to-be-removed orders still has
|
||||||
|
* lines, prompt ONCE before discarding. Returns true if the removal
|
||||||
|
* completed, false if the cashier cancelled.
|
||||||
|
*/
|
||||||
|
async _removeAllOpenOrders({ except = null } = {}) {
|
||||||
|
const keepUuid = except?.uuid || null;
|
||||||
|
const snapshot = this.getOpenOrders().slice();
|
||||||
|
const doomed = snapshot.filter((o) => o.uuid !== keepUuid);
|
||||||
|
if (!doomed.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const hasLines = doomed.some((o) => (o.lines?.length || 0) > 0);
|
||||||
|
if (hasLines) {
|
||||||
|
const confirmed = await ask(this.env.services.dialog, {
|
||||||
|
title: _t("Discard Open Orders?"),
|
||||||
|
body: _t(
|
||||||
|
"Starting a settlement will discard %s open order(s) that still contain items. Continue?",
|
||||||
|
doomed.length
|
||||||
|
),
|
||||||
|
confirmLabel: _t("Continue"),
|
||||||
|
cancelLabel: _t("Cancel"),
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const o of doomed) {
|
||||||
|
this._clearSettleDue(o);
|
||||||
|
this.removeOrder(o, false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation gate — every cashier action that would switch the active
|
||||||
|
* order context (tab click, "+" new order, navbar register click)
|
||||||
|
* must funnel through this helper FIRST.
|
||||||
|
*
|
||||||
|
* targetOrder = null → "create new order" intent
|
||||||
|
* targetOrder = some open order → "switch to that order" intent
|
||||||
|
*
|
||||||
|
* Returns true if the navigation should proceed, false if blocked.
|
||||||
|
* If the user confirms the exit, the active settle order is dropped
|
||||||
|
* here so callers can proceed without further cleanup.
|
||||||
|
*/
|
||||||
|
async confirmExitSettleIfNeeded(targetOrder) {
|
||||||
|
const current = this.getOrder();
|
||||||
|
if (!this.isSettleDueOrder(current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Switching to the same settle order itself is a no-op for safety.
|
||||||
|
if (targetOrder && targetOrder.uuid === current.uuid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const confirmed = await ask(this.env.services.dialog, {
|
||||||
|
title: _t("Exit Settle Dues Mode?"),
|
||||||
|
body: _t(
|
||||||
|
"You are settling dues for %s. Leaving will cancel the current settlement.",
|
||||||
|
current.getPartner()?.name || _t("a customer")
|
||||||
|
),
|
||||||
|
confirmLabel: _t("Exit"),
|
||||||
|
cancelLabel: _t("Cancel"),
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._clearSettleDue(current);
|
||||||
|
this.removeOrder(current, false);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard guard: while a settle-due order is active, the only line that
|
||||||
|
* can land on it is the settlement product itself. Any other product
|
||||||
|
* click is silently rejected with a notification.
|
||||||
|
*/
|
||||||
|
async addLineToCurrentOrder(vals, opts = {}, configure = true) {
|
||||||
|
const order = this.getOrder();
|
||||||
|
if (this.isSettleDueOrder(order)) {
|
||||||
|
const tmplVal = vals?.product_tmpl_id;
|
||||||
|
const tmpl = typeof tmplVal === "number"
|
||||||
|
? this.data.models["product.template"]?.get(tmplVal)
|
||||||
|
: tmplVal;
|
||||||
|
if (!tmpl?.is_laundry_settlement) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Cannot add products while settling dues. Exit settle mode first."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Laundry configuration now runs through the CORE Odoo configurator
|
||||||
|
// (ProductConfiguratorPopup). We don't intercept here — core's
|
||||||
|
// handleConfigurableProduct / openConfigurator fires naturally for
|
||||||
|
// any template whose attributes expose >1 value. We only enhance
|
||||||
|
// the popup's presentation via XML inheritance + scoped SCSS
|
||||||
|
// (see xml/product_configurator_popup.xml).
|
||||||
|
return super.addLineToCurrentOrder(vals, opts, configure);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HARD BLOCK: once a settle-due order is active, no new orders can
|
||||||
|
* be created. No confirm — just stop. Any path that tries to create
|
||||||
|
* a new order during settle mode is either a mistake or an escape
|
||||||
|
* route we haven't caught upstream; either way, block it.
|
||||||
|
*/
|
||||||
|
addNewOrder(data = {}) {
|
||||||
|
if (this.isSettleDueOrder(this.getOrder())) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Finish settlement first."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return super.addNewOrder(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HARD CONTROL on order switching. If the active order is settle-due
|
||||||
|
* and the target is a different order:
|
||||||
|
* - ask confirmation
|
||||||
|
* - cancel → block the switch
|
||||||
|
* - confirm → drop the settle order cleanly, then proceed
|
||||||
|
*
|
||||||
|
* The function becomes async. Native sync callers (e.g. TicketScreen)
|
||||||
|
* do not await, but the user-facing UI gates (OrderTabs.selectFloating-
|
||||||
|
* Order, Navbar.onClickRegister) run confirmExitSettleIfNeeded first
|
||||||
|
* and clear settle mode before super.setOrder runs, so this patch is
|
||||||
|
* defense-in-depth for any escape route that bypasses those gates.
|
||||||
|
*/
|
||||||
|
async setOrder(order) {
|
||||||
|
const current = this.getOrder();
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
this.isSettleDueOrder(current) &&
|
||||||
|
order &&
|
||||||
|
order.uuid !== current.uuid
|
||||||
|
) {
|
||||||
|
const confirmed = await ask(this.env.services.dialog, {
|
||||||
|
title: _t("Exit Settle Dues Mode?"),
|
||||||
|
body: _t("The current settlement will be cancelled."),
|
||||||
|
confirmLabel: _t("Exit"),
|
||||||
|
cancelLabel: _t("Cancel"),
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._clearSettleDue(current);
|
||||||
|
this.removeOrder(current, false);
|
||||||
|
}
|
||||||
|
return super.setOrder(order);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Always release settle-state when an order is dropped (success OR exit). */
|
||||||
|
removeOrder(order, removeFromServer = true) {
|
||||||
|
if (order?.uiState) {
|
||||||
|
order.uiState.is_laundry_settle_due = false;
|
||||||
|
}
|
||||||
|
return super.removeOrder(order, removeFromServer);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-safe navigation: while a settle-due order is active, the ONLY
|
||||||
|
* legitimate screen is PaymentScreen. Any other navigation target is
|
||||||
|
* rewritten to PaymentScreen so the cashier never lands on a screen
|
||||||
|
* that would corrupt the settlement context (no blank screen, no
|
||||||
|
* stranded products on the wrong order).
|
||||||
|
*
|
||||||
|
* BUT: when the cashier presses Back (PaymentScreen → ProductScreen),
|
||||||
|
* silently snapping them back used to feel like a trap. We now also
|
||||||
|
* SCHEDULE the existing exit-confirmation dialog on the next
|
||||||
|
* microtask. The cashier sees PaymentScreen for one frame, then a
|
||||||
|
* clear "Cancel settlement?" dialog. Confirming → settle order
|
||||||
|
* removed, fresh sale order, ProductScreen. Cancelling → still on
|
||||||
|
* PaymentScreen, can keep settling. No new buttons, no XML changes.
|
||||||
|
*
|
||||||
|
* Legit exit paths (exitSettleDueMode itself, removeOrder during
|
||||||
|
* payment success) clear the settle flag BEFORE calling navigate,
|
||||||
|
* so they pass through unaffected and never trigger the prompt.
|
||||||
|
*/
|
||||||
|
navigate(routeName, routeParams = {}) {
|
||||||
|
const current = this.getOrder();
|
||||||
|
if (
|
||||||
|
this.isSettleDueOrder(current) &&
|
||||||
|
routeName !== "PaymentScreen"
|
||||||
|
) {
|
||||||
|
// Schedule exit-confirmation but don't await — keep navigate
|
||||||
|
// sync so callers that don't await still work cleanly.
|
||||||
|
Promise.resolve().then(() => this.exitSettleDueMode());
|
||||||
|
return super.navigate("PaymentScreen", { orderUuid: current.uuid });
|
||||||
|
}
|
||||||
|
return super.navigate(routeName, routeParams);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Mirror a patch into BOTH the reactive store and the order primitives. */
|
||||||
|
_writeLaundryContext(order, patchObj) {
|
||||||
|
if (!order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reactive store — drives panel + receipt re-render.
|
||||||
|
this.laundryContext.set(order.uuid, patchObj);
|
||||||
|
// Order primitives — drive serializeForORM payload.
|
||||||
|
if ("type_id" in patchObj) {
|
||||||
|
order.laundry_order_type_id = patchObj.type_id || false;
|
||||||
|
}
|
||||||
|
if ("attribute_ids" in patchObj) {
|
||||||
|
order.laundry_order_attribute_ids = Array.isArray(patchObj.attribute_ids)
|
||||||
|
? patchObj.attribute_ids.slice()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
if ("is_delivery" in patchObj) {
|
||||||
|
order.laundry_is_delivery = !!patchObj.is_delivery;
|
||||||
|
}
|
||||||
|
if ("delivery_address" in patchObj) {
|
||||||
|
order.laundry_delivery_address = patchObj.delivery_address || false;
|
||||||
|
}
|
||||||
|
if ("delivery_scheduled_at" in patchObj) {
|
||||||
|
order.laundry_delivery_scheduled_at =
|
||||||
|
patchObj.delivery_scheduled_at || false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async postSyncAllOrders(orders) {
|
||||||
|
await super.postSyncAllOrders(orders);
|
||||||
|
for (const order of orders) {
|
||||||
|
if (order.laundry_order_id) {
|
||||||
|
this.notification.add(_t("Laundry Order Created"), {
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pay() {
|
||||||
|
const currentOrder = this.getOrder();
|
||||||
|
if (!currentOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Settle-due orders already have a partner (gated in settleLaundryDues).
|
||||||
|
if (
|
||||||
|
!this.isSettleDueOrder(currentOrder) &&
|
||||||
|
hasLaundryProduct(currentOrder) &&
|
||||||
|
!currentOrder.getPartner()
|
||||||
|
) {
|
||||||
|
const confirmed = await ask(this.env.services.dialog, {
|
||||||
|
title: _t("Customer Required"),
|
||||||
|
body: _t(
|
||||||
|
"This order contains laundry items. Please select or create a customer."
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
const partner = await this.selectPartner();
|
||||||
|
if (!partner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.pay();
|
||||||
|
},
|
||||||
|
|
||||||
|
async editPartner(partner) {
|
||||||
|
if (partner) {
|
||||||
|
return super.editPartner(partner);
|
||||||
|
}
|
||||||
|
const result = await makeAwaitable(this.dialog, LaundryQuickCreatePartner, {});
|
||||||
|
if (!result) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const existing = await this.data.call(
|
||||||
|
"res.partner",
|
||||||
|
"laundry_find_by_phone",
|
||||||
|
[result.phone]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
const useExisting = await ask(this.env.services.dialog, {
|
||||||
|
title: _t("Customer Already Exists"),
|
||||||
|
body: _t(
|
||||||
|
'A customer "%s" already exists with this phone number. Use existing customer?',
|
||||||
|
existing.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (useExisting) {
|
||||||
|
const partners = await this.data.read("res.partner", [existing.id]);
|
||||||
|
return partners[0] || false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const partnerId = await this.data.call(
|
||||||
|
"res.partner",
|
||||||
|
"laundry_quick_create",
|
||||||
|
[{ name: result.name, phone: result.phone, street: result.street || "" }]
|
||||||
|
);
|
||||||
|
const partners = await this.data.read("res.partner", [partnerId]);
|
||||||
|
return partners[0] || false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async settleLaundryDues() {
|
||||||
|
// Re-entrancy guard — defends against double-clicks while we
|
||||||
|
// hit the server for dues / before the order is materialized.
|
||||||
|
if (this._settleDuesInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._settleDuesInFlight = true;
|
||||||
|
try {
|
||||||
|
const currentOrder = this.getOrder();
|
||||||
|
const partner = currentOrder?.getPartner();
|
||||||
|
if (!partner) {
|
||||||
|
this.notification.add(_t("Please select a customer first."), {
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
const selectedPartner = await this.selectPartner();
|
||||||
|
if (!selectedPartner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._settleDuesInFlight = false;
|
||||||
|
return this.settleLaundryDues();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse an existing settle-due order for the same partner
|
||||||
|
// instead of creating a duplicate one. Whether we reuse or
|
||||||
|
// create fresh, SINGLE-ORDER MODE must hold: every other
|
||||||
|
// open order is removed before we navigate.
|
||||||
|
const existing = this.getOpenOrders().find(
|
||||||
|
(o) =>
|
||||||
|
this.isSettleDueOrder(o) &&
|
||||||
|
o.getPartner()?.id === partner.id
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
const ok = await this._removeAllOpenOrders({ except: existing });
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Our patched setOrder short-circuits when target === current
|
||||||
|
// settle order; safe to call here.
|
||||||
|
this.setOrder(existing);
|
||||||
|
this.navigate("PaymentScreen", { orderUuid: existing.uuid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dues = await this.data.call(
|
||||||
|
"res.partner",
|
||||||
|
"get_laundry_dues",
|
||||||
|
[partner.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dues || dues.total_due <= 0) {
|
||||||
|
this.notification.add(
|
||||||
|
_t('"%s" has no outstanding laundry dues.', partner.name),
|
||||||
|
{ type: "info" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settlementProduct = this.models["product.product"]
|
||||||
|
.getAll()
|
||||||
|
.find((p) => p.product_tmpl_id?.is_laundry_settlement);
|
||||||
|
|
||||||
|
if (!settlementProduct) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Settlement product not configured. Please check laundry settings."),
|
||||||
|
{ type: "danger" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SINGLE-ORDER MODE ─────────────────────────────────────
|
||||||
|
// Nuke every open order BEFORE creating the settle order.
|
||||||
|
// Data-loss protection lives inside _removeAllOpenOrders —
|
||||||
|
// it prompts once if any order has lines and returns false
|
||||||
|
// if the cashier cancels.
|
||||||
|
const ok = await this._removeAllOpenOrders();
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = this.addNewOrder();
|
||||||
|
|
||||||
|
// Mark BEFORE adding the settlement line so any concurrent
|
||||||
|
// attempt to add other products to this order is blocked.
|
||||||
|
// The settlement product itself is whitelisted in
|
||||||
|
// addLineToCurrentOrder.
|
||||||
|
this._markSettleDue(order);
|
||||||
|
order.setPartner(partner);
|
||||||
|
|
||||||
|
await this.addLineToCurrentOrder(
|
||||||
|
{
|
||||||
|
product_id: settlementProduct,
|
||||||
|
product_tmpl_id: settlementProduct.product_tmpl_id,
|
||||||
|
price_unit: dues.total_due,
|
||||||
|
qty: 1,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
this.navigate("PaymentScreen", {
|
||||||
|
orderUuid: order.uuid,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this._settleDuesInFlight = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** True iff order has at least one laundry-service line (excludes settlement). */
|
||||||
|
orderHasLaundryServiceLine(order) {
|
||||||
|
return !!order?.lines?.some(isLaundryServiceLine);
|
||||||
|
},
|
||||||
|
|
||||||
|
async runLaundryOrderTypeFlow(order) {
|
||||||
|
if (!order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = this.config;
|
||||||
|
|
||||||
|
// Step 1 — Main type
|
||||||
|
const types = (this.models["laundry.order.type"]?.getAll() || [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
|
||||||
|
if (!types.length) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("No laundry order types configured. Ask a manager to add some."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
if (config.require_laundry_order_type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = order.getPartner();
|
||||||
|
const ctx = this.laundryContext.get(order.uuid);
|
||||||
|
const defaultTypeId =
|
||||||
|
toId(ctx.type_id) ||
|
||||||
|
toId(order.laundry_order_type_id) ||
|
||||||
|
toId(partner?.default_laundry_order_type_id) ||
|
||||||
|
toId(config.default_laundry_order_type_id) ||
|
||||||
|
false;
|
||||||
|
|
||||||
|
const chosenType = await makeAwaitable(this.dialog, LaundryOrderTypePopup, {
|
||||||
|
types: types,
|
||||||
|
defaultTypeId: defaultTypeId,
|
||||||
|
showIcons: !!config.show_order_type_icons,
|
||||||
|
allowSkip: !config.require_laundry_order_type,
|
||||||
|
title: _t("Select Order Type"),
|
||||||
|
});
|
||||||
|
if (chosenType === undefined) {
|
||||||
|
return; // user closed without confirming
|
||||||
|
}
|
||||||
|
|
||||||
|
this._writeLaundryContext(order, {
|
||||||
|
type_id: chosenType ? chosenType.id : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2 — Attributes
|
||||||
|
let chosenAttributes = [];
|
||||||
|
if (config.enable_laundry_attributes) {
|
||||||
|
const allAttrs = (this.models["laundry.order.attribute"]?.getAll() || [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
|
||||||
|
if (allAttrs.length) {
|
||||||
|
const suggestedIds = toIds(chosenType?.attribute_ids);
|
||||||
|
const partnerDefaultIds = toIds(partner?.default_laundry_attribute_ids);
|
||||||
|
const preselected = [
|
||||||
|
...new Set([...suggestedIds, ...partnerDefaultIds]),
|
||||||
|
];
|
||||||
|
const result = await makeAwaitable(
|
||||||
|
this.dialog,
|
||||||
|
LaundryOrderAttributePopup,
|
||||||
|
{
|
||||||
|
attributes: allAttrs,
|
||||||
|
preselectedIds: preselected,
|
||||||
|
title: _t("Select Attributes (optional)"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result !== undefined) {
|
||||||
|
chosenAttributes = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._writeLaundryContext(order, {
|
||||||
|
attribute_ids: chosenAttributes.map((a) => a.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3 — Delivery details
|
||||||
|
const typeIsDelivery = !!chosenType?.is_delivery;
|
||||||
|
const attrIsDelivery = chosenAttributes.some((a) => a.is_delivery_related);
|
||||||
|
const isDelivery = typeIsDelivery || attrIsDelivery;
|
||||||
|
this._writeLaundryContext(order, { is_delivery: isDelivery });
|
||||||
|
|
||||||
|
if (isDelivery && config.require_delivery_details_if_needed) {
|
||||||
|
const requireAddress =
|
||||||
|
!!chosenType?.requires_address || attrIsDelivery;
|
||||||
|
const requireTime = !!chosenType?.requires_scheduled_time;
|
||||||
|
const result = await makeAwaitable(
|
||||||
|
this.dialog,
|
||||||
|
LaundryDeliveryDetailsPopup,
|
||||||
|
{
|
||||||
|
defaultAddress: ctx.delivery_address || "",
|
||||||
|
defaultScheduledAt: ctx.delivery_scheduled_at || "",
|
||||||
|
requireAddress: requireAddress,
|
||||||
|
requireScheduledTime: requireTime,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
this._writeLaundryContext(order, {
|
||||||
|
delivery_address: result.address || false,
|
||||||
|
delivery_scheduled_at: normalizeDateTime(result.scheduledAt) || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!isDelivery) {
|
||||||
|
this._writeLaundryContext(order, {
|
||||||
|
delivery_address: false,
|
||||||
|
delivery_scheduled_at: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async editLaundryOrderType() {
|
||||||
|
const order = this.getOrder();
|
||||||
|
if (!order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.config?.allow_change_laundry_order_type_before_payment) {
|
||||||
|
this.notification.add(_t("Changing order type is disabled."), {
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.runLaundryOrderTypeFlow(order);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point invoked by the laundry-orders popup's "Collect Payment"
|
||||||
|
* button. Reuses the existing settleLaundryDues flow end-to-end so
|
||||||
|
* the well-tested settle-due isolation, navigation guards, and
|
||||||
|
* accounting RPC are inherited verbatim — no parallel collection
|
||||||
|
* pipeline, no new payment logic, no accounting changes.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Resolve the partner from POS data.
|
||||||
|
* 2. Make sure the active POS order has that partner set so
|
||||||
|
* settleLaundryDues doesn't pop the partner-selection dialog
|
||||||
|
* again.
|
||||||
|
* 3. Hand off to settleLaundryDues — which:
|
||||||
|
* • drops every other open order (with a confirm if any
|
||||||
|
* carry lines) so the cashier is in a single-order state,
|
||||||
|
* • spins up the settle-due context order,
|
||||||
|
* • hides Customer Account / pay-later methods,
|
||||||
|
* • navigates to PaymentScreen,
|
||||||
|
* • on Validate calls settle_laundry_dues_rpc → cash bank
|
||||||
|
* statement OR account.payment + reconcile + FIFO
|
||||||
|
* distribution across the customer's open laundry orders
|
||||||
|
* (this order included).
|
||||||
|
*
|
||||||
|
* Idempotent: if a settle-due is already active for the same
|
||||||
|
* partner, settleLaundryDues reuses it instead of creating a
|
||||||
|
* duplicate.
|
||||||
|
*/
|
||||||
|
async collectLaundryOrderPayment(partnerId) {
|
||||||
|
if (!partnerId) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Cannot collect payment: customer not specified."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const partner = this.models["res.partner"].get(partnerId);
|
||||||
|
if (!partner) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Customer not loaded in POS data. Refresh POS and retry."),
|
||||||
|
{ type: "danger" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let currentOrder = this.getOrder();
|
||||||
|
if (!currentOrder) {
|
||||||
|
currentOrder = this.addNewOrder();
|
||||||
|
}
|
||||||
|
const cur = currentOrder?.getPartner();
|
||||||
|
if (!cur || cur.id !== partner.id) {
|
||||||
|
currentOrder?.setPartner(partner);
|
||||||
|
}
|
||||||
|
await this.settleLaundryDues();
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewLaundryOrders() {
|
||||||
|
const currentOrder = this.getOrder();
|
||||||
|
let partner = currentOrder?.getPartner();
|
||||||
|
if (!partner) {
|
||||||
|
partner = await this.selectPartner();
|
||||||
|
if (!partner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Popup owns its own data lifecycle — fetches on mount + search,
|
||||||
|
// refetches the affected order after each workflow action.
|
||||||
|
this.dialog.add(LaundryOrdersViewPopup, {
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
// `mobile` is optional in some Odoo distributions — guard.
|
||||||
|
partnerPhone: partner.phone || partner.mobile || "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user