fix: prevent POS crashes by adding PosOrderLine setup patches and strengthening reward line orphan guards

This commit is contained in:
Suherdy Yacob 2026-05-29 16:14:27 +07:00
parent 855f770934
commit 2f7ec27c9a

View File

@ -1,38 +1,59 @@
/** @odoo-module **/
import { PosOrder } from "@point_of_sale/app/models/pos_order";
import { PosOrderLine } from "@point_of_sale/app/models/pos_order_line";
import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation";
import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch";
/**
* Helper: delete all reward lines in an order where reward_id or reward_id.program_id is missing.
* This prevents TypeError crashes when the core code accesses reward.program_id.xxx
* on lines whose loyalty program was archived, deleted, or not yet loaded into the POS cache.
*/
function purgeOrphanedRewardLines(order) {
if (!order || !order.lines) {
return;
}
const orphans = order.lines.filter(
(line) =>
line.is_reward_line &&
(!line.reward_id || !line.reward_id.program_id)
(line) => line.is_reward_line && (!line.reward_id || !line.reward_id.program_id)
);
for (const line of orphans) {
try {
line.delete();
} catch (e) {
// Ignore deletion errors — the line may already be gone
// ignore
}
}
}
// ─── PosOrderLine: earliest possible interception ─────────────────────────────
// The crash happens at Proxy.create → renderRecords, which fires DURING line creation.
// Patching setup() lets us mark orphaned lines for deletion synchronously,
// before OWL components re-render and crash on reward_id.program_id.
patch(PosOrderLine.prototype, {
setup(vals) {
super.setup(...arguments);
// If this reward line has no valid program, schedule deletion immediately.
// We use Promise.resolve (microtask) because we cannot delete during setup().
if (this.is_reward_line && (!this.reward_id || !this.reward_id.program_id)) {
Promise.resolve().then(() => {
try {
if (this.is_reward_line && (!this.reward_id || !this.reward_id.program_id)) {
this.delete();
}
} catch (e) {
// ignore
}
});
}
},
});
// ─── PosOrder patches ─────────────────────────────────────────────────────────
patch(PosOrder.prototype, {
/**
* getLoyaltyPoints reads coupon_id.id guard against undefined coupon_id.
* getLoyaltyPoints reads coupon_id.id guard undefined coupon_id.
*/
getLoyaltyPoints() {
const undefinedCouponLines = [];
@ -52,7 +73,7 @@ patch(PosOrder.prototype, {
},
/**
* _updateRewardLines rebuilds reward lines purge orphans before core touches them.
* _updateRewardLines purge orphans before core re-processes them.
*/
_updateRewardLines() {
purgeOrphanedRewardLines(this);
@ -60,8 +81,18 @@ patch(PosOrder.prototype, {
},
/**
* initState fires when an order is initialized (including from server restore).
* Schedule a purge on the next microtask so reactive relations are fully resolved first.
* _validForPointsCorrection core accesses reward.program_id.id without null check.
* Guard it here.
*/
_validForPointsCorrection(reward, line, rule) {
if (!reward || !reward.program_id || !rule.program_id) {
return false;
}
return super._validForPointsCorrection(...arguments);
},
/**
* initState purge on next microtask after order initialization.
*/
initState() {
super.initState(...arguments);
@ -94,8 +125,7 @@ patch(OrderPaymentValidation.prototype, {
patch(PosStore.prototype, {
/**
* afterProcessServerData earliest global hook after all records are loaded.
* Purge orphaned reward lines from ALL open orders.
* afterProcessServerData purge orphaned lines from ALL orders after load.
*/
async afterProcessServerData() {
for (const order of this.models["pos.order"].getAll()) {
@ -105,8 +135,7 @@ patch(PosStore.prototype, {
},
/**
* updateRewards called on every order-line change.
* Purge orphaned lines from the current order before loyalty recalculation.
* updateRewards purge before loyalty recalculation.
*/
updateRewards() {
const order = this.getOrder();
@ -117,8 +146,7 @@ patch(PosStore.prototype, {
},
/**
* orderUpdateLoyaltyPrograms called when switching tables or rescanning codes.
* This is the path triggered by "Check Table" `setTableFromUi` `updateOrder`.
* orderUpdateLoyaltyPrograms purge before loyalty program update (table switch path).
*/
async orderUpdateLoyaltyPrograms() {
const order = this.getOrder();
@ -129,8 +157,7 @@ patch(PosStore.prototype, {
},
/**
* setTableFromUi the exact entry point for the "Check Table → blank screen" crash.
* Purge ALL orders before switching so the target table's order is clean.
* setTableFromUi "Check Table" entry point. Purge ALL orders before switching.
*/
async setTableFromUi(table, orderUuid = null) {
for (const order of this.models["pos.order"].getAll()) {
@ -140,14 +167,11 @@ patch(PosStore.prototype, {
},
/**
* postProcessLoyalty called when pushing/validating an order.
* Guards both reward.program_id === undefined AND coupon_id === undefined.
* postProcessLoyalty guards reward.program_id AND coupon_id during order push.
*/
async postProcessLoyalty(order) {
// Remove lines with missing program_id (core reads reward.program_id.id)
purgeOrphanedRewardLines(order);
// Also guard coupon_id=undefined (original safe_coupon behavior)
const undefinedCouponLines = [];
for (const line of order._get_reward_lines()) {
if (!line.coupon_id) {
@ -156,7 +180,6 @@ patch(PosStore.prototype, {
}
}
// Strip dummy coupon key "0" from confirm_coupon_programs calls
const originalCall = this.data.call;
this.data.call = function (model, method, args) {
if (model === "pos.order" && method === "confirm_coupon_programs") {