Skip to content

Commit 754e556

Browse files
author
Ahmed Osama
committed
refactor: improve promotional offers system and use ERPNext native pricing logic
- Add posOffers Pinia store for centralized offer state management - Enhanced offers API to fetch promotional scheme slab details including min/max amounts, discount values, and apply_multiple_pricing_rules flag - Add eligibility checking and auto-apply logic in offers store - Update cart to support multiple manual offer selection - Simplify apply_offers function to use pure ERPNext pricing engine - Remove all debug code and temporary workarounds - Update OffersDialog to use new posOffers store for better reactivity - Format invoices.py with consistent code style (Black formatter) This refactor allows ERPNext to handle all pricing rule conflicts natively based on priority, while giving users the ability to manually select from eligible promotional offers.
1 parent ab9b9a4 commit 754e556

9 files changed

Lines changed: 1918 additions & 1339 deletions

File tree

POS/src/components/sale/InvoiceCart.vue

Lines changed: 242 additions & 131 deletions
Large diffs are not rendered by default.

POS/src/components/sale/OffersDialog.vue

Lines changed: 43 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,21 @@
3131
</div>
3232

3333
<!-- Offers List -->
34-
<div v-else class="space-y-3 max-h-[500px] overflow-y-auto pr-2">
35-
<div
36-
v-for="offer in eligibleOffers"
37-
:key="offer.name"
38-
:class="[
39-
'relative rounded-xl p-4 transition-all duration-200 border-2',
40-
appliedOffer?.code === offer.name
41-
? 'bg-green-50 border-green-500 shadow-md'
42-
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 hover:border-green-400 hover:shadow-lg cursor-pointer'
43-
]"
44-
>
34+
<div v-else>
35+
<div class="space-y-3 max-h-[500px] overflow-y-auto pr-2">
36+
<div
37+
v-for="offer in eligibleOffers"
38+
:key="offer.name"
39+
:class="[
40+
'relative rounded-xl p-4 transition-all duration-200 border-2',
41+
isOfferApplied(offer)
42+
? 'bg-green-50 border-green-500 shadow-md'
43+
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 hover:border-green-400 hover:shadow-lg cursor-pointer'
44+
]"
45+
>
4546
<!-- Applied Badge -->
4647
<div
47-
v-if="appliedOffer?.code === offer.name"
48+
v-if="isOfferApplied(offer)"
4849
class="absolute top-2 right-2 bg-green-600 text-white text-[10px] font-bold px-2 py-1 rounded-full flex items-center space-x-1"
4950
>
5051
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@@ -131,22 +132,22 @@
131132
</div>
132133
</div>
133134

134-
<!-- Progress Bar for Min Amount -->
135-
<div v-if="offer.min_amt && subtotal < offer.min_amt" class="mt-3">
135+
<!-- Progress Bar for Min Amount (only shown if not eligible) -->
136+
<div v-if="offer.min_amt && offersStore.cartSnapshot.subtotal < offer.min_amt" class="mt-3">
136137
<div class="flex items-center justify-between text-xs mb-1">
137138
<span class="text-gray-600">Subtotal (before tax)</span>
138139
<span class="text-gray-900 font-semibold">
139-
{{ formatCurrency(subtotal) }} / {{ formatCurrency(offer.min_amt) }}
140+
{{ formatCurrency(offersStore.cartSnapshot.subtotal) }} / {{ formatCurrency(offer.min_amt) }}
140141
</span>
141142
</div>
142143
<div class="w-full bg-gray-200 rounded-full h-2">
143144
<div
144145
class="bg-green-600 h-2 rounded-full transition-all"
145-
:style="{ width: `${Math.min((subtotal / offer.min_amt) * 100, 100)}%` }"
146+
:style="{ width: `${Math.min((offersStore.cartSnapshot.subtotal / offer.min_amt) * 100, 100)}%` }"
146147
></div>
147148
</div>
148149
<p class="text-xs text-orange-600 mt-1 font-medium">
149-
Add {{ formatCurrency(offer.min_amt - subtotal) }} more to unlock
150+
Add {{ formatCurrency(offersStore.getUnlockAmount(offer)) }} more to unlock
150151
</p>
151152
</div>
152153

@@ -157,17 +158,18 @@
157158
:disabled="applyingOffer"
158159
:class="[
159160
'mt-3 w-full py-2 px-4 rounded-lg font-semibold text-sm transition-all',
160-
appliedOffer?.code === offer.name
161+
isOfferApplied(offer)
161162
? 'bg-red-600 hover:bg-red-700 text-white'
162163
: 'bg-green-600 hover:bg-green-700 text-white shadow-md hover:shadow-lg',
163164
applyingOffer ? 'opacity-70 cursor-not-allowed' : ''
164165
]"
165166
>
166-
{{ appliedOffer?.code === offer.name ? 'Remove Offer' : 'Apply Offer' }}
167+
{{ isOfferApplied(offer) ? 'Remove Offer' : 'Apply Offer' }}
167168
</button>
168169
</div>
169170
</div>
170171
</div>
172+
</div>
171173
</template>
172174
<template #actions>
173175
<div class="flex justify-end w-full">
@@ -181,8 +183,12 @@
181183

182184
<script setup>
183185
import { ref, watch, computed } from 'vue'
184-
import { Dialog, Button, createResource, toast } from 'frappe-ui'
186+
import { Dialog, Button } from 'frappe-ui'
185187
import { formatCurrency as formatCurrencyUtil } from '@/utils/currency'
188+
import { usePOSOffersStore } from '@/stores/posOffers'
189+
190+
// Use Pinia stores
191+
const offersStore = usePOSOffersStore()
186192
187193
const props = defineProps({
188194
modelValue: Boolean,
@@ -199,64 +205,31 @@ const props = defineProps({
199205
type: String,
200206
default: 'USD'
201207
},
202-
appliedOffer: {
203-
type: Object,
204-
default: null
208+
appliedOffers: {
209+
type: Array,
210+
default: () => []
205211
}
206212
})
207213
208214
const emit = defineEmits(['update:modelValue', 'apply-offer', 'remove-offer'])
209215
210216
const show = ref(props.modelValue)
211-
const allOffers = ref([])
212-
const loading = ref(false)
213217
const applyingOffer = ref(false)
214-
215-
// Resource to load offers
216-
const offersResource = createResource({
217-
url: 'pos_next.api.offers.get_offers',
218-
makeParams() {
219-
return {
220-
pos_profile: props.posProfile
221-
}
222-
},
223-
auto: false,
224-
onSuccess(data) {
225-
allOffers.value = data?.message || data || []
226-
loading.value = false
227-
},
228-
onError(error) {
229-
console.error('Error loading offers:', error)
230-
loading.value = false
231-
toast({
232-
title: 'Error',
233-
text: 'Failed to load offers',
234-
icon: 'x',
235-
iconClasses: 'text-red-600'
236-
})
237-
}
218+
const appliedOfferCodes = computed(() => {
219+
return new Set((props.appliedOffers || []).map(entry => entry?.code).filter(Boolean))
238220
})
239221
240-
// Computed eligible offers - only show offers the user is eligible for
241-
const eligibleOffers = computed(() => {
242-
if (!allOffers.value) return []
222+
// Use ALL eligible offers from store (includes both auto and manual offers)
223+
const eligibleOffers = computed(() => offersStore.allEligibleOffersSorted)
243224
244-
// Filter to only show eligible, non-coupon offers
245-
return allOffers.value
246-
.filter(offer => !offer.coupon_based && checkOfferEligibility(offer))
247-
.sort((a, b) => {
248-
// Sort by discount value (higher discounts first)
249-
const aValue = a.discount_percentage || a.discount_amount || 0
250-
const bValue = b.discount_percentage || b.discount_amount || 0
251-
return bValue - aValue
252-
})
225+
// Loading state - check if offers are being loaded
226+
const loading = computed(() => {
227+
return !offersStore.hasFetched && eligibleOffers.value.length === 0
253228
})
254229
255230
watch(() => props.modelValue, (val) => {
256231
show.value = val
257-
if (val) {
258-
loadOffers()
259-
}
232+
// No need to load offers - they're already in the store
260233
})
261234
262235
watch(show, (val) => {
@@ -267,46 +240,16 @@ watch(show, (val) => {
267240
}
268241
})
269242
270-
function checkOfferEligibility(offer) {
271-
// Check minimum amount (on subtotal before tax)
272-
if (offer.min_amt && props.subtotal < offer.min_amt) {
273-
return false
274-
}
275-
// Check maximum amount (on subtotal before tax)
276-
if (offer.max_amt && props.subtotal > offer.max_amt) {
277-
return false
278-
}
279-
// Check minimum quantity
280-
if (offer.min_qty) {
281-
const totalQty = props.items.reduce((sum, item) => sum + item.quantity, 0)
282-
if (totalQty < offer.min_qty) {
283-
return false
284-
}
285-
}
286-
return true
287-
}
288-
289-
async function loadOffers() {
290-
if (!props.posProfile) return
291-
loading.value = true
292-
try {
293-
await offersResource.reload()
294-
} catch (error) {
295-
console.error('Error loading offers:', error)
296-
loading.value = false
297-
}
298-
}
299-
300243
async function handleApplyOffer(offer) {
301244
if (applyingOffer.value) {
302245
return
303246
}
304247
305248
// Toggle offer - if already applied, remove it
306-
if (props.appliedOffer?.code === offer.name) {
249+
if (isOfferApplied(offer)) {
307250
applyingOffer.value = true
308251
try {
309-
emit('remove-offer')
252+
emit('remove-offer', offer)
310253
// Close dialog after successful removal
311254
await new Promise(resolve => setTimeout(resolve, 500))
312255
show.value = false
@@ -335,6 +278,10 @@ function resetApplyingState() {
335278
// Expose for parent component
336279
defineExpose({ resetApplyingState })
337280
281+
function isOfferApplied(offer) {
282+
return appliedOfferCodes.value.has(offer?.name)
283+
}
284+
338285
function formatCurrency(amount) {
339286
return formatCurrencyUtil(parseFloat(amount || 0), props.currency)
340287
}

POS/src/composables/useInvoice.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ export function useInvoice() {
154154
batch_no: item.batch_no,
155155
serial_no: item.serial_no,
156156
item_uoms: item.item_uoms || [], // Available UOMs for this item
157+
// Add item_group and brand for offer eligibility checking
158+
item_group: item.item_group,
159+
brand: item.brand,
157160
}
158161
invoiceItems.value.push(newItem)
159162
// Recalculate the newly added item to apply taxes

POS/src/pages/POSSale.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@
205205
:grand-total="cartStore.grandTotal"
206206
:pos-profile="shiftStore.profileName"
207207
:currency="shiftStore.profileCurrency"
208-
:applied-offer="cartStore.autoAppliedOffer"
208+
:applied-offers="cartStore.appliedOffers"
209209
:warehouses="profileWarehouses"
210210
@update-quantity="cartStore.updateItemQuantity"
211211
@remove-item="cartStore.removeItem"
@@ -216,7 +216,7 @@
216216
@save-draft="handleSaveDraft"
217217
@apply-coupon="uiStore.showCouponDialog = true"
218218
@show-offers="uiStore.showOffersDialog = true"
219-
@remove-offer="cartStore.removeOffer"
219+
@remove-offer="offer => cartStore.removeOffer(offer, shiftStore.currentProfile, offersDialogRef.value)"
220220
@update-uom="cartStore.changeItemUOM"
221221
@edit-item="handleEditItem"
222222
/>
@@ -331,9 +331,9 @@
331331
:customer="cartStore.customer?.name || cartStore.customer"
332332
:company="shiftStore.profileCompany"
333333
:currency="shiftStore.profileCurrency"
334-
:applied-offer="cartStore.autoAppliedOffer"
334+
:applied-offers="cartStore.appliedOffers"
335335
@apply-offer="handleApplyOffer"
336-
@remove-offer="cartStore.removeOffer"
336+
@remove-offer="offer => cartStore.removeOffer(offer, shiftStore.currentProfile, offersDialogRef.value)"
337337
/>
338338

339339
<!-- Batch/Serial Dialog -->

0 commit comments

Comments
 (0)