Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions POS/src/composables/useInvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,11 +739,15 @@ export function useInvoice() {
* Format cart items for server submission.
* Used by both online and offline flows for consistent formatting.
*
* Legacy carts could mark same-item BOGO only via `free_qty` on the paid line.
* Sales Invoice Item has no `free_qty` field — ERPNext expects a second row with
* is_free_item=1. When there is no matching dedicated free row, synthesize one.
*
* @param {Array} items - Raw cart items
* @returns {Array} Items formatted for ERPNext Sales Invoice
*/
function formatItemsForSubmission(items) {
return items.map((item) => ({
const mapRow = (item) => ({
item_code: item.item_code,
item_name: item.item_name,
qty: item.quantity || item.qty || 1,
Expand All @@ -753,6 +757,7 @@ export function useInvoice() {
warehouse: item.warehouse,
batch_no: item.batch_no,
serial_no: item.serial_no,
use_serial_batch_fields: item.batch_no || item.serial_no ? 1 : 0,
conversion_factor: item.conversion_factor || 1,
discount_percentage: roundCurrency(item.discount_percentage || 0),
discount_amount: roundCurrency(item.discount_amount || 0),
Expand All @@ -761,7 +766,44 @@ export function useInvoice() {
is_rate_manually_edited: item.is_rate_manually_edited || 0,
original_rate: item.original_rate || null,
is_free_item: item.is_free_item || 0,
}))
})

const out = []
for (const item of items) {
out.push(mapRow(item))
const fq = Number.parseFloat(item.free_qty) || 0
if (!item.is_free_item && fq > 0) {
const u = item.uom || item.stock_uom
const hasDedicatedFree = items.some(
(i) =>
i.is_free_item &&
i.item_code === item.item_code &&
(i.uom || i.stock_uom) === u,
)
if (!hasDedicatedFree) {
out.push({
item_code: item.item_code,
item_name: item.item_name,
qty: fq,
rate: 0,
price_list_rate: 0,
uom: item.uom,
warehouse: item.warehouse,
batch_no: item.batch_no,
serial_no: item.serial_no,
use_serial_batch_fields: item.batch_no || item.serial_no ? 1 : 0,
conversion_factor: item.conversion_factor || 1,
discount_percentage: 0,
discount_amount: 0,
pricing_rules: stringifyPricingRules(item.pricing_rules),
is_rate_manually_edited: 0,
original_rate: null,
is_free_item: 1,
})
}
}
}
return out
}

function addPayment(payment) {
Expand Down
104 changes: 68 additions & 36 deletions POS/src/stores/posCart.js
Original file line number Diff line number Diff line change
Expand Up @@ -380,10 +380,11 @@ export const usePOSCartStore = defineStore("posCart", () => {
/**
* Processes free items from backend offer response.
*
* Two cases:
* 1. Same item (free item matches an existing cart item) → sets free_qty on that item
* 2. Different product (free item not in cart) → adds a dedicated free item row
* with is_free_item=1, rate=0, non-editable in UI
* ERPNext represents product discounts as separate SI rows: paid line(s) plus
* one or more rows with is_free_item=1 (see pricing_rule tests for same_item).
* We always add a dedicated free row so formatItemsForSubmission sends qty > 0
* for stock and accounting; annotating only free_qty on the paid line never
* reaches Sales Invoice Item (there is no free_qty field server-side).
*
* @param {Array} freeItems - Array of free items from backend (e.g., [{item_code, qty, uom, item_name}])
* @returns {void}
Expand Down Expand Up @@ -418,30 +419,26 @@ export const usePOSCartStore = defineStore("posCart", () => {
(item.uom || item.stock_uom) === freeUom
)

if (cartItem) {
// Same item is already in cart — just annotate with free_qty
cartItem.free_qty = freeQty
} else {
// Different product — add a dedicated free item row
invoiceItems.value.push({
item_code: freeItem.item_code,
item_name: freeItem.item_name || freeItem.item_code,
rate: 0,
price_list_rate: 0,
quantity: freeQty,
discount_amount: 0,
discount_percentage: 0,
tax_amount: 0,
amount: 0,
stock_qty: 0,
uom: freeUom,
stock_uom: freeItem.stock_uom || freeUom,
conversion_factor: freeItem.conversion_factor || 1,
is_free_item: 1,
free_qty: freeQty,
pricing_rules: freeItem.pricing_rules || null,
})
}
const cf = freeItem.conversion_factor || cartItem?.conversion_factor || 1
invoiceItems.value.push({
item_code: freeItem.item_code,
item_name: freeItem.item_name || cartItem?.item_name || freeItem.item_code,
rate: 0,
price_list_rate: 0,
quantity: freeQty,
discount_amount: 0,
discount_percentage: 0,
tax_amount: 0,
amount: 0,
stock_qty: 0,
uom: freeUom,
stock_uom: freeItem.stock_uom || freeUom,
conversion_factor: cf,
is_free_item: 1,
free_qty: freeQty,
pricing_rules: freeItem.pricing_rules || null,
warehouse: freeItem.warehouse || cartItem?.warehouse,
})
}

rebuildIncrementalCache()
Expand Down Expand Up @@ -822,7 +819,7 @@ export const usePOSCartStore = defineStore("posCart", () => {
* In offline mode, we:
* 1. Check eligibility using posOffers.checkOfferEligibility
* 2. Apply discount percentage/amount directly to cart items
* 3. Handle free items (product discounts) by setting free_qty
* 3. Handle free items (product discounts) via dedicated is_free_item rows (same as online)
* 4. Mark offers as applied (with source: "offline")
*
* Supports:
Expand Down Expand Up @@ -1022,14 +1019,49 @@ export const usePOSCartStore = defineStore("posCart", () => {
}
}

if (freeItemsToGive > 0 && (!item.free_qty || item.free_qty === 0)) {
item.free_qty = freeItemsToGive
item.pricing_rules = item.pricing_rules || []
if (!item.pricing_rules.includes(offer.name)) {
item.pricing_rules.push(offer.name)
}
applied = true
if (freeItemsToGive <= 0) {
continue
}
const uomKey = item.uom || item.stock_uom
const existingFreeRow = invoiceItems.value.find(
(r) =>
r.is_free_item &&
r.item_code === item.item_code &&
(r.uom || r.stock_uom) === uomKey,
)
if (existingFreeRow) {
existingFreeRow.quantity = freeItemsToGive
existingFreeRow.free_qty = freeItemsToGive
const pr = existingFreeRow.pricing_rules
const prArr = Array.isArray(pr)
? [...pr]
: pr
? String(pr).split(',').map((s) => s.trim()).filter(Boolean)
: []
if (!prArr.includes(offer.name)) prArr.push(offer.name)
existingFreeRow.pricing_rules = prArr
} else {
invoiceItems.value.push({
item_code: item.item_code,
item_name: item.item_name || item.item_code,
rate: 0,
price_list_rate: 0,
quantity: freeItemsToGive,
discount_amount: 0,
discount_percentage: 0,
tax_amount: 0,
amount: 0,
stock_qty: 0,
uom: uomKey,
stock_uom: item.stock_uom || uomKey,
conversion_factor: item.conversion_factor || 1,
is_free_item: 1,
free_qty: freeItemsToGive,
pricing_rules: [offer.name],
warehouse: item.warehouse,
})
}
applied = true
}
} else if (freeItemCode) {
// Free item is a specific different item
Expand Down
161 changes: 161 additions & 0 deletions pos_next/api/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@
from erpnext.accounts.doctype.pricing_rule.utils import (
get_applied_pricing_rules as erpnext_get_applied_pricing_rules,
)
from erpnext.accounts.doctype.pricing_rule.utils import (
apply_pricing_rule_on_transaction as erpnext_apply_pricing_rule_on_transaction,
)
except Exception: # pragma: no cover - ERPNext not installed in some environments
erpnext_apply_pricing_rule = None
erpnext_get_applied_pricing_rules = None
erpnext_apply_pricing_rule_on_transaction = None


# ==========================================
Expand Down Expand Up @@ -2677,6 +2681,114 @@ def search_invoices_for_return(
# ==========================================


def _evaluate_transaction_offers(
invoice,
profile,
pricing_items,
customer,
customer_group,
territory,
posting_date,
currency,
price_list,
rule_map,
selected_offer_names,
):
"""Run ERPNext's transaction-level pricing engine and collect free items.

ERPNext routes `apply_on = "Transaction"` rules through a different entry
point (`apply_pricing_rule_on_transaction`) than the per-item engine. That
function mutates a real Sales Invoice document in place — appending free
item rows via `doc.append("items", ...)` — so we build a transient,
never-saved Sales Invoice document for evaluation only.

Returns {"free_items": dict keyed by (item_code, rule_name), "applied_rules": set}.
"""
if not erpnext_apply_pricing_rule_on_transaction or not pricing_items:
return {"free_items": {}, "applied_rules": set()}

total_qty = sum(flt(it.qty) for it in pricing_items)
total = sum(flt(it.qty) * flt(it.rate) for it in pricing_items)
if total <= 0:
return {"free_items": {}, "applied_rules": set()}

doc = frappe.new_doc("Sales Invoice")
doc.update(
{
"is_pos": 1,
"company": profile.company,
"currency": currency,
"conversion_rate": 1,
"selling_price_list": price_list,
"price_list_currency": currency,
"plc_conversion_rate": 1,
"customer": customer,
"customer_group": customer_group,
"territory": territory,
"transaction_date": posting_date,
"posting_date": posting_date,
"pos_profile": invoice.get("pos_profile"),
"coupon_code": invoice.get("coupon_code") or None,
}
)
doc.flags.ignore_mandatory = True

for prep in pricing_items:
doc.append(
"items",
{
"item_code": prep.item_code,
"item_name": prep.item_name,
"item_group": prep.item_group,
"brand": prep.brand,
"qty": prep.qty,
"stock_qty": prep.stock_qty,
"conversion_factor": prep.conversion_factor,
"uom": prep.uom,
"stock_uom": prep.stock_uom,
"rate": prep.rate,
"price_list_rate": prep.price_list_rate,
"base_rate": prep.base_rate,
"base_price_list_rate": prep.base_price_list_rate,
"amount": flt(prep.rate) * flt(prep.qty),
"warehouse": prep.warehouse,
},
)

# filter_pricing_rules_for_qty_amount reads these straight off the doc
# (erpnext/accounts/doctype/pricing_rule/utils.py:572).
doc.total_qty = total_qty
doc.total = total

initial_item_count = len(doc.items)
try:
erpnext_apply_pricing_rule_on_transaction(doc)
except Exception:
# A misconfigured transaction-scoped rule must not break the per-item
# discounts that have already been computed by the caller.
frappe.log_error(
frappe.get_traceback(), "POS Apply Offers (Transaction Rules)"
)
return {"free_items": {}, "applied_rules": set()}

free_items = {}
applied_rules = set()
for row in doc.items[initial_item_count:]:
if not getattr(row, "is_free_item", 0):
continue
rule_name = row.get("pricing_rules")
if not rule_name or rule_name not in rule_map:
continue
if selected_offer_names and rule_name not in selected_offer_names:
continue
fid = frappe._dict(row.as_dict())
fid.applied_promotional_scheme = rule_map[rule_name].promotional_scheme
free_items[(row.item_code, rule_name)] = fid
applied_rules.add(rule_name)

return {"free_items": free_items, "applied_rules": applied_rules}


@frappe.whitelist()
def apply_offers(invoice_data, selected_offers=None):
"""Calculate and apply promotional offers using ERPNext Pricing Rules.
Expand Down Expand Up @@ -2917,6 +3029,32 @@ def apply_offers(invoice_data, selected_offers=None):
# Include both promotional scheme rules and standalone pricing rules
rule_map[record.name] = record

# Top up rule_map with transaction-scoped rules. The per-item engine
# never surfaces apply_on="Transaction" rules, so without this they
# would be dropped at the `if not rule_map: return` check below.
# ERPNext's own SQL inside apply_pricing_rule_on_transaction handles
# date/currency/pos_only filtering, so a broad superset is sufficient.
if erpnext_apply_pricing_rule_on_transaction:
txn_rule_records = frappe.get_all(
"Pricing Rule",
filters={
"disable": 0,
"apply_on": "Transaction",
"company": profile.company,
"selling": 1,
"coupon_code_based": 0,
},
fields=[
"name",
"promotional_scheme",
"coupon_code_based",
"promotional_scheme_id",
"price_or_product_discount",
],
)
for record in txn_rule_records:
rule_map.setdefault(record.name, record)

if selected_offer_names:
# Restrict available rules to the ones explicitly selected from the UI.
rule_map = {
Expand Down Expand Up @@ -3052,6 +3190,29 @@ def apply_offers(invoice_data, selected_offers=None):
].promotional_scheme
free_items_map[(free_item.get("item_code"), rule_name)] = free_item_doc

# Evaluate apply_on="Transaction" rules through ERPNext's separate
# transaction-level engine. The per-item engine above does not see
# them, so without this step "Entire Transaction" promotional schemes
# (free product based on cart total) would never apply.
txn_result = _evaluate_transaction_offers(
invoice,
profile,
pricing_items,
customer,
customer_group,
territory,
invoice.get("posting_date") or nowdate(),
pricing_args.currency,
pricing_args.price_list,
rule_map,
selected_offer_names,
)
# Per-item results win on collisions because they already carry full
# discount metadata from the per-item engine result.
for key, free_item_doc in txn_result.get("free_items", {}).items():
free_items_map.setdefault(key, free_item_doc)
applied_rules.update(txn_result.get("applied_rules", set()))

return {
"items": [dict(item) for item in prepared_items],
"free_items": [dict(item) for item in free_items_map.values()],
Expand Down
Loading
Loading