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
108 changes: 105 additions & 3 deletions POS/src/components/sale/BatchSerialDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,32 @@

<!-- Batch Selection -->
<div v-if="item?.has_batch_no">
<div v-if="requestedQty > 1" class="mb-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm text-gray-700">
<span class="font-medium">{{ __('Requested') }}:</span> {{ requestedQty }}
<span class="mx-2 text-gray-400">|</span>
<span class="font-medium">{{ __('Available') }}:</span> {{ totalAvailableQty }}
<span v-if="availableBatches.length" class="text-gray-500">
({{ __("{0} batches", [availableBatches.length]) }})
</span>
</div>
<Button
v-if="canAutoAllocate"
variant="solid"
size="sm"
:loading="isAllocating"
@click="autoAllocateBatches"
>
{{ __('Auto Allocate') }}
</Button>
</div>
<p v-if="allocationError" class="mt-2 text-xs text-red-600">{{ allocationError }}</p>
</div>

<label class="block text-sm font-medium text-gray-700 mb-2">
{{ __('Select Batch Number') }}
<span class="font-normal text-gray-500">({{ __('optional if using Auto Allocate') }})</span>
</label>
<div class="flex flex-col gap-2 max-h-80 overflow-y-auto">
<div
Expand All @@ -54,6 +78,9 @@
<span v-if="batch.expiry_date" class="text-xs text-gray-600">
{{ __('Exp: {0}', [formatDate(batch.expiry_date)]) }}
</span>
<span v-if="requestedQty > 1 && selectedBatch?.batch_no === batch.batch_no && batch.qty < requestedQty" class="text-xs text-amber-600">
{{ __('Max {0} from this batch', [batch.qty]) }}
</span>
</div>
</div>
<div v-if="selectedBatch?.batch_no === batch.batch_no" class="flex-shrink-0">
Expand Down Expand Up @@ -188,7 +215,7 @@
<Button
variant="solid"
@click="handleConfirm"
:disabled="!isValid"
:disabled="!isValid || isAllocating"
>
{{ __('Confirm') }}
</Button>
Expand All @@ -198,7 +225,7 @@
</template>

<script setup>
import { Button, Dialog, createResource } from "frappe-ui"
import { Button, Dialog, createResource, call } from "frappe-ui"
import { computed, ref, watch } from "vue"
import { useSerialNumberStore } from "@/stores/serialNumber"
import { usePOSCartStore } from "@/stores/posCart"
Expand Down Expand Up @@ -230,6 +257,22 @@ const availableSerials = ref([])
const selectedBatch = ref(null)
const selectedSerials = ref([])
const serialSearchQuery = ref("")
const isAllocating = ref(false)
const allocationError = ref("")

const requestedQty = computed(() => Math.max(1, Math.round(Number(props.quantity) || 1)))

const totalAvailableQty = computed(() =>
availableBatches.value.reduce((sum, batch) => sum + (batch.qty || 0), 0),
)

const canAutoAllocate = computed(() => {
return (
requestedQty.value > 0 &&
totalAvailableQty.value >= requestedQty.value &&
availableBatches.value.length > 0
)
})

// Computed: Available batches with cart quantities subtracted
const availableBatches = computed(() => {
Expand Down Expand Up @@ -308,7 +351,13 @@ const filteredSerials = computed(() => {

const isValid = computed(() => {
if (props.item?.has_batch_no) {
return selectedBatch.value !== null
if (!selectedBatch.value) {
return false
}
if (requestedQty.value > (selectedBatch.value.qty || 0)) {
return false
}
return true
}
if (props.item?.has_serial_no) {
// Valid if at least one serial is selected
Expand Down Expand Up @@ -391,11 +440,62 @@ function clearAllSerials() {
selectedSerials.value = []
}

function getConsumedBatchesFromCart() {
return cartStore.invoiceItems
.filter(
(cartItem) =>
cartItem.item_code === props.item?.item_code && cartItem.batch_no,
)
.map((cartItem) => ({
batch_no: cartItem.batch_no,
qty: (cartItem.quantity || 0) * (cartItem.conversion_factor || 1),
}))
}

async function autoAllocateBatches() {
if (!props.item?.item_code || !props.warehouse) {
return
}

allocationError.value = ""
isAllocating.value = true

try {
const segments = await call("pos_next.api.items.get_batch_allocation", {
item_code: props.item.item_code,
warehouse: props.warehouse,
qty: requestedQty.value,
consumed_batches: getConsumedBatchesFromCart(),
})

if (!segments?.length) {
allocationError.value = __("No batches could be allocated for the requested quantity.")
return
}

emit("batch-serial-selected", { segments })
show.value = false
} catch (error) {
allocationError.value =
error?.messages?.[0] || error?.message || __("Failed to allocate batches.")
} finally {
isAllocating.value = false
}
}

function handleConfirm() {
const result = {}

if (props.item?.has_batch_no && selectedBatch.value) {
if (requestedQty.value > (selectedBatch.value.qty || 0)) {
allocationError.value = __(
"Requested quantity ({0}) exceeds selected batch quantity ({1}). Use Auto Allocate for multiple batches.",
[requestedQty.value, selectedBatch.value.qty || 0],
)
return
}
result.batch_no = selectedBatch.value.batch_no
result.expiry_date = selectedBatch.value.expiry_date
}

if (props.item?.has_serial_no) {
Expand All @@ -416,6 +516,8 @@ function resetSelection() {
selectedSerials.value = []
serialSearchQuery.value = ""
warehouseBatches.value = []
allocationError.value = ""
isAllocating.value = false
// Don't clear availableSerials - it's managed by the store cache
}

Expand Down
16 changes: 8 additions & 8 deletions POS/src/components/sale/InvoiceCart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@
<div v-else class="flex flex-col gap-0.5 sm:gap-1">
<div
v-for="(item, index) in sortedItems"
:key="item.item_code + '-' + (item.uom || '') + (item.is_free_item ? '-free' : '')"
:key="item.item_code + '-' + (item.uom || '') + (item.batch_no ? '-' + item.batch_no : '') + (item.is_free_item ? '-free' : '')"
@click="item.is_free_item ? null : openEditDialog(item)"
:class="[
'border rounded-md p-1.5 sm:p-2 transition-all duration-200',
Expand Down Expand Up @@ -838,7 +838,7 @@
<button
v-if="!item.is_free_item"
type="button"
@click.stop="$emit('remove-item', item.item_code, item.uom)"
@click.stop="$emit('remove-item', item.item_code, item.uom, item.batch_no)"
class="text-gray-400 hover:text-red-600 active:text-red-700 transition-colors flex-shrink-0 p-0.5 -m-0.5 touch-manipulation active:scale-90"
:aria-label="__('Remove {0}', [item.item_name])"
:title="__('Remove item')"
Expand Down Expand Up @@ -1345,7 +1345,7 @@ const props = defineProps({
*/
const emit = defineEmits([
"update-quantity", // (itemCode, newQty, uom?) - Update item quantity
"remove-item", // (itemCode, uom?) - Remove item from cart
"remove-item", // (itemCode, uom?, batchNo?) - Remove item from cart
"select-customer", // (customer) - Select/change customer
"edit-customer", // (customer) - Open edit customer dialog
"create-customer", // (searchText) - Open create customer dialog
Expand Down Expand Up @@ -1810,7 +1810,7 @@ function incrementQuantity(item) {

const step = getSmartStep(item.quantity);
const newQty = Math.round((item.quantity + step) * 10000) / 10000;
emit("update-quantity", item.item_code, newQty, item.uom);
emit("update-quantity", item.item_code, newQty, item.uom, item.batch_no);
}

/**
Expand All @@ -1828,9 +1828,9 @@ function decrementQuantity(item) {

if (newQty <= 0) {
// If quantity would be 0 or negative, remove the item
emit("remove-item", item.item_code, item.uom);
emit("remove-item", item.item_code, item.uom, item.batch_no);
} else {
emit("update-quantity", item.item_code, newQty, item.uom);
emit("update-quantity", item.item_code, newQty, item.uom, item.batch_no);
}
}

Expand All @@ -1852,7 +1852,7 @@ function updateQuantity(item, value) {
if (isNaN(qty)) return;

// If quantity is zero or negative, remove the item from the cart
if (qty <= 0) return emit("remove-item", item.item_code, item.uom);
if (qty <= 0) return emit("remove-item", item.item_code, item.uom, item.batch_no);

// For positive numbers, update quantity immediately (no rounding here while typing)
emit("update-quantity", item.item_code, qty, item.uom);
Expand All @@ -1870,7 +1870,7 @@ function handleQuantityBlur(item) {
// When user leaves the input field, round and validate
if (!item.quantity || item.quantity <= 0) {
// If quantity is 0 or invalid, remove the item
emit("remove-item", item.item_code, item.uom);
emit("remove-item", item.item_code, item.uom, item.batch_no);
} else {
// Round to 4 decimal places for consistency
const roundedQty = Math.round(item.quantity * 10000) / 10000;
Expand Down
54 changes: 40 additions & 14 deletions POS/src/composables/useInvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,21 @@ export function useInvoice() {
})

// Actions
function addItem(item, quantity = 1) {
function findMatchingCartItem(item) {
const itemUom = item.uom || item.stock_uom
const existingItem = invoiceItems.value.find(
(i) => i.item_code === item.item_code && i.uom === itemUom,
)
return invoiceItems.value.find((i) => {
if (i.item_code !== item.item_code || i.uom !== itemUom) {
return false
}
if (item.has_batch_no || i.has_batch_no) {
return (i.batch_no || "") === (item.batch_no || "")
}
return true
})
}

function addItem(item, quantity = 1) {
const existingItem = findMatchingCartItem(item)

if (existingItem) {
// Store old values before update for incremental cache adjustment
Expand Down Expand Up @@ -316,11 +326,27 @@ export function useInvoice() {
* If provided, only removes the item with matching item_code AND uom.
* If null, removes the first item matching item_code.
*/
function removeItem(itemCode, uom = null) {
function matchesCartLine(i, itemCode, uom, batchNo) {
if (i.item_code !== itemCode) {
return false
}
if (uom && i.uom !== uom) {
return false
}
if (batchNo !== undefined && batchNo !== null) {
return (i.batch_no || "") === (batchNo || "")
}
if (i.has_batch_no && batchNo === undefined) {
return false
}
return true
}

function removeItem(itemCode, uom = null, batchNo = undefined) {
let itemToRemove
if (uom) {
itemToRemove = invoiceItems.value.find(
(i) => i.item_code === itemCode && i.uom === uom,
if (uom || batchNo !== undefined) {
itemToRemove = invoiceItems.value.find((i) =>
matchesCartLine(i, itemCode, uom, batchNo),
)
} else {
itemToRemove = invoiceItems.value.find((i) => i.item_code === itemCode)
Expand All @@ -343,9 +369,9 @@ export function useInvoice() {
}
}

if (uom) {
if (uom || batchNo !== undefined) {
invoiceItems.value = invoiceItems.value.filter(
(i) => !(i.item_code === itemCode && i.uom === uom),
(i) => !matchesCartLine(i, itemCode, uom, batchNo),
)
} else {
invoiceItems.value = invoiceItems.value.filter(
Expand All @@ -362,11 +388,11 @@ export function useInvoice() {
* If provided, only updates the item with matching item_code AND uom.
* If null, updates the first item matching item_code.
*/
function updateItemQuantity(itemCode, quantity, uom = null) {
function updateItemQuantity(itemCode, quantity, uom = null, batchNo = undefined) {
let item
if (uom) {
item = invoiceItems.value.find(
(i) => i.item_code === itemCode && i.uom === uom,
if (uom || batchNo !== undefined) {
item = invoiceItems.value.find((i) =>
matchesCartLine(i, itemCode, uom, batchNo),
)
} else {
item = invoiceItems.value.find((i) => i.item_code === itemCode)
Expand Down
36 changes: 35 additions & 1 deletion POS/src/pages/POSSale.vue
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,8 @@
:warehouses="profileWarehouses"
@update-quantity="cartStore.updateItemQuantity"
@remove-item="
(itemCode, uom) => cartStore.removeItem(itemCode, uom)
(itemCode, uom, batchNo) =>
cartStore.removeItem(itemCode, uom, batchNo)
"
@select-customer="handleCustomerSelected"
@create-customer="handleCreateCustomer"
Expand Down Expand Up @@ -2477,6 +2478,39 @@ async function handleApplyOffer(offer) {
}

function handleBatchSerialSelected(batchSerial) {
if (!cartStore.pendingItem) {
return;
}

if (batchSerial.segments?.length) {
const itemName = cartStore.pendingItem.item_name;
try {
for (const segment of batchSerial.segments) {
const itemToAdd = {
...cartStore.pendingItem,
batch_no: segment.batch_no,
expiry_date: segment.expiry_date,
};
cartStore.addItem(
itemToAdd,
segment.qty,
false,
shiftStore.currentProfile,
);
}
cartStore.clearPendingItem();
showSuccess(
__("{0} added to cart ({1} batches)", [
itemName,
batchSerial.segments.length,
]),
);
} catch (error) {
showError(error.message);
}
return;
}

if (cartStore.pendingItem) {
// Use quantity from batchSerial if provided (for multiple serial numbers), otherwise use pendingItemQty
const qty = batchSerial.quantity || cartStore.pendingItemQty;
Expand Down
Loading