From 70d43d34ca86b3dd84840f4cdfc9ef7d98f01dfb Mon Sep 17 00:00:00 2001 From: git_admin Date: Fri, 1 May 2026 15:00:36 +0000 Subject: [PATCH] Tower: upload laundry_management 19.0.19.0.4 (via marketplace) --- .../static/src/js/pos_store_patch.js | 807 ++++++++++++++++++ 1 file changed, 807 insertions(+) create mode 100644 addons/laundry_management/static/src/js/pos_store_patch.js diff --git a/addons/laundry_management/static/src/js/pos_store_patch.js b/addons/laundry_management/static/src/js/pos_store_patch.js new file mode 100644 index 0000000..5a75e03 --- /dev/null +++ b/addons/laundry_management/static/src/js/pos_store_patch.js @@ -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 || "", + }); + }, +});