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);