Skip to content
Open
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
23 changes: 21 additions & 2 deletions POS/src/components/sale/CreateCustomerDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,18 @@
</label>
<Input v-model="customerData.email_id" type="email" :placeholder="__('Enter email address')" />
</div>

<!-- Pincode (Required) -->
<div>
<label class="block text-start text-sm font-medium text-gray-700 mb-2">
{{ __("Pincode") }} <span class="text-red-500">*</span>
</label>
<Input
v-model="customerData.pincode"
type="text"
:placeholder="__('Enter pincode')"
required
/>
</div>
<!-- Customer Group -->
<div>
<label class="block text-start text-sm font-medium text-gray-700 mb-2">
Expand Down Expand Up @@ -159,7 +170,7 @@
variant="solid"
@click="handleCreate"
:loading="createCustomerResource.loading || updateCustomerResource.loading || checkingPermission"
:disabled="!customerData.customer_name || !hasPermission"
:disabled="!customerData.customer_name || !customerData.pincode || !hasPermission"
>
{{ isEditMode ? __("Save Changes") : __("Create Customer") }}
</Button>
Expand Down Expand Up @@ -233,6 +244,7 @@ const customerData = ref({
customer_name: "",
mobile_no: "",
email_id: "",
pincode: "",
customer_group: "Individual",
territory: "All Territories",
})
Expand Down Expand Up @@ -337,6 +349,7 @@ const createCustomerResource = createResource({
customer_name: customerData.value.customer_name,
mobile_no: customerData.value.mobile_no || "",
email_id: customerData.value.email_id || "",
custom_pincode: customerData.value.pincode || "",
customer_group: customerData.value.customer_group || __("Individual"),
territory: customerData.value.territory || __("All Territories"),
pos_profile: props.posProfile,
Expand All @@ -363,6 +376,7 @@ const updateCustomerResource = createResource({
territory: customerData.value.territory || __("All Territories"),
mobile_no: customerData.value.mobile_no || "",
email_id: customerData.value.email_id || "",
custom_pincode: customerData.value.pincode || "",
},
}),
onSuccess: (data) => {
Expand Down Expand Up @@ -446,6 +460,9 @@ const handleCreate = async () => {
if (!customerData.value.customer_name) {
return showError(__("Customer Name is required"))
}
if (!customerData.value.pincode) {
return showError(__("Pincode is required"))
}
if (isEditMode.value) {
await updateCustomerResource.submit()
} else {
Expand All @@ -458,6 +475,7 @@ const resetForm = () => {
customer_name: "",
mobile_no: "",
email_id: "",
pincode: "",
customer_group: "Individual",
territory: "All Territories",
})
Expand All @@ -481,6 +499,7 @@ watch(
if (customer?.name) {
customerData.value.customer_name = customer.customer_name || ""
customerData.value.email_id = customer.email_id || ""
customerData.value.pincode = customer.custom_pincode || ""
customerData.value.customer_group = customer.customer_group || "Individual"
customerData.value.territory = customer.territory || "All Territories"
// Handle mobile_no with country code
Expand Down
12 changes: 7 additions & 5 deletions POS/src/components/sale/EditItemDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
/>
</div>
<!-- Compact warning when rate editing disabled due to pricing rules -->
<p v-if="hasPricingRules && settingsStore.allowUserToEditRate" class="mt-1 text-xs text-amber-600 flex items-center gap-1">
<p v-if="hasPricingRules && settingsStore.allowUserToEditRate && localItem?.is_stock_item" class="mt-1 text-xs text-amber-600 flex items-center gap-1">
<FeatherIcon name="lock" class="w-3 h-3" />
{{ __('Locked (offer applied)') }}
</p>
Expand Down Expand Up @@ -356,14 +356,16 @@ const hasPricingRules = computed(() => {
})

// Rate editing is allowed only if:
// 1. POS Settings allows rate editing AND
// 2. Item does NOT have pricing rules (promotional offers) applied
// 1. POS Settings allows rate editing AND no pricing rules applied, OR
// 2. Item is not a stock item (is_stock_item == 0)
const canEditRate = computed(() => {
return settingsStore.allowUserToEditRate && !hasPricingRules.value
return (settingsStore.allowUserToEditRate && !hasPricingRules.value) || !localItem.value?.is_stock_item
})

// Tooltip message for why rate editing is disabled
const rateEditDisabledReason = computed(() => {
if (canEditRate.value) return ''
if (!localItem.value?.is_stock_item) return '' // Should be editable for non-stock items
if (!settingsStore.allowUserToEditRate) {
return __('Rate editing is disabled')
}
Expand Down Expand Up @@ -691,7 +693,7 @@ function updateItem() {
// ========================================================================
// RATE EDIT VALIDATION
// ========================================================================
if (settingsStore.allowUserToEditRate && isRateManuallyEdited) {
if ((settingsStore.allowUserToEditRate || !localItem.is_stock_item) && isRateManuallyEdited) {
// Validate rate is positive
if (localRate.value <= 0) {
showError(__('Rate must be greater than zero'))
Expand Down
8 changes: 6 additions & 2 deletions POS/src/components/sale/ItemsSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,8 @@
<th scope="col" class="px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 w-[50px] sm:w-[60px]">{{ __('Image') }}</th>
<th scope="col" class="px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 max-w-[120px] sm:max-w-[180px] md:max-w-[200px]">{{ __('Name') }}</th>
<th scope="col" class="hidden sm:table-cell px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 sm:max-w-[150px]">{{ __('Code') }}</th>
<th scope="col" class="px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 w-[70px] sm:w-[100px]">{{ __('Rate') }}</th>
<th scope="col" class="px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 w-[70px] sm:w-[100px]">{{ __('MRP') }}</th>
<th scope="col" class="px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 w-[70px] sm:w-[100px]">{{ __('MSP') }}</th>
<th scope="col" class="px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 w-[70px] sm:w-[100px]">{{ __('Qty') }}</th>
<th scope="col" class="hidden md:table-cell px-2 sm:px-3 py-2 sm:py-2.5 text-start text-[10px] sm:text-xs font-semibold text-gray-700 uppercase tracking-wider bg-gray-50 border-b-2 border-gray-200 sticky top-0 z-10 md:w-[80px]">{{ __('UOM') }}</th>
</tr>
Expand Down Expand Up @@ -556,8 +557,11 @@
<div class="text-xs sm:text-sm text-gray-500 truncate" :title="item.item_code">{{ item.item_code }}</div>
</td>
<td class="px-2 sm:px-3 py-2 whitespace-nowrap w-[70px] sm:w-[100px]">
<div class="text-xs sm:text-sm font-semibold text-blue-600">{{ formatCurrency(item.rate || item.price_list_rate || 0) }}</div>
<div class="text-xs sm:text-sm font-semibold text-blue-600">{{ formatCurrency(item.mrp || 0) }}</div>
</td>
<td class="px-2 sm:px-3 py-2 whitespace-nowrap w-[70px] sm:w-[100px]">
<div class="text-xs sm:text-sm font-semibold text-blue-600">{{ formatCurrency(item.price_list_rate || item.rate || 0) }}</div>
</td>
<td class="px-2 sm:px-3 py-2 whitespace-nowrap w-[70px] sm:w-[100px]">
<!-- Stock Badge - Tap to select, long press to view warehouse availability -->
<div
Expand Down
55 changes: 13 additions & 42 deletions POS/src/composables/useQuickAmounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,53 +34,24 @@ export function useQuickAmounts(remainingAmount, isCash) {
// Always include the primary amount first
amounts.add(exactAmount)

// Determine appropriate denominations based on amount size
let denominations
if (remaining < 20) {
denominations = [5, 10, 20, 50]
} else if (remaining < 100) {
denominations = [10, 20, 50, 100]
} else if (remaining < 500) {
denominations = [50, 100, 200, 500]
} else if (remaining < 2000) {
denominations = [100, 200, 500, 1000]
} else {
denominations = [500, 1000, 2000, 5000]
// Determine a step size based on the remaining amount scale
const getSuggestionStep = (value) => {
if (value < 50) return 5
if (value < 200) return 10
if (value < 1000) return 20
if (value < 10000) return 100
if (value < 50000) return 500
return 1000
}

// Minimum gap between suggestions (at least 5% or 5, whichever is larger)
const minGap = Math.max(5, exactAmount * 0.05)
const step = getSuggestionStep(remaining)
let nextAmount = Math.ceil((exactAmount + 1) / step) * step

// Helper to check if amount is far enough from existing amounts
const isFarEnough = (newAmt) => {
for (const existing of amounts) {
if (Math.abs(newAmt - existing) < minGap) return false
}
return true
while (amounts.size < 4) {
amounts.add(nextAmount)
nextAmount += step
}

// Add round-up amounts for each denomination
for (const denom of denominations) {
if (amounts.size >= 4) break

// Round up to next multiple of this denomination
const roundedUp = Math.ceil(remaining / denom) * denom

// Add if it's meaningfully different from exact amount
if (roundedUp > exactAmount && isFarEnough(roundedUp)) {
amounts.add(roundedUp)
}

// Also add one step higher for convenience (e.g., 350 when remaining is 299)
if (amounts.size < 4) {
const oneStepUp = roundedUp + denom
if (oneStepUp > exactAmount && isFarEnough(oneStepUp)) {
amounts.add(oneStepUp)
}
}
}

// Convert to array, sort, and limit to 4
return Array.from(amounts)
.filter((amt) => amt > 0)
.sort((a, b) => a - b)
Expand Down
3 changes: 3 additions & 0 deletions pos_next/api/customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def create_customer(
customer_name,
mobile_no=None,
email_id=None,
custom_pincode=None,
customer_group="Individual",
territory="All Territories",
company=None,
Expand All @@ -90,6 +91,7 @@ def create_customer(
customer_name (str): Customer name (required)
mobile_no (str): Mobile number (optional)
email_id (str): Email address (optional)
custom_pincode (str): Pincode (optional)
customer_group (str): Customer group (default: Individual)
territory (str): Territory (default: All Territories)
company (str): Company (optional, used to auto-assign loyalty program)
Expand Down Expand Up @@ -119,6 +121,7 @@ def create_customer(
"territory": territory or "All Territories",
"mobile_no": mobile_no or "",
"email_id": email_id or "",
"custom_pincode": custom_pincode or "",
"loyalty_program": loyalty_program,
}
)
Expand Down
6 changes: 4 additions & 2 deletions pos_next/api/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,10 @@ def validate_manual_rate_edit(item, pos_profile=None, pos_settings_cache=None):
"message": _("POS Settings not found for profile {0}. Cannot validate rate edit.").format(pos_profile)
}

# Check if rate editing is allowed
if not cint(pos_settings.get(FIELD_ALLOW_USER_TO_EDIT_RATE)):
is_stock_item = cint(item.get("is_stock_item") or 0)

# Check if rate editing is allowed for stock items only
if is_stock_item and not cint(pos_settings.get(FIELD_ALLOW_USER_TO_EDIT_RATE)):
return {
"valid": False,
"message": _("Rate editing is not allowed for this POS Profile")
Expand Down
46 changes: 36 additions & 10 deletions pos_next/api/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,8 @@ def get_items(pos_profile, search_term=None, item_group=None, start=0, limit=20,
.select(
ItemPrice.item_code,
ItemPrice.uom,
ItemPrice.price_list_rate
ItemPrice.price_list_rate,
ItemPrice.mrp_rate
)
.where(ItemPrice.item_code.isin(item_codes))
.where(ItemPrice.price_list == pos_profile_doc.selling_price_list)
Expand All @@ -1350,7 +1351,10 @@ def get_items(pos_profile, search_term=None, item_group=None, start=0, limit=20,
.run(as_dict=True)
)
for price in prices:
uom_prices_map.setdefault(price["item_code"], {})[price["uom"]] = price["price_list_rate"]
uom_prices_map.setdefault(price["item_code"], {})[price["uom"]] = {
"price_list_rate": price["price_list_rate"],
"mrp_rate": price.get("mrp_rate"),
}

# Batch query stock for all items at once using Query Builder
stock_map = {}
Expand Down Expand Up @@ -1427,13 +1431,21 @@ def get_items(pos_profile, search_term=None, item_group=None, start=0, limit=20,

# 1) Try price explicitly for stock UOM (preferred)
if stock_uom and stock_uom in item_prices:
price_row = {"price_list_rate": item_prices[stock_uom], "uom": stock_uom}
price_row = {
"price_list_rate": item_prices[stock_uom].get("price_list_rate"),
"mrp_rate": item_prices[stock_uom].get("mrp_rate"),
"uom": stock_uom,
}

# 2) If not found, try any price for the item (and capture its UOM)
elif item_prices:
# Get first available price
first_uom = next(iter(item_prices.keys()))
price_row = {"price_list_rate": item_prices[first_uom], "uom": first_uom}
price_row = {
"price_list_rate": item_prices[first_uom].get("price_list_rate"),
"mrp_rate": item_prices[first_uom].get("mrp_rate"),
"uom": first_uom,
}

# 3) If still not found and it's a template, derive min variant price
derived_price = None
Expand All @@ -1458,35 +1470,40 @@ def get_items(pos_profile, search_term=None, item_group=None, start=0, limit=20,
# Finalize display price & display UOM
display_rate = 0.0
display_uom = stock_uom
display_mrp = 0.0

if price_row:
raw_rate = flt(price_row.get("price_list_rate") or 0)
mrp_value = flt(price_row.get("mrp_rate") or 0)
price_uom = price_row.get("uom") or stock_uom
if price_uom and stock_uom and price_uom != stock_uom:
# convert to per-stock-UOM if possible
cf = flt(conversion_map[item["item_code"]].get(price_uom) or 0)
if cf:
display_rate = raw_rate / cf
display_mrp = mrp_value / cf
display_uom = stock_uom
else:
# no conversion available: show as is (price UOM)
display_rate = raw_rate
display_mrp = mrp_value
display_uom = price_uom
else:
display_rate = raw_rate
display_mrp = mrp_value
display_uom = stock_uom
elif derived_price is not None:
display_rate = flt(derived_price)
display_mrp = 0.0
display_uom = stock_uom

item["rate"] = display_rate
item["price_list_rate"] = display_rate
item["mrp"] = display_mrp
item["uom"] = display_uom
item["price_uom"] = display_uom
item["conversion_factor"] = 1
item["price_list_rate_price_uom"] = display_rate

# ===================================================================
# STOCK QUANTITY ASSIGNMENT: Stock Items vs Product Bundles
# ===================================================================
# Stock items: Use actual_qty from Bin table (direct stock tracking)
Expand Down Expand Up @@ -1663,14 +1680,16 @@ def get_items_bulk(pos_profile, item_groups=None, start=0, limit=2000, include_v
ItemPrice = DocType("Item Price")
prices = (
frappe.qb.from_(ItemPrice)
.select(ItemPrice.item_code, ItemPrice.uom, ItemPrice.price_list_rate)
.select(ItemPrice.item_code, ItemPrice.uom, ItemPrice.price_list_rate, ItemPrice.mrp_rate)
.where(ItemPrice.price_list == price_list)
.where(ItemPrice.item_code.isin(item_codes))
.where(ItemPrice.selling == 1)
.run(as_dict=True)
)
for p in prices:
uom_prices_map.setdefault(p.item_code, {})[p.uom] = flt(p.price_list_rate)
uom_prices_map.setdefault(p.item_code, {})[p.uom] = {
"price_list_rate": flt(p.price_list_rate),
"mrp_rate": flt(p.mrp_rate),
}

# Stock
warehouse = pos_profile_doc.warehouse
Expand Down Expand Up @@ -1718,8 +1737,15 @@ def get_items_bulk(pos_profile, item_groups=None, start=0, limit=2000, include_v

# Price: prefer stock_uom, then None/empty UOM (Item Price without UOM)
prices = uom_prices_map.get(item_code, {})
item["rate"] = flt(prices.get(stock_uom) or prices.get(None) or prices.get("") or 0)
price_row = (
prices.get(stock_uom)
or prices.get(None)
or prices.get("")
or next(iter(prices.values()), {})
)
item["rate"] = flt(price_row.get("price_list_rate") or 0)
item["price_list_rate"] = item["rate"]
item["mrp"] = flt(price_row.get("mrp_rate") or 0)
item["uom"] = stock_uom
item["price_uom"] = stock_uom
item["conversion_factor"] = 1
Expand Down