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,