Merge pull request #1342 from Gadha2311/batch-selection

feat: enable selecting batches for batched items
This commit is contained in:
Able k Saju
2025-09-17 15:37:04 +05:30
committed by GitHub
12 changed files with 299 additions and 49 deletions
+5 -2
View File
@@ -27,6 +27,7 @@ import {
getReturnQtyTotal,
getReturnLoyaltyPoints,
getItemQtyMap,
getItemVisibility,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
@@ -1329,17 +1330,17 @@ export abstract class Invoice extends Transactional {
linkedEntries = await getLinkedEntries(sinvDoc);
}
const itemVisibility = this.fyo.singles.POSSettings?.itemVisibility;
const itemVisibility = await getItemVisibility(this.fyo);
if (!this.stockNotTransferred && itemVisibility === 'Inventory Items') {
return null;
}
const schemaName = this.stockTransferSchemaName;
const defaults = (this.fyo.singles.Defaults as Defaults) ?? {};
let terms;
let numberSeries;
if (this.isSales) {
terms = defaults.shipmentTerms ?? '';
numberSeries = defaults.shipmentNumberSeries ?? undefined;
@@ -1367,6 +1368,7 @@ export abstract class Invoice extends Transactional {
}
const transfer = this.fyo.doc.getNewDoc(schemaName, data) as StockTransfer;
for (const row of this.items ?? []) {
if (!row.item) {
continue;
@@ -1398,6 +1400,7 @@ export abstract class Invoice extends Transactional {
} else {
quantity = row.quantity;
}
const item = row.item;
const batch = row.batch || null;
const description = row.description;
+1
View File
@@ -27,6 +27,7 @@ export class Item extends Doc {
for?: 'Purchases' | 'Sales' | 'Both';
hasBatch?: boolean;
itemGroup?: string;
hsnCode?: number;
hasSerialNumber?: boolean;
uomConversions: UOMConversionItem[] = [];
+16 -2
View File
@@ -40,7 +40,7 @@ import { safeParseFloat } from 'utils/index';
import { PriceList } from './baseModels/PriceList/PriceList';
import { InvoiceItem } from './baseModels/InvoiceItem/InvoiceItem';
import { SalesInvoiceItem } from './baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { ItemQtyMap } from 'src/components/POS/types';
import { ItemQtyMap, ItemVisibility, POSItem } from 'src/components/POS/types';
import { ValuationMethod } from './inventory/types';
import {
getRawStockLedgerEntries,
@@ -113,6 +113,20 @@ export async function getItemQtyMap(doc: SalesInvoice): Promise<ItemQtyMap> {
return itemQtyMap;
}
export async function getItemVisibility(fyo: Fyo): Promise<ItemVisibility> {
const posProfileName = fyo.singles.POSSettings?.posProfile as string;
if (posProfileName) {
const posProfile = await fyo.doc.getDoc(
ModelNameEnum.POSProfile,
posProfileName
);
return posProfile?.itemVisibility as ItemVisibility;
}
return fyo.singles.POSSettings?.itemVisibility as ItemVisibility;
}
export function getStockTransferActions(
fyo: Fyo,
schemaName: ModelNameEnum.Shipment | ModelNameEnum.PurchaseReceipt
@@ -1000,7 +1014,7 @@ export async function removeLoyaltyPoint(doc: Doc) {
export async function validateQty(
sinvDoc: SalesInvoice,
item: Item | SalesInvoiceItem | undefined,
item: Item | SalesInvoiceItem | POSItem | undefined,
existingItems: InvoiceItem[]
) {
if (!item) {
+4 -3
View File
@@ -5,6 +5,7 @@ import { Money } from 'pesa';
import { StockLedgerEntry } from './StockLedgerEntry';
import { SMDetails, SMIDetails, SMTransferDetails } from './types';
import { getSerialNumbers } from './helpers';
import { getItemVisibility } from 'models/helpers';
export class StockManager {
/**
@@ -86,13 +87,13 @@ export class StockManager {
async #validate(details: SMIDetails) {
this.#validateRate(details);
this.#validateQuantity(details);
await this.#validateQuantity(details);
this.#validateLocation(details);
await this.#validateStockAvailability(details);
}
#validateQuantity(details: SMIDetails) {
const itemVisibility = this.fyo.singles.POSSettings?.itemVisibility;
async #validateQuantity(details: SMIDetails) {
const itemVisibility = await getItemVisibility(this.fyo);
if (itemVisibility !== 'Inventory Items') {
return;
}
+2
View File
@@ -69,6 +69,8 @@ export interface BaseField {
tab?: string; // UI Facing config, for grouping by tabs
abstract?: string; // Used to mark the location of a field in an Abstract schema
isCustom?: boolean; // Whether the field is a custom field
filters?: Record<string, string>;
getOptions?: () => Promise<{ label: string; value: string }[]>;
}
export type SelectOption = { value: string; label: string };
+3
View File
@@ -189,6 +189,9 @@ export default {
return getCreateFiltersFromListViewFilters(filters);
},
async getFilters() {
if (this.df.filters) {
return this.df.filters;
}
const { schemaName, fieldname } = this.df;
const getFilters = fyo.models[schemaName]?.filters?.[fieldname];
+28 -13
View File
@@ -232,6 +232,7 @@
fieldtype: 'Link',
target: 'Batch',
label: t`Batch`,
filters: { item: row.item as string},
}"
:value="row.batch"
:border="true"
@@ -241,7 +242,7 @@
/>
</div>
<div v-if="showAvlQuantityInBatch()" class="px-5 pt-6 col-span-2">
<div v-if="showAvlQuantityInBatch" class="px-5 pt-6 col-span-2">
<Float
:df="{
fieldname: 'availableQtyInBatch',
@@ -289,17 +290,19 @@ import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoic
import { Money } from 'pesa';
import { DiscountType } from '../types';
import { validateSerialNumberCount } from 'src/utils/pos';
import { validateQty } from 'models/helpers';
import { getItemVisibility, validateQty } from 'models/helpers';
import { InvoiceItem } from 'models/baseModels/InvoiceItem/InvoiceItem';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { showToast } from 'src/utils/interactive';
import { ModelNameEnum } from 'models/types';
import { POSProfile } from 'models/baseModels/POSProfile/PosProfile';
export default defineComponent({
name: 'SelectedItemRow',
components: { Currency, Data, Float, Int, Link, Text },
props: {
row: { type: SalesInvoiceItem, required: true },
batchAdded: { type: Boolean, default: false },
},
emits: ['runSinvFormulas', 'applyPricingRule', 'selectedRow'],
setup() {
@@ -315,12 +318,23 @@ export default defineComponent({
isExapanded: false,
batches: [] as string[],
availableQtyInBatch: 0,
itemVisibility: '',
defaultRate: this.row.rate as Money,
profileDiscountSetting: null as boolean | null,
profileRateSetting: null as boolean | null,
};
},
watch: {
'row.batch': {
async handler(newBatch) {
if (newBatch) {
this.availableQtyInBatch = await this.getAvailableQtyInBatch();
this.isExapanded = true;
}
},
immediate: true,
},
},
computed: {
isUOMConversionEnabled(): boolean {
return !!fyo.singles.InventorySettings?.enableUomConversions;
@@ -331,6 +345,13 @@ export default defineComponent({
isReadOnly() {
return this.row.isFreeItem;
},
showAvlQuantityInBatch() {
return (
this.row.links?.item &&
this.row.links?.item.hasBatch &&
this.itemVisibility
);
},
},
async mounted() {
@@ -349,11 +370,14 @@ export default defineComponent({
this.profileRateSetting =
!!profile?.canChangeRate ||
!!this.fyo.singles.POSSettings?.canChangeRate;
this.itemVisibility = await getItemVisibility(this.fyo);
} else {
this.profileDiscountSetting =
!!this.fyo.singles.POSSettings?.canEditDiscount;
this.profileRateSetting = !!this.fyo.singles.POSSettings?.canChangeRate;
this.itemVisibility = await getItemVisibility(this.fyo);
}
},
@@ -402,15 +426,6 @@ export default defineComponent({
return transferQty;
},
showAvlQuantityInBatch() {
const itemVisibility = this.fyo.singles.POSSettings?.itemVisibility;
return (
this.row.links?.item &&
this.row.links?.item.hasBatch &&
itemVisibility === 'Inventory Items'
);
},
isDiscountsReadOnly(isValidDiscount: boolean) {
const canEditDiscount = this.profileDiscountSetting;
@@ -419,7 +434,7 @@ export default defineComponent({
},
async setBatch(batch: string) {
this.row.set('batch', batch);
this.availableQtyInBatch = await this.getAvailableQtyInBatch();
await this.getAvailableQtyInBatch();
},
setSerialNumber(serialNumber: string) {
if (!serialNumber) {
+7 -1
View File
@@ -10,6 +10,8 @@ export type ItemGroupMap = Record<string, string>;
export type DiscountType = 'percent' | 'amount';
export type ItemVisibility = 'Inventory Items' | 'Non-Inventory Items'
export const modalNames = [
'Keyboard',
'Payment',
@@ -21,6 +23,7 @@ export const modalNames = [
'PriceList',
'ItemEnquiry',
'ReturnSalesInvoice',
'BatchSelection',
] as const;
export type ModalName = typeof modalNames[number];
@@ -44,13 +47,16 @@ export type PosEmits =
| 'selectedReturnInvoice'
| 'saveAndContinue'
| 'handlePaymentAction'
| 'setTransferClearanceDate';
| 'setTransferClearanceDate'
| 'batchSelected';
export interface POSItem {
id?: number;
image?: string;
name: string;
rate: Money;
item?: string;
batch?: string;
availableQty: number;
unit: string;
hasBatch: boolean;
+108
View File
@@ -0,0 +1,108 @@
<template>
<Modal class="h-auto w-96" :set-close-listener="false">
<p class="text-center font-semibold py-3 text-gray-800 dark:text-gray-200">
{{ t`Select the Batch` }}
</p>
<div class="px-10 pt-6">
<Link
:df="{
fieldname: 'batch',
fieldtype: 'Link',
target: 'Batch',
label: t`Batch`,
required: true,
getOptions: getBatchOptions,
filters: { item: itemCode },
}"
:value="selectedBatch"
:border="true"
:show-label="true"
@change="(value: string) => selectedBatch = value"
/>
<div class="mt-8 mb-6 grid grid-cols-2 gap-4">
<Button
class="w-full bg-green-500 dark:bg-green-700"
style="padding: 1.35rem"
:disabled="!selectedBatch"
@click="submitSelection"
>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Select` }}
</p>
</Button>
<Button
class="w-full bg-red-500 dark:bg-red-700"
style="padding: 1.35rem"
@click="closeModal"
>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Cancel` }}
</p>
</Button>
</div>
</div>
</Modal>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { t } from 'fyo';
import { showToast } from 'src/utils/interactive';
import Modal from 'src/components/Modal.vue';
import Button from 'src/components/Button.vue';
import Link from 'src/components/Controls/Link.vue';
import { ModelNameEnum } from 'models/types';
import { fyo } from 'src/initFyo';
export default defineComponent({
name: 'BatchSelectionModal',
components: {
Modal,
Button,
Link,
},
props: {
itemCode: {
type: String,
required: true,
},
},
emits: ['toggleModal', 'batchSelected'],
data() {
return {
selectedBatch: '' as string,
};
},
methods: {
async getBatchOptions() {
if (!this.itemCode) {
return [];
}
try {
const batches = (await fyo.db.getAll(ModelNameEnum.Batch, {
filters: { item: this.itemCode },
fields: ['name'],
})) as { name: string; itemCode: string }[];
return batches.map((b) => ({ label: b.name, value: b.name }));
} catch (error) {
showToast({ type: 'error', message: t`Failed to load batches` });
return [];
}
},
submitSelection() {
this.$emit('batchSelected', this.selectedBatch);
this.$emit('toggleModal', 'BatchSelection');
this.selectedBatch = '';
},
closeModal() {
this.$emit('toggleModal', 'BatchSelection');
this.selectedBatch = '';
},
},
});
</script>
+20 -3
View File
@@ -19,6 +19,13 @@
@set-loyalty-points="(points) => emitEvent('setLoyaltyPoints', points)"
/>
<BatchSelectionModal
:open-modal="openBatchSelectionModal"
:item-code="selectedItemForBatch"
@toggle-modal="emitEvent('toggleModal', 'BatchSelection')"
@batch-selected="(batch) => emitEvent('batchSelected', batch)"
/>
<SavedInvoiceModal
:open-modal="openSavedInvoiceModal"
:modal-status="openSavedInvoiceModal"
@@ -104,9 +111,7 @@
:border="true"
:value="itemSearchTerm"
:show-clear-button="true"
@keyup.enter="(item) =>
emitEvent('handleItemSearch', item.target.value as string, true)
"
@keyup.enter="(event: KeyboardEvent) => emitEvent('handleItemSearch', (event.target as HTMLInputElement).value, true)"
@change="(item: string) => emitEvent('handleItemSearch', item)"
/>
@@ -395,6 +400,7 @@ import SelectedItemTable from 'src/components/POS/Classic/SelectedItemTable.vue'
import FloatingLabelFloatInput from 'src/components/POS/FloatingLabelFloatInput.vue';
import FloatingLabelCurrencyInput from 'src/components/POS/FloatingLabelCurrencyInput.vue';
import { AppliedCouponCodes } from 'models/baseModels/AppliedCouponCodes/AppliedCouponCodes';
import BatchSelectionModal from 'src/pages/POS/BatchSelectionModal.vue';
export default defineComponent({
name: 'ClassicPOS',
@@ -418,6 +424,7 @@ export default defineComponent({
FloatingLabelFloatInput,
ReturnSalesInvoiceModal,
FloatingLabelCurrencyInput,
BatchSelectionModal,
},
props: {
paidAmount: Money,
@@ -435,6 +442,7 @@ export default defineComponent({
openLoyaltyProgramModal: Boolean,
openAppliedCouponsModal: Boolean,
openReturnSalesInvoiceModal: Boolean,
openBatchSelectionModal: Boolean,
totalQuantity: {
type: Number,
default: 0,
@@ -480,6 +488,14 @@ export default defineComponent({
required: false,
default: null,
},
batchAddedItems: {
type: Array as () => string[],
default: () => [],
},
selectedItemForBatch: {
type: String,
default: '',
},
},
emits: [
'addItem',
@@ -505,6 +521,7 @@ export default defineComponent({
'saveAndContinue',
'handlePaymentAction',
'selectedRow',
'batchSelected',
],
data() {
return {
+104 -24
View File
@@ -40,6 +40,8 @@
:open-loyalty-program-modal="openLoyaltyProgramModal"
:open-applied-coupons-modal="openAppliedCouponsModal"
:open-return-sales-invoice-modal="openReturnSalesInvoiceModal"
:open-batch-selection-modal="openBatchSelectionModal"
:selected-item-for-batch="selectedItemForBatch"
@add-item="addItem"
@toggle-view="toggleView"
@set-sinv-doc="setSinvDoc"
@@ -64,6 +66,7 @@
@save-and-continue="handleSaveAndContinue"
@handle-payment-action="handlePaymentAction"
@selected-row="setQuickQtySelectedRow"
@batch-selected="handleBatchSelected"
/>
<ModernPOS
v-else
@@ -155,6 +158,7 @@ import {
getPricingRule,
removeFreeItems,
getItemRateFromPriceList,
getItemVisibility,
} from 'models/helpers';
import {
POSItem,
@@ -214,6 +218,7 @@ export default defineComponent({
openLoyaltyProgramModal: false,
openAppliedCouponsModal: false,
openReturnSalesInvoiceModal: false,
openBatchSelectionModal: false,
totalQuantity: 0,
paidAmount: fyo.pesa(0),
@@ -242,13 +247,13 @@ export default defineComponent({
itemQtyMap: {} as ItemQtyMap,
coupons: {} as AppliedCouponCodes,
itemSerialNumbers: {} as ItemSerialNumbers,
// Quick Quantity via holding 'Q'
quickQtyActive: false,
quickQtyBuffer: '' as string,
quickQtyRow: null as SalesInvoiceItem | null,
quickQtyKeyDownHandler: null as ((e: KeyboardEvent) => void) | null,
quickQtyKeyUpHandler: null as ((e: KeyboardEvent) => void) | null,
selectedItemForBatch: '' as string,
pendingBatchItem: null as { item: POSItem; quantity: number } | null,
};
},
computed: {
@@ -416,7 +421,7 @@ export default defineComponent({
}
// Determine target row: prefer explicitly selected row; else fallback to last non-free item
let row: SalesInvoiceItem | null = this.quickQtyRow;
let row = this.quickQtyRow as SalesInvoiceItem | null;
if (!row || !(this.sinvDoc.items || []).includes(row)) {
const items = (this.sinvDoc.items || []).filter((r) => !r.isFreeItem);
row = items.length
@@ -443,11 +448,11 @@ export default defineComponent({
try {
await row.set('quantity', qty);
const existingItems =
(this.sinvDoc.items || []).filter(
(invoiceItem: InvoiceItem) =>
invoiceItem.item === row!.item && !invoiceItem.isFreeItem
) || [];
const existingItems = (this.sinvDoc.items || []).filter(
(invoiceItem) =>
(invoiceItem as InvoiceItem).item === row!.item &&
!(invoiceItem as InvoiceItem).isFreeItem
) as InvoiceItem[];
await validateQty(
this.sinvDoc as SalesInvoice,
@@ -673,9 +678,7 @@ export default defineComponent({
},
async setItems() {
const filters: Record<string, boolean | string> = {};
const itemVisibility =
this.posProfile?.itemVisibility ??
this.fyo.singles.POSSettings?.itemVisibility;
const itemVisibility = await getItemVisibility(this.fyo);
const hideUnavailable =
this.posProfile?.hideUnavailableItems ??
@@ -833,7 +836,7 @@ export default defineComponent({
);
}
},
async addItem(item: POSItem | Item | undefined, quantity?: number) {
async addItem(item: POSItem | undefined, quantity?: number) {
try {
await this.sinvDoc.runFormulas();
this.validateInvoice();
@@ -842,15 +845,23 @@ export default defineComponent({
return;
}
if (item.hasBatch) {
this.selectedItemForBatch = item.name;
this.pendingBatchItem = { item, quantity: quantity ?? 1 };
this.toggleModal('BatchSelection', true);
return;
}
const isInventoryItem = await this.fyo.getValue(
ModelNameEnum.Item,
item.name as string,
item.name,
'trackItem'
);
if (isInventoryItem) {
const availableQty =
this.itemQtyMap[item.name as string]?.availableQty ?? 0;
const availableQty = this.itemQtyMap[item.name]?.availableQty ?? 0;
if (availableQty <= 0) {
throw new ValidationError(
t`Item is out of stock (quantity is zero)`
@@ -866,13 +877,13 @@ export default defineComponent({
await validateQty(
this.sinvDoc as SalesInvoice,
item as Item,
item,
existingItems as InvoiceItem[]
);
const itemsHsncode = (await this.fyo.getValue(
'Item',
item?.name as string,
item?.name,
'hsnCode'
)) as number;
@@ -903,7 +914,7 @@ export default defineComponent({
}
await this.sinvDoc.append('items', {
rate: item.rate as Money,
rate: item.rate,
item: item.name,
quantity: addQty,
hsnCode: itemsHsncode,
@@ -915,14 +926,13 @@ export default defineComponent({
if (existingItems.length) {
if (!this.sinvDoc.priceList) {
existingItems[0].rate = item.rate as Money;
existingItems[0].rate = item.rate;
}
const currentQty = existingItems[0].quantity ?? 0;
const addQty = quantity ?? 1;
if (isInventoryItem) {
const availableQty =
this.itemQtyMap[item.name as string]?.availableQty ?? 0;
const availableQty = this.itemQtyMap[item.name]?.availableQty ?? 0;
if (currentQty + addQty > availableQty) {
throw new ValidationError(
'Cannot add more than the available quantity'
@@ -936,7 +946,7 @@ export default defineComponent({
if (isInventoryItem) {
await validateQty(
this.sinvDoc as SalesInvoice,
item as Item,
item,
existingItems as InvoiceItem[]
);
}
@@ -944,7 +954,7 @@ export default defineComponent({
}
await this.sinvDoc.append('items', {
rate: item.rate as Money,
rate: item.rate,
item: item.name,
quantity: quantity ? quantity : 1,
hsnCode: itemsHsncode,
@@ -971,13 +981,83 @@ export default defineComponent({
});
}
},
async handleBatchSelected(batchName: string) {
if (!this.pendingBatchItem) {
return;
}
const { item, quantity } = this.pendingBatchItem;
this.pendingBatchItem = null;
try {
const itemDoc = (await this.fyo.doc.getDoc(
ModelNameEnum.Item,
item.name
)) as Item;
let availableQty = 0;
if (itemDoc.trackItem) {
availableQty =
(await fyo.db.getStockQuantity(
item.name,
undefined,
undefined,
undefined,
batchName
)) ?? 0;
const itemIndex = this.items.findIndex((i) => i.name === item.name);
if (itemIndex !== -1) {
this.items[itemIndex].availableQty = availableQty ?? 0;
}
}
const existingItems =
this.sinvDoc.items?.filter(
(invoiceItem) =>
invoiceItem.item === item.name &&
invoiceItem.batch === batchName &&
!invoiceItem.isFreeItem
) ?? [];
await validateQty(
this.sinvDoc as SalesInvoice,
itemDoc,
existingItems as InvoiceItem[]
);
if (existingItems.length) {
const currentQty = existingItems[0].quantity ?? 0;
const addQty = quantity ?? 1;
await existingItems[0].set('quantity', currentQty + addQty);
} else {
await this.sinvDoc.append('items', {
rate: item.rate as Money,
item: item.name,
quantity: quantity ?? 1,
hsnCode: itemDoc.hsnCode,
batch: batchName,
});
}
await this.applyPricingRule();
await this.sinvDoc.runFormulas();
await this.setItemQtyMap();
} catch (error) {
showToast({
type: 'error',
message: t`${error as string}`,
});
}
},
async createTransaction(shouldPrint = false, isPay = false) {
try {
this.sinvDoc.date = new Date();
await this.validate();
await this.submitSinvDoc();
const itemVisibility = this.fyo.singles.POSSettings?.itemVisibility;
const itemVisibility = await getItemVisibility(this.fyo);
if (
this.sinvDoc.stockNotTransferred &&
+1 -1
View File
@@ -329,7 +329,7 @@ export default defineComponent({
return false;
},
},
async activated() {
async mounted() {
await this.setPaymentMethods();
},
methods: {