From bf4999d9469d3d2fcecf7587cae9f787b0ba4720 Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Fri, 6 Feb 2026 10:16:25 +0530 Subject: [PATCH 1/3] feat : added Batch Autogeneration --- fyo/models/BatchSeries.ts | 69 ++++++ fyo/models/index.ts | 2 + models/baseModels/InvoiceItem/InvoiceItem.ts | 210 +++++++++++++++++- models/baseModels/Item/Item.ts | 68 ++++++ .../PurchaseInvoice/PurchaseInvoice.ts | 34 +++ models/helpers.ts | 12 +- models/inventory/helpers.ts | 165 ++++++++++++++ models/types.ts | 1 + schemas/app/BatchSeries.json | 38 ++++ schemas/app/Item.json | 6 + schemas/schemas.ts | 2 + 11 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 fyo/models/BatchSeries.ts create mode 100644 schemas/app/BatchSeries.json diff --git a/fyo/models/BatchSeries.ts b/fyo/models/BatchSeries.ts new file mode 100644 index 00000000..d9c879c9 --- /dev/null +++ b/fyo/models/BatchSeries.ts @@ -0,0 +1,69 @@ +import { Doc } from 'fyo/model/doc'; +import { ReadOnlyMap, ValidationMap } from 'fyo/model/types'; +import { ValidationError } from 'fyo/utils/errors'; + +const invalidNumberSeries = /[/\=\?\&\%]/; + +function getPaddedName(prefix: string, next: number, padZeros: number): string { + return prefix + next.toString().padStart(padZeros ?? 4, '0'); +} + +export default class BatchSeries extends Doc { + validations: ValidationMap = { + name: (value) => { + if (typeof value !== 'string') { + return; + } + + if (invalidNumberSeries.test(value)) { + throw new ValidationError( + this.fyo + .t`The following characters cannot be used ${'/, ?, &, =, %'} in a Number Series name.` + ); + } + }, + }; + + setCurrent() { + let current = this.get('current') as number | null; + + if (!current) { + current = this.get('start') as number; + } else { + current = current + 1; + } + + this.current = current; + } + + async next(schemaName: string) { + this.setCurrent(); + const exists = await this.checkIfCurrentExists(schemaName); + + if (exists) { + this.current = (this.current as number) + 1; + } + + await this.sync(); + return this.getPaddedName(this.current as number); + } + + async checkIfCurrentExists(schemaName: string) { + if (!schemaName) { + return true; + } + + const name = this.getPaddedName(this.current as number); + return await this.fyo.db.exists(schemaName, name); + } + + getPaddedName(next: number): string { + return getPaddedName(this.name as string, next, this.padZeros as number); + } + + readOnly: ReadOnlyMap = { + referenceType: () => this.inserted, + padZeros: () => this.inserted, + start: () => this.inserted, + }; +} diff --git a/fyo/models/index.ts b/fyo/models/index.ts index 79108b64..b1a050da 100644 --- a/fyo/models/index.ts +++ b/fyo/models/index.ts @@ -1,4 +1,5 @@ import { ModelMap } from 'fyo/model/types'; +import BatchSeries from './BatchSeries'; import NumberSeries from './NumberSeries'; import SerialNumberSeries from './SerialNumberSeries'; import SystemSettings from './SystemSettings'; @@ -6,6 +7,7 @@ import { CustomField } from './CustomField'; import { CustomForm } from './CustomForm'; export const coreModels = { + BatchSeries, NumberSeries, SerialNumberSeries, SystemSettings, diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index 590702d2..a49de27f 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -21,6 +21,13 @@ import { isPesa } from 'fyo/utils'; import { PricingRule } from '../PricingRule/PricingRule'; import { getItemRateFromPriceList, getPricingRule } from 'models/helpers'; import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { getSuggestedBatchName } from 'models/inventory/helpers'; +import { ValuationMethod } from 'models/inventory/types'; +import { + getRawStockLedgerEntries, + getStockLedgerEntries, + getStockBalanceEntries, +} from 'reports/inventory/helpers'; export abstract class InvoiceItem extends Doc { item?: string; @@ -606,15 +613,122 @@ export abstract class InvoiceItem extends Doc { filters: { uom: value as string, parent: this.item }, }); - if (item.length < 1) + if (item.length < 1) { throw new ValidationError( t`Transfer Unit ${value as string} is not applicable for Item ${ this.item }` ); + } + }, + + qty: async (value: DocValue) => { + const requiredQuantity = Math.abs(value as number); + + if (!this.item || requiredQuantity <= 0) { + return; + } + + if (!this.isSales) { + return; + } + + if (!this.fyo.singles.InventorySettings?.enableBatches) { + return; + } + + if (!this.batch) { + return; + } + + await this.validateBatchQuantity(this.batch, requiredQuantity); + }, + + batch: async (value: DocValue) => { + if (!value || !this.item) { + return; + } + + if (!this.isSales) { + return; + } + + if (!this.fyo.singles.InventorySettings?.enableBatches) { + return; + } + + const requiredQuantity = this.quantity ?? 0; + + if (requiredQuantity > 0) { + await this.validateBatchQuantity(value as string, requiredQuantity); + } else if (requiredQuantity < 0) { + await this.validateBatchQuantity( + value as string, + Math.abs(requiredQuantity) + ); + } }, }; + async validateBatchQuantity( + batchName: string, + requiredQuantity: number + ): Promise { + try { + let inventoryLocation: string | undefined; + + if (this.location) { + inventoryLocation = this.location as string; + } else { + const posProfileName = this.fyo.singles.POSSettings?.posProfile; + if (posProfileName) { + const posProfile = await this.fyo.doc.getDoc( + ModelNameEnum.POSProfile, + posProfileName as string + ); + inventoryLocation = posProfile?.inventory as string | undefined; + } else { + inventoryLocation = this.fyo.singles.POSSettings?.inventory; + } + } + + const valuationMethod = + (this.fyo.singles.InventorySettings + ?.valuationMethod as ValuationMethod) ?? ValuationMethod.FIFO; + + const rawSLEs = await getRawStockLedgerEntries(this.fyo); + const computedSLEs = getStockLedgerEntries(rawSLEs, valuationMethod); + + const stockBalance = getStockBalanceEntries(computedSLEs, { + item: this.item!, + location: inventoryLocation, + batch: batchName, + }); + + const availableQuantity = stockBalance.reduce( + (sum, entry) => sum + (entry.balanceQuantity || 0), + 0 + ); + + if (requiredQuantity > availableQuantity) { + // ✅ dynamic import just like your example + const { showToast } = await import('src/utils/interactive'); + + showToast({ + type: 'warning', + message: this.fyo.t` + Batch ${batchName} only has ${availableQuantity} quantity available + but ${requiredQuantity} is required + `, + }); + } + } catch (error) { + if (error instanceof ValidationError) { + throw error; + } + } + } + hidden: HiddenMap = { itemDiscountedTotal: () => { if (!this.enableDiscounting) { @@ -655,15 +769,93 @@ export abstract class InvoiceItem extends Doc { return { for: ['not in', [itemNotFor]] }; }, batch: async (doc: Doc) => { - const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, { - fields: ['name'], - filters: { item: doc.item as string }, - }); - const batchName = batches.map((b) => b.name) as string[]; + const hasBatch = await doc.fyo.getValue( + ModelNameEnum.Item, + doc.item as string, + 'hasBatch' + ); - return { - name: ['in', batchName], - }; + if (!hasBatch) { + return { name: ['in', []] }; + } + + if (!doc.isSales) { + const batchName = await getSuggestedBatchName( + doc.fyo, + doc.item as string + ); + + if (batchName) { + await doc.set('batch', batchName); + + return { + name: ['in', [batchName]], + }; + } + } + + try { + let inventoryLocation: string | undefined; + + if (doc.location) { + inventoryLocation = doc.location as string; + } else { + const posProfileName = doc.fyo.singles.POSSettings?.posProfile; + if (posProfileName) { + const posProfile = await doc.fyo.doc.getDoc( + ModelNameEnum.POSProfile, + posProfileName as string + ); + inventoryLocation = posProfile?.inventory as string | undefined; + } else { + inventoryLocation = doc.fyo.singles.POSSettings?.inventory; + } + } + + const rawSLEs = await getRawStockLedgerEntries(doc.fyo); + + const valuationMethod = + (doc.fyo.singles.InventorySettings + ?.valuationMethod as ValuationMethod) ?? ValuationMethod.FIFO; + + const computedSLEs = getStockLedgerEntries(rawSLEs, valuationMethod); + + const stockBalance = getStockBalanceEntries(computedSLEs, { + item: doc.item as string, + location: inventoryLocation, + }); + + const batchesWithStock = stockBalance + .filter((entry) => entry.batch && entry.balanceQuantity > 0) + .map((entry) => entry.batch); + + if (batchesWithStock.length === 0) { + const allBatchesWithStock = stockBalance + .filter((entry) => entry.batch && entry.balanceQuantity > 0) + .map((entry) => entry.batch); + + if (allBatchesWithStock.length > 0) { + return { + name: ['in', allBatchesWithStock], + }; + } + } + + return { + name: ['in', batchesWithStock], + }; + } catch (error) { + // Fallback to all batches for the item + const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, { + fields: ['name'], + filters: { item: doc.item as string }, + }); + const batchName = batches.map((b) => b.name) as string[]; + + return { + name: ['in', batchName], + }; + } }, transferUnit: async (doc: Doc) => { const conversionItems = await doc.fyo.db.getAll( diff --git a/models/baseModels/Item/Item.ts b/models/baseModels/Item/Item.ts index 9971a1ba..3a02a8a4 100644 --- a/models/baseModels/Item/Item.ts +++ b/models/baseModels/Item/Item.ts @@ -14,6 +14,10 @@ import { ValidationError } from 'fyo/utils/errors'; import { Money } from 'pesa'; import { AccountRootTypeEnum, AccountTypeEnum } from '../Account/types'; +function getPaddedName(prefix: string, next: number, padZeros: number): string { + return prefix + next.toString().padStart(padZeros ?? 4, '0'); +} + interface UOMConversionItem { name: string; uom: string; @@ -26,6 +30,7 @@ export class Item extends Doc { itemType?: 'Product' | 'Service'; for?: 'Purchases' | 'Sales' | 'Both'; hasBatch?: boolean; + batchSeries?: string; itemGroup?: string; hsnCode?: number; hasSerialNumber?: boolean; @@ -100,6 +105,13 @@ export class Item extends Doc { this.serialNumberSeries = series + '-'; } } + + if (this.batchSeries && this.hasBatch) { + const series = this.batchSeries.trim(); + if (series && !series.endsWith('-')) { + this.batchSeries = series + '-'; + } + } } async afterSync(): Promise { @@ -125,6 +137,46 @@ export class Item extends Doc { .sync(); } } + + if (this.hasBatch && this.batchSeries) { + const seriesName = this.batchSeries?.trim(); + + if (!seriesName) { + return; + } + + const exists = await this.fyo.db.exists('BatchSeries', seriesName); + + if (!exists) { + await this.fyo.doc + .getNewDoc('BatchSeries', { + name: seriesName, + start: 1001, + padZeros: 4, + current: 1001, + }) + .sync(); + + const batchSeriesDoc = await this.fyo.doc.getDoc( + 'BatchSeries', + seriesName + ); + const start = (batchSeriesDoc?.start as number) ?? 1001; + const padZeros = (batchSeriesDoc?.padZeros as number) ?? 4; + const batchName = getPaddedName(seriesName, start, padZeros); + + const batchExists = await this.fyo.db.exists('Batch', batchName); + + if (!batchExists) { + await this.fyo.doc + .getNewDoc('Batch', { + name: batchName, + item: this.name as string, + }) + .sync(); + } + } + } } static filters: FiltersMap = { @@ -173,6 +225,21 @@ export class Item extends Doc { ); } }, + batchSeries: (value: DocValue) => { + if (!value) { + return; + } + + const series = (value as string).trim(); + const invalidChars = /[/\=\?\&\%]/; + + if (invalidChars.test(series)) { + throw new ValidationError( + this.fyo + .t`Batch Series cannot contain the following characters: /, ?, &, =, %` + ); + } + }, }; static getActions(fyo: Fyo): Action[] { @@ -226,6 +293,7 @@ export class Item extends Doc { this.fyo.singles.InventorySettings?.enableSerialNumber && this.trackItem ), serialNumberSeries: () => !this.hasSerialNumber, + batchSeries: () => !this.hasBatch, uomConversions: () => !this.fyo.singles.InventorySettings?.enableUomConversions, itemGroup: () => !this.fyo.singles.AccountingSettings?.enableitemGroup, diff --git a/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts b/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts index 24403eed..e4631d6e 100644 --- a/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts +++ b/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts @@ -5,10 +5,44 @@ import { ModelNameEnum } from 'models/types'; import { getInvoiceActions, getTransactionStatusColumn } from '../../helpers'; import { Invoice } from '../Invoice/Invoice'; import { PurchaseInvoiceItem } from '../PurchaseInvoiceItem/PurchaseInvoiceItem'; +import { createBatch } from 'models/inventory/helpers'; export class PurchaseInvoice extends Invoice { items?: PurchaseInvoiceItem[]; + async beforeSubmit(): Promise { + await super.beforeSubmit(); + + if (this.isReturn) { + return; + } + + const batchesToCreate: { item: string; batch: string }[] = []; + + for (const item of this.items ?? []) { + if (!item.item || !item.batch) { + continue; + } + + const hasBatch = await this.fyo.getValue( + ModelNameEnum.Item, + item.item, + 'hasBatch' + ); + + if (hasBatch) { + batchesToCreate.push({ + item: item.item, + batch: item.batch, + }); + } + } + + for (const { item, batch } of batchesToCreate) { + await createBatch(this.fyo, item, batch); + } + } + async getPosting() { const exchangeRate = this.exchangeRate ?? 1; const posting: LedgerPosting = new LedgerPosting(this, this.fyo); diff --git a/models/helpers.ts b/models/helpers.ts index 07e13435..fa5b4791 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -48,7 +48,10 @@ import { getStockLedgerEntries, } from 'reports/inventory/helpers'; import { LoyaltyPointEntry } from './baseModels/LoyaltyPointEntry/LoyaltyPointEntry'; -import { generateSerialNumbersForItem } from './inventory/helpers'; +import { + generateSerialNumbersForItem, + generateBatchForItem, +} from './inventory/helpers'; export function getQuoteActions( fyo: Fyo, @@ -761,6 +764,13 @@ export async function addItem(name: string, doc: M) { await item.set('item', name); + if (doc instanceof Invoice && !doc.isSales) { + const batchName = await generateBatchForItem(doc.fyo, name); + if (batchName) { + await item.set('batch', batchName); + } + } + if ( doc instanceof StockTransfer && doc.schemaName === ModelNameEnum.PurchaseReceipt diff --git a/models/inventory/helpers.ts b/models/inventory/helpers.ts index 4bed96a5..52e287ad 100644 --- a/models/inventory/helpers.ts +++ b/models/inventory/helpers.ts @@ -11,6 +11,7 @@ import type { StockTransferItem } from './StockTransferItem'; import { Transfer } from './Transfer'; import { TransferItem } from './TransferItem'; import type { SerialNumberStatus } from './types'; +import BatchSeries from 'fyo/models/BatchSeries'; import SerialNumberSeries from 'fyo/models/SerialNumberSeries'; export async function validateBatch( @@ -19,6 +20,33 @@ export async function validateBatch( if (doc.schemaName === ModelNameEnum.SalesQuote) { return; } + + if ( + doc.schemaName === ModelNameEnum.PurchaseInvoice || + doc.schemaName === ModelNameEnum.PurchaseReceipt + ) { + for (const row of doc.items ?? []) { + if (row.item && row.batch) { + const hasBatch = await doc.fyo.getValue( + ModelNameEnum.Item, + row.item, + 'hasBatch' + ); + + if (hasBatch) { + const batchExists = await doc.fyo.db.exists( + ModelNameEnum.Batch, + row.batch + ); + + if (!batchExists) { + await createBatch(doc.fyo, row.item, row.batch); + } + } + } + } + } + for (const row of doc.items ?? []) { await validateItemRowBatch(row); } @@ -531,3 +559,140 @@ export async function getExistingActiveSerialNumbersForItem( return selectedSerialNumbers.join('\n'); } + +export async function getSuggestedBatchName( + fyo: Fyo, + itemName: string +): Promise { + try { + const batchSeries = await fyo.getValue( + ModelNameEnum.Item, + itemName, + 'batchSeries' + ); + + if (!batchSeries) { + return undefined; + } + + const seriesName = (batchSeries as string).trim(); + const seriesExists = await fyo.db.exists('BatchSeries', seriesName); + + if (!seriesExists) { + await fyo.doc + .getNewDoc('BatchSeries', { + name: seriesName, + start: 1001, + padZeros: 4, + current: 1001, + }) + .sync(); + } + + const batchSeriesDoc = (await fyo.doc.getDoc( + 'BatchSeries', + seriesName + )) as BatchSeries; + + const padZeros = (batchSeriesDoc.padZeros as number) ?? 4; + + const prefix = seriesName.endsWith('-') ? seriesName : seriesName + '-'; + const existingBatches = (await fyo.db.getAllRaw(ModelNameEnum.Batch, { + fields: ['name'], + filters: { item: itemName }, + })) as { name: string }[]; + + let nextNumber: number; + + if (existingBatches && existingBatches.length > 0) { + let highestNumber = -1; + + for (const batch of existingBatches) { + const batchName = batch.name; + if (batchName.startsWith(prefix)) { + const numericPart = batchName.substring(prefix.length); + const num = parseInt(numericPart, 10); + + if (!isNaN(num) && num > highestNumber) { + highestNumber = num; + } + } + } + + if (highestNumber >= 0) { + nextNumber = highestNumber + 1; + } else { + nextNumber = (batchSeriesDoc.start as number) ?? 1001; + } + } else { + nextNumber = (batchSeriesDoc.start as number) ?? 1001; + } + + const batchName = prefix + nextNumber.toString().padStart(padZeros, '0'); + + return batchName; + } catch (error) { + return undefined; + } +} + +export async function createBatch( + fyo: Fyo, + itemName: string, + batchName: string +): Promise { + try { + const batchExists = await fyo.db.exists(ModelNameEnum.Batch, batchName); + if (batchExists) { + return true; + } + + const batchDoc = fyo.doc.getNewDoc('Batch', { + name: batchName, + item: itemName, + }); + + await batchDoc.sync(); + + const batchSeries = await fyo.getValue( + ModelNameEnum.Item, + itemName, + 'batchSeries' + ); + + if (batchSeries) { + const seriesName = (batchSeries as string).trim(); + const batchSeriesDoc = (await fyo.doc.getDoc( + 'BatchSeries', + seriesName + )) as BatchSeries; + + const prefix = seriesName.endsWith('-') ? seriesName : seriesName + '-'; + if (batchName.startsWith(prefix)) { + const numericPart = batchName.substring(prefix.length); + const num = parseInt(numericPart, 10); + if (!isNaN(num)) { + await batchSeriesDoc.set('current', num); + await batchSeriesDoc.sync(); + } + } + } + + return true; + } catch (error) { + return false; + } +} + +export async function generateBatchForItem( + fyo: Fyo, + itemName: string +): Promise { + const batchName = await getSuggestedBatchName(fyo, itemName); + if (!batchName) { + return undefined; + } + + const success = await createBatch(fyo, itemName, batchName); + return success ? batchName : undefined; +} diff --git a/models/types.ts b/models/types.ts index 710293c7..4805820d 100644 --- a/models/types.ts +++ b/models/types.ts @@ -15,6 +15,7 @@ export enum ModelNameEnum { AccountingSettings = 'AccountingSettings', Address = 'Address', Batch = 'Batch', + BatchSeries = 'BatchSeries', Color = 'Color', Currency = 'Currency', GetStarted = 'GetStarted', diff --git a/schemas/app/BatchSeries.json b/schemas/app/BatchSeries.json new file mode 100644 index 00000000..a36f97f4 --- /dev/null +++ b/schemas/app/BatchSeries.json @@ -0,0 +1,38 @@ +{ + "name": "BatchSeries", + "label": "Batch Series", + "naming": "manual", + "isSingle": false, + "isChild": false, + "fields": [ + { + "fieldname": "name", + "label": "Prefix", + "fieldtype": "Data", + "required": true + }, + { + "fieldname": "start", + "label": "Start", + "fieldtype": "Int", + "default": 1001, + "required": true, + "minvalue": 0 + }, + { + "fieldname": "padZeros", + "label": "Pad Zeros", + "fieldtype": "Int", + "default": 4, + "required": true + }, + { + "fieldname": "current", + "label": "Current", + "fieldtype": "Int", + "required": true, + "readOnly": true + } + ], + "quickEditFields": ["start", "padZeros"] +} diff --git a/schemas/app/Item.json b/schemas/app/Item.json index 02a483fa..5546d86f 100644 --- a/schemas/app/Item.json +++ b/schemas/app/Item.json @@ -153,6 +153,12 @@ "default": false, "section": "Inventory" }, + { + "fieldname": "batchSeries", + "label": "Batch Series", + "fieldtype": "Data", + "section": "Inventory" + }, { "fieldname": "hasSerialNumber", "label": "Has Serial Number", diff --git a/schemas/schemas.ts b/schemas/schemas.ts index 2b7b25e0..4b3fab6a 100644 --- a/schemas/schemas.ts +++ b/schemas/schemas.ts @@ -3,6 +3,7 @@ import AccountingLedgerEntry from './app/AccountingLedgerEntry.json'; import AccountingSettings from './app/AccountingSettings.json'; import Address from './app/Address.json'; import Batch from './app/Batch.json'; +import BatchSeries from './app/BatchSeries.json'; import Color from './app/Color.json'; import Currency from './app/Currency.json'; import Defaults from './app/Defaults.json'; @@ -108,6 +109,7 @@ export const appSchemas: Schema[] | SchemaStub[] = [ Defaults as Schema, NumberSeries as Schema, SerialNumberSeries as Schema, + BatchSeries as Schema, PrintSettings as Schema, From d57cb79e3386bb4933834ff41afc179d75dd1d3e Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Thu, 12 Feb 2026 16:39:40 +0530 Subject: [PATCH 2/3] fix: formatted code --- models/baseModels/InvoiceItem/InvoiceItem.ts | 148 ++++++++++-------- models/baseModels/Item/Item.ts | 6 +- models/inventory/StockMovement.ts | 63 +++++++- models/inventory/StockMovementItem.ts | 53 ++++++- models/inventory/helpers.ts | 33 ++-- .../POS/Classic/SelectedItemRow.vue | 40 ++++- .../POS/Classic/SelectedItemTable.vue | 17 +- .../POS/Modern/ModernPOSSelectedItemRow.vue | 33 +++- .../POS/Modern/ModernPOSSelectedItemTable.vue | 22 ++- src/pages/POS/ClassicPOS.vue | 9 ++ src/pages/POS/ModernPOS.vue | 9 ++ src/pages/POS/POS.vue | 8 + 12 files changed, 320 insertions(+), 121 deletions(-) diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index a49de27f..183094e8 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -3,6 +3,7 @@ import { DocValue, DocValueMap } from 'fyo/core/types'; import { Doc } from 'fyo/model/doc'; import { CurrenciesMap, + ChangeArg, FiltersMap, FormulaMap, HiddenMap, @@ -114,6 +115,27 @@ export abstract class InvoiceItem extends Doc { this._setGetCurrencies(); } + override async change(ch: ChangeArg): Promise { + await super.change(ch); + + if (ch.changed === 'item') { + if (!this.isSales && this.item) { + const hasBatch = await this.fyo.getValue( + ModelNameEnum.Item, + this.item, + 'hasBatch' + ); + + if (hasBatch) { + const batchName = await getSuggestedBatchName(this.fyo, this.item); + if (batchName) { + await this.set('batch', batchName); + } + } + } + } + } + async getTotalTaxRate(): Promise { if (!this.tax) { return 0; @@ -674,58 +696,51 @@ export abstract class InvoiceItem extends Doc { batchName: string, requiredQuantity: number ): Promise { - try { - let inventoryLocation: string | undefined; + let inventoryLocation: string | undefined; - if (this.location) { - inventoryLocation = this.location as string; + if (this.location) { + inventoryLocation = this.location as string; + } else { + const posProfileName = this.fyo.singles.POSSettings?.posProfile; + + if (posProfileName) { + const inventory = await this.fyo.getValue( + ModelNameEnum.POSProfile, + posProfileName as string, + 'inventory' + ); + + inventoryLocation = inventory as string | undefined; } else { - const posProfileName = this.fyo.singles.POSSettings?.posProfile; - if (posProfileName) { - const posProfile = await this.fyo.doc.getDoc( - ModelNameEnum.POSProfile, - posProfileName as string - ); - inventoryLocation = posProfile?.inventory as string | undefined; - } else { - inventoryLocation = this.fyo.singles.POSSettings?.inventory; - } + inventoryLocation = this.fyo.singles.POSSettings?.inventory; } + } - const valuationMethod = - (this.fyo.singles.InventorySettings - ?.valuationMethod as ValuationMethod) ?? ValuationMethod.FIFO; + const valuationMethod = + (this.fyo.singles.InventorySettings + ?.valuationMethod as ValuationMethod) ?? ValuationMethod.FIFO; - const rawSLEs = await getRawStockLedgerEntries(this.fyo); - const computedSLEs = getStockLedgerEntries(rawSLEs, valuationMethod); + const rawSLEs = await getRawStockLedgerEntries(this.fyo); + const computedSLEs = getStockLedgerEntries(rawSLEs, valuationMethod); - const stockBalance = getStockBalanceEntries(computedSLEs, { - item: this.item!, - location: inventoryLocation, - batch: batchName, - }); + const stockBalance = getStockBalanceEntries(computedSLEs, { + item: this.item!, + location: inventoryLocation, + batch: batchName, + }); - const availableQuantity = stockBalance.reduce( - (sum, entry) => sum + (entry.balanceQuantity || 0), - 0 + const availableQuantity = stockBalance.reduce( + (sum, entry) => sum + (entry.balanceQuantity || 0), + 0 + ); + + if (requiredQuantity > availableQuantity) { + throw new ValidationError( + this.fyo.t` + Batch ${batchName} only has ${availableQuantity} quantity available + but ${requiredQuantity} is required + ` ); - - if (requiredQuantity > availableQuantity) { - // ✅ dynamic import just like your example - const { showToast } = await import('src/utils/interactive'); - - showToast({ - type: 'warning', - message: this.fyo.t` - Batch ${batchName} only has ${availableQuantity} quantity available - but ${requiredQuantity} is required - `, - }); - } - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } } } @@ -769,28 +784,26 @@ export abstract class InvoiceItem extends Doc { return { for: ['not in', [itemNotFor]] }; }, batch: async (doc: Doc) => { - const hasBatch = await doc.fyo.getValue( + const hasBatch = !!(await doc.fyo.getValue( ModelNameEnum.Item, doc.item as string, 'hasBatch' - ); + )); if (!hasBatch) { return { name: ['in', []] }; } + let suggestedBatch: string | undefined; + if (!doc.isSales) { - const batchName = await getSuggestedBatchName( + suggestedBatch = await getSuggestedBatchName( doc.fyo, doc.item as string ); - if (batchName) { - await doc.set('batch', batchName); - - return { - name: ['in', [batchName]], - }; + if (suggestedBatch) { + await doc.set('batch', suggestedBatch); } } @@ -829,31 +842,32 @@ export abstract class InvoiceItem extends Doc { .filter((entry) => entry.batch && entry.balanceQuantity > 0) .map((entry) => entry.batch); - if (batchesWithStock.length === 0) { - const allBatchesWithStock = stockBalance - .filter((entry) => entry.batch && entry.balanceQuantity > 0) - .map((entry) => entry.batch); - - if (allBatchesWithStock.length > 0) { - return { - name: ['in', allBatchesWithStock], - }; - } + const allBatches = new Set(batchesWithStock); + if (suggestedBatch) { + allBatches.add(suggestedBatch); } + const finalBatchList = Array.from(allBatches); + return { - name: ['in', batchesWithStock], + name: ['in', finalBatchList], }; } catch (error) { - // Fallback to all batches for the item const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, { fields: ['name'], filters: { item: doc.item as string }, }); - const batchName = batches.map((b) => b.name) as string[]; + const batchNames = batches.map((b) => b.name) as string[]; + + const allBatches = new Set(batchNames); + if (suggestedBatch) { + allBatches.add(suggestedBatch); + } + + const finalBatchList = Array.from(allBatches); return { - name: ['in', batchName], + name: ['in', finalBatchList], }; } }, diff --git a/models/baseModels/Item/Item.ts b/models/baseModels/Item/Item.ts index 3a02a8a4..44dd91c1 100644 --- a/models/baseModels/Item/Item.ts +++ b/models/baseModels/Item/Item.ts @@ -14,10 +14,6 @@ import { ValidationError } from 'fyo/utils/errors'; import { Money } from 'pesa'; import { AccountRootTypeEnum, AccountTypeEnum } from '../Account/types'; -function getPaddedName(prefix: string, next: number, padZeros: number): string { - return prefix + next.toString().padStart(padZeros ?? 4, '0'); -} - interface UOMConversionItem { name: string; uom: string; @@ -163,7 +159,7 @@ export class Item extends Doc { ); const start = (batchSeriesDoc?.start as number) ?? 1001; const padZeros = (batchSeriesDoc?.padZeros as number) ?? 4; - const batchName = getPaddedName(seriesName, start, padZeros); + const batchName = start.toString().padStart(padZeros, '0'); const batchExists = await this.fyo.db.exists('Batch', batchName); diff --git a/models/inventory/StockMovement.ts b/models/inventory/StockMovement.ts index b68da008..3e5ee0f2 100644 --- a/models/inventory/StockMovement.ts +++ b/models/inventory/StockMovement.ts @@ -16,6 +16,8 @@ import { StockMovementItem } from './StockMovementItem'; import { Transfer } from './Transfer'; import { canValidateSerialNumber, + createBatch, + generateBatchForItem, getSerialNumberFromDoc, updateSerialNumbers, validateBatch, @@ -65,6 +67,42 @@ export class StockMovement extends Transfer { await updateSerialNumbers(this, false); } + async beforeSubmit(): Promise { + await super.beforeSubmit(); + + const batchesToCreate: { item: string; batch: string }[] = []; + + for (const item of this.items ?? []) { + if (!item.item || !item.batch) { + continue; + } + + const hasBatch = await this.fyo.getValue( + ModelNameEnum.Item, + item.item, + 'hasBatch' + ); + + if (hasBatch) { + const batchExists = await this.fyo.db.exists( + ModelNameEnum.Batch, + item.batch + ); + + if (!batchExists) { + batchesToCreate.push({ + item: item.item, + batch: item.batch, + }); + } + } + } + + for (const { item, batch } of batchesToCreate) { + await createBatch(this.fyo, item, batch); + } + } + async afterCancel(): Promise { await super.afterCancel(); await updateSerialNumbers(this, true); @@ -125,10 +163,10 @@ export class StockMovement extends Transfer { item: row.item!, rate: row.rate!, quantity: row.quantity!, - batch: row.batch!, - serialNumber: row.serialNumber!, - fromLocation: row.fromLocation, - toLocation: row.toLocation, + batch: row.batch ?? undefined, + serialNumber: row.serialNumber ?? undefined, + fromLocation: row.fromLocation ?? undefined, + toLocation: row.toLocation ?? undefined, })); } @@ -142,19 +180,30 @@ export class StockMovement extends Transfer { throw new ValidationError(t`Item ${name} not found`); } + let batch: string | null | undefined = + (itemDoc.defaultBatch as string | null | undefined) ?? null; + + if ( + this.movementType === MovementTypeEnum.MaterialReceipt && + itemDoc.hasBatch && + !batch + ) { + batch = await generateBatchForItem(this.fyo, name); + } + const item = { name: itemDoc.name, - batch: itemDoc.defaultBatch ?? null, + batch, }; if (item.batch) { const batchDoc = await this.fyo.doc.getDoc( ModelNameEnum.Batch, - item.batch as string + item.batch ); if (batchDoc && batchDoc.item !== name) { throw new ValidationError( - t`Batch ${item.batch as string} does not belong to Item ${name}` + t`Batch ${item.batch} does not belong to Item ${name}` ); } } diff --git a/models/inventory/StockMovementItem.ts b/models/inventory/StockMovementItem.ts index 9ba57bbb..e37e4c03 100644 --- a/models/inventory/StockMovementItem.ts +++ b/models/inventory/StockMovementItem.ts @@ -13,7 +13,7 @@ import { ValidationError } from 'fyo/utils/errors'; import { ModelNameEnum } from 'models/types'; import { Money } from 'pesa'; import { safeParseFloat } from 'utils/index'; -import { generateSerialNumbersForItem } from './helpers'; +import { generateSerialNumbersForItem, getSuggestedBatchName } from './helpers'; import { StockMovement } from './StockMovement'; import { TransferItem } from './TransferItem'; import { MovementTypeEnum } from './types'; @@ -80,14 +80,43 @@ export class StockMovementItem extends TransferItem { }; }, batch: async (doc: Doc) => { + let suggestedBatch: string | undefined; + let hasBatch = false; + + if (doc.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt) { + hasBatch = !!(await doc.fyo.getValue( + ModelNameEnum.Item, + doc.item as string, + 'hasBatch' + )); + + if (hasBatch) { + suggestedBatch = await getSuggestedBatchName( + doc.fyo, + doc.item as string + ); + + if (suggestedBatch) { + await doc.set('batch', suggestedBatch); + } + } + } + const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, { fields: ['name'], filters: { item: doc.item as string }, }); - const batchName = batches.map((b) => b.name) as string[]; + const existingBatchNames = batches.map((b) => b.name) as string[]; + + const allBatches = new Set(existingBatchNames); + if (suggestedBatch) { + allBatches.add(suggestedBatch); + } + + const finalBatchList = Array.from(allBatches); return { - name: ['in', batchName], + name: ['in', finalBatchList], }; }, }; @@ -338,6 +367,24 @@ export class StockMovementItem extends TransferItem { if (ch.changed === 'item') { await this.set('serialNumber', ''); + if ( + this.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt && + this.item + ) { + const hasBatch = await this.fyo.getValue( + ModelNameEnum.Item, + this.item, + 'hasBatch' + ); + + if (hasBatch) { + const batchName = await getSuggestedBatchName(this.fyo, this.item); + if (batchName) { + await this.set('batch', batchName); + } + } + } + if (shouldGenerateSerialNumbers) { await this.generateAndSetSerialNumbers(); } diff --git a/models/inventory/helpers.ts b/models/inventory/helpers.ts index 52e287ad..db2078b2 100644 --- a/models/inventory/helpers.ts +++ b/models/inventory/helpers.ts @@ -23,7 +23,9 @@ export async function validateBatch( if ( doc.schemaName === ModelNameEnum.PurchaseInvoice || - doc.schemaName === ModelNameEnum.PurchaseReceipt + doc.schemaName === ModelNameEnum.PurchaseReceipt || + doc.schemaName === ModelNameEnum.StockMovement || + doc.schemaName === ModelNameEnum.Shipment ) { for (const row of doc.items ?? []) { if (row.item && row.batch) { @@ -576,6 +578,7 @@ export async function getSuggestedBatchName( } const seriesName = (batchSeries as string).trim(); + const seriesExists = await fyo.db.exists('BatchSeries', seriesName); if (!seriesExists) { @@ -596,7 +599,6 @@ export async function getSuggestedBatchName( const padZeros = (batchSeriesDoc.padZeros as number) ?? 4; - const prefix = seriesName.endsWith('-') ? seriesName : seriesName + '-'; const existingBatches = (await fyo.db.getAllRaw(ModelNameEnum.Batch, { fields: ['name'], filters: { item: itemName }, @@ -609,13 +611,12 @@ export async function getSuggestedBatchName( for (const batch of existingBatches) { const batchName = batch.name; - if (batchName.startsWith(prefix)) { - const numericPart = batchName.substring(prefix.length); - const num = parseInt(numericPart, 10); + // Extract numeric part from batch name (handles names like "com-1001") + const numericPart = batchName.replace(seriesName, ''); + const num = parseInt(numericPart, 10); - if (!isNaN(num) && num > highestNumber) { - highestNumber = num; - } + if (!isNaN(num) && num > highestNumber) { + highestNumber = num; } } @@ -628,7 +629,9 @@ export async function getSuggestedBatchName( nextNumber = (batchSeriesDoc.start as number) ?? 1001; } - const batchName = prefix + nextNumber.toString().padStart(padZeros, '0'); + const batchName = `${seriesName}${nextNumber + .toString() + .padStart(padZeros, '0')}`; return batchName; } catch (error) { @@ -667,14 +670,10 @@ export async function createBatch( seriesName )) as BatchSeries; - const prefix = seriesName.endsWith('-') ? seriesName : seriesName + '-'; - if (batchName.startsWith(prefix)) { - const numericPart = batchName.substring(prefix.length); - const num = parseInt(numericPart, 10); - if (!isNaN(num)) { - await batchSeriesDoc.set('current', num); - await batchSeriesDoc.sync(); - } + const num = parseInt(batchName, 10); + if (!isNaN(num)) { + await batchSeriesDoc.set('current', num); + await batchSeriesDoc.sync(); } } diff --git a/src/components/POS/Classic/SelectedItemRow.vue b/src/components/POS/Classic/SelectedItemRow.vue index f3b37aad..1d697d7a 100644 --- a/src/components/POS/Classic/SelectedItemRow.vue +++ b/src/components/POS/Classic/SelectedItemRow.vue @@ -2,10 +2,10 @@ -
+
+
-
+
, + default: undefined, + }, }, - emits: ['applyPricingRule', 'selectedRow'], + emits: ['applyPricingRule', 'selectedRow', 'setExpandedBatchId'], computed: { ratio() { return [0.1, 0.9, 0.8, 0.8, 0.8, 0.8, 0.2]; diff --git a/src/components/POS/Modern/ModernPOSSelectedItemRow.vue b/src/components/POS/Modern/ModernPOSSelectedItemRow.vue index 5f5b2c47..dd24a726 100644 --- a/src/components/POS/Modern/ModernPOSSelectedItemRow.vue +++ b/src/components/POS/Modern/ModernPOSSelectedItemRow.vue @@ -3,11 +3,11 @@
-
+
, + default: undefined, + }, }, - emits: ['toggleModal', 'selectedRow', 'applyPricingRule'], + emits: [ + 'toggleModal', + 'selectedRow', + 'applyPricingRule', + 'setExpandedBatchId', + ], computed: { ratio() { return [0.1, 0.8, 0.4, 0.8, 0.8, 0.3]; diff --git a/src/pages/POS/ClassicPOS.vue b/src/pages/POS/ClassicPOS.vue index de59942f..22c14510 100644 --- a/src/pages/POS/ClassicPOS.vue +++ b/src/pages/POS/ClassicPOS.vue @@ -184,6 +184,10 @@ /> @@ -496,8 +500,13 @@ export default defineComponent({ type: String, default: '', }, + expandedBatchId: { + type: String as PropType, + default: undefined, + }, }, emits: [ + 'setExpandedBatchId', 'addItem', 'toggleView', 'toggleModal', diff --git a/src/pages/POS/ModernPOS.vue b/src/pages/POS/ModernPOS.vue index 7d278ee8..ba52e67d 100644 --- a/src/pages/POS/ModernPOS.vue +++ b/src/pages/POS/ModernPOS.vue @@ -119,6 +119,10 @@ /> , + default: undefined, + }, }, emits: [ + 'setExpandedBatchId', 'addItem', 'toggleView', 'toggleModal', diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index cdd6b032..241e798d 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -43,6 +43,8 @@ :open-return-sales-invoice-modal="openReturnSalesInvoiceModal" :open-batch-selection-modal="openBatchSelectionModal" :selected-item-for-batch="selectedItemForBatch" + :expanded-batch-id="expandedBatchId" + @set-expanded-batch-id="setExpandedBatchId" @add-item="addItem" @toggle-view="toggleView" @set-sinv-doc="setSinvDoc" @@ -100,6 +102,8 @@ :open-return-sales-invoice-modal="openReturnSalesInvoiceModal" :open-batch-selection-modal="openBatchSelectionModal" :selected-item-for-batch="selectedItemForBatch" + :expanded-batch-id="expandedBatchId" + @set-expanded-batch-id="setExpandedBatchId" @add-item="addItem" @toggle-view="toggleView" @set-sinv-doc="setSinvDoc" @@ -262,6 +266,7 @@ export default defineComponent({ quickQtyKeyUpHandler: null as ((e: KeyboardEvent) => void) | null, selectedItemForBatch: '' as string, pendingBatchItem: null as { item: POSItem; quantity: number } | null, + expandedBatchId: undefined as string | null | undefined, }; }, computed: { @@ -317,6 +322,9 @@ export default defineComponent({ setQuickQtySelectedRow(row: SalesInvoiceItem) { this.quickQtyRow = row; }, + setExpandedBatchId(rowName: string | null) { + this.expandedBatchId = rowName; + }, addQuickQtyListeners() { this.quickQtyKeyDownHandler = (e: KeyboardEvent) => this.onQuickQtyKeyDown(e); From 7569bbd21f96728eff1d52930b01c04eb7dcba83 Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Wed, 18 Feb 2026 12:44:03 +0530 Subject: [PATCH 3/3] fix:prevent modal close on Transfer Qty click --- models/baseModels/Item/Item.ts | 19 ------------------- .../POS/Classic/SelectedItemRow.vue | 4 ++-- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/models/baseModels/Item/Item.ts b/models/baseModels/Item/Item.ts index 44dd91c1..c24f992e 100644 --- a/models/baseModels/Item/Item.ts +++ b/models/baseModels/Item/Item.ts @@ -152,25 +152,6 @@ export class Item extends Doc { current: 1001, }) .sync(); - - const batchSeriesDoc = await this.fyo.doc.getDoc( - 'BatchSeries', - seriesName - ); - const start = (batchSeriesDoc?.start as number) ?? 1001; - const padZeros = (batchSeriesDoc?.padZeros as number) ?? 4; - const batchName = start.toString().padStart(padZeros, '0'); - - const batchExists = await this.fyo.db.exists('Batch', batchName); - - if (!batchExists) { - await this.fyo.doc - .getNewDoc('Batch', { - name: batchName, - item: this.name as string, - }) - .sync(); - } } } } diff --git a/src/components/POS/Classic/SelectedItemRow.vue b/src/components/POS/Classic/SelectedItemRow.vue index 1d697d7a..650a0f87 100644 --- a/src/components/POS/Classic/SelectedItemRow.vue +++ b/src/components/POS/Classic/SelectedItemRow.vue @@ -27,7 +27,7 @@

-
+
-
+