fix: prevent TypeError crashes by centralizing orphan reward line purging across POS lifecycle hooks

This commit is contained in:
Suherdy Yacob 2026-05-29 15:42:01 +07:00
parent 40ef3de37b
commit 855f770934

View File

@ -1,9 +1,39 @@
/** @odoo-module **/
import { PosOrder } from "@point_of_sale/app/models/pos_order"; import { PosOrder } from "@point_of_sale/app/models/pos_order";
import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation"; import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation";
import { PosStore } from "@point_of_sale/app/services/pos_store"; import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch"; 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)
);
for (const line of orphans) {
try {
line.delete();
} catch (e) {
// Ignore deletion errors — the line may already be gone
}
}
}
// ─── PosOrder patches ─────────────────────────────────────────────────────────
patch(PosOrder.prototype, { patch(PosOrder.prototype, {
/**
* getLoyaltyPoints reads coupon_id.id guard against undefined coupon_id.
*/
getLoyaltyPoints() { getLoyaltyPoints() {
const undefinedCouponLines = []; const undefinedCouponLines = [];
for (const line of this._get_reward_lines()) { for (const line of this._get_reward_lines()) {
@ -20,17 +50,27 @@ patch(PosOrder.prototype, {
} }
} }
}, },
/**
* _updateRewardLines rebuilds reward lines purge orphans before core touches them.
*/
_updateRewardLines() { _updateRewardLines() {
if (this.lines) { purgeOrphanedRewardLines(this);
const invalidRewardLines = this.lines.filter((line) => line.is_reward_line && (!line.reward_id || !line.reward_id.program_id));
for (const line of invalidRewardLines) {
line.delete();
}
}
return super._updateRewardLines(...arguments); return super._updateRewardLines(...arguments);
} },
/**
* 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.
*/
initState() {
super.initState(...arguments);
Promise.resolve().then(() => purgeOrphanedRewardLines(this));
},
}); });
// ─── OrderPaymentValidation patches ───────────────────────────────────────────
patch(OrderPaymentValidation.prototype, { patch(OrderPaymentValidation.prototype, {
async validateOrder(isForceValidate) { async validateOrder(isForceValidate) {
const undefinedCouponLines = []; const undefinedCouponLines = [];
@ -47,11 +87,67 @@ patch(OrderPaymentValidation.prototype, {
line.coupon_id = undefined; line.coupon_id = undefined;
} }
} }
} },
}); });
// ─── PosStore patches ─────────────────────────────────────────────────────────
patch(PosStore.prototype, { patch(PosStore.prototype, {
/**
* afterProcessServerData earliest global hook after all records are loaded.
* Purge orphaned reward lines from ALL open orders.
*/
async afterProcessServerData() {
for (const order of this.models["pos.order"].getAll()) {
purgeOrphanedRewardLines(order);
}
return await super.afterProcessServerData(...arguments);
},
/**
* updateRewards called on every order-line change.
* Purge orphaned lines from the current order before loyalty recalculation.
*/
updateRewards() {
const order = this.getOrder();
if (order) {
purgeOrphanedRewardLines(order);
}
return super.updateRewards(...arguments);
},
/**
* orderUpdateLoyaltyPrograms called when switching tables or rescanning codes.
* This is the path triggered by "Check Table" `setTableFromUi` `updateOrder`.
*/
async orderUpdateLoyaltyPrograms() {
const order = this.getOrder();
if (order) {
purgeOrphanedRewardLines(order);
}
return await super.orderUpdateLoyaltyPrograms(...arguments);
},
/**
* 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.
*/
async setTableFromUi(table, orderUuid = null) {
for (const order of this.models["pos.order"].getAll()) {
purgeOrphanedRewardLines(order);
}
return await super.setTableFromUi(...arguments);
},
/**
* postProcessLoyalty called when pushing/validating an order.
* Guards both reward.program_id === undefined AND coupon_id === undefined.
*/
async postProcessLoyalty(order) { 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 = []; const undefinedCouponLines = [];
for (const line of order._get_reward_lines()) { for (const line of order._get_reward_lines()) {
if (!line.coupon_id) { if (!line.coupon_id) {
@ -60,7 +156,7 @@ patch(PosStore.prototype, {
} }
} }
// Intercept data.call to strip out the dummy coupon data with key "0" // Strip dummy coupon key "0" from confirm_coupon_programs calls
const originalCall = this.data.call; const originalCall = this.data.call;
this.data.call = function (model, method, args) { this.data.call = function (model, method, args) {
if (model === "pos.order" && method === "confirm_coupon_programs") { if (model === "pos.order" && method === "confirm_coupon_programs") {
@ -81,14 +177,4 @@ patch(PosStore.prototype, {
} }
} }
}, },
async orderUpdateLoyaltyPrograms() {
const order = this.getOrder();
if (order && order.lines) {
const invalidRewardLines = order.lines.filter((line) => line.is_reward_line && (!line.reward_id || !line.reward_id.program_id));
for (const line of invalidRewardLines) {
line.delete();
}
}
return await super.orderUpdateLoyaltyPrograms(...arguments);
}
}); });