Tower: upload laundry_management 19.0.19.0.4 (via marketplace)

This commit is contained in:
2026-05-01 15:00:36 +00:00
parent 8425e51ca4
commit 70d43d34ca

View 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 || "",
});
},
});