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