Merge pull request #1419 from Gadha2311/serial-autogenerate

feat: add auto-generation for serial numbers
This commit is contained in:
Able k Saju
2026-01-31 10:41:59 +05:30
committed by GitHub
17 changed files with 842 additions and 47 deletions
+69
View File
@@ -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 SerialNumberSeries 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,
};
}
+2
View File
@@ -1,11 +1,13 @@
import { ModelMap } from 'fyo/model/types'; import { ModelMap } from 'fyo/model/types';
import NumberSeries from './NumberSeries'; import NumberSeries from './NumberSeries';
import SerialNumberSeries from './SerialNumberSeries';
import SystemSettings from './SystemSettings'; import SystemSettings from './SystemSettings';
import { CustomField } from './CustomField'; import { CustomField } from './CustomField';
import { CustomForm } from './CustomForm'; import { CustomForm } from './CustomForm';
export const coreModels = { export const coreModels = {
NumberSeries, NumberSeries,
SerialNumberSeries,
SystemSettings, SystemSettings,
CustomForm, CustomForm,
CustomField, CustomField,
+49
View File
@@ -29,6 +29,7 @@ export class Item extends Doc {
itemGroup?: string; itemGroup?: string;
hsnCode?: number; hsnCode?: number;
hasSerialNumber?: boolean; hasSerialNumber?: boolean;
serialNumberSeries?: string;
uomConversions: UOMConversionItem[] = []; uomConversions: UOMConversionItem[] = [];
formulas: FormulaMap = { formulas: FormulaMap = {
@@ -92,6 +93,38 @@ export class Item extends Doc {
}); });
this.uomConversions = Array.from(latestByUom.values()); this.uomConversions = Array.from(latestByUom.values());
if (this.serialNumberSeries && this.hasSerialNumber) {
const series = this.serialNumberSeries.trim();
if (series && !series.endsWith('-')) {
this.serialNumberSeries = series + '-';
}
}
}
async afterSync(): Promise<void> {
await super.afterSync();
if (this.hasSerialNumber && this.serialNumberSeries) {
const seriesName = this.serialNumberSeries?.trim();
if (!seriesName) {
return;
}
const exists = await this.fyo.db.exists('SerialNumberSeries', seriesName);
if (!exists) {
await this.fyo.doc
.getNewDoc('SerialNumberSeries', {
name: seriesName,
start: 1001,
padZeros: 4,
current: 1001,
})
.sync();
}
}
} }
static filters: FiltersMap = { static filters: FiltersMap = {
@@ -125,6 +158,21 @@ export class Item extends Doc {
throw new ValidationError(this.fyo.t`Invalid HSN Code.`); throw new ValidationError(this.fyo.t`Invalid HSN Code.`);
} }
}, },
serialNumberSeries: (value: DocValue) => {
if (!value) {
return;
}
const series = (value as string).trim();
const invalidChars = /[/\=\?\&\%]/;
if (invalidChars.test(series)) {
throw new ValidationError(
this.fyo
.t`Serial Number Series cannot contain the following characters: /, ?, &, =, %`
);
}
},
}; };
static getActions(fyo: Fyo): Action[] { static getActions(fyo: Fyo): Action[] {
@@ -177,6 +225,7 @@ export class Item extends Doc {
!( !(
this.fyo.singles.InventorySettings?.enableSerialNumber && this.trackItem this.fyo.singles.InventorySettings?.enableSerialNumber && this.trackItem
), ),
serialNumberSeries: () => !this.hasSerialNumber,
uomConversions: () => uomConversions: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions, !this.fyo.singles.InventorySettings?.enableUomConversions,
itemGroup: () => !this.fyo.singles.AccountingSettings?.enableitemGroup, itemGroup: () => !this.fyo.singles.AccountingSettings?.enableitemGroup,
+11
View File
@@ -48,6 +48,7 @@ import {
getStockLedgerEntries, getStockLedgerEntries,
} from 'reports/inventory/helpers'; } from 'reports/inventory/helpers';
import { LoyaltyPointEntry } from './baseModels/LoyaltyPointEntry/LoyaltyPointEntry'; import { LoyaltyPointEntry } from './baseModels/LoyaltyPointEntry/LoyaltyPointEntry';
import { generateSerialNumbersForItem } from './inventory/helpers';
export function getQuoteActions( export function getQuoteActions(
fyo: Fyo, fyo: Fyo,
@@ -759,6 +760,16 @@ export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
} }
await item.set('item', name); await item.set('item', name);
if (
doc instanceof StockTransfer &&
doc.schemaName === ModelNameEnum.PurchaseReceipt
) {
const serialNumbers = await generateSerialNumbersForItem(doc.fyo, name, 1);
if (serialNumbers) {
await item.set('serialNumber', serialNumbers);
}
}
} }
export async function getReturnLoyaltyPoints(doc: Invoice) { export async function getReturnLoyaltyPoints(doc: Invoice) {
+44
View File
@@ -1,6 +1,7 @@
import { t } from 'fyo'; import { t } from 'fyo';
import { DocValue } from 'fyo/core/types'; import { DocValue } from 'fyo/core/types';
import { import {
ChangeArg,
FiltersMap, FiltersMap,
FormulaMap, FormulaMap,
HiddenMap, HiddenMap,
@@ -12,6 +13,7 @@ import { ValidationError } from 'fyo/utils/errors';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { safeParseFloat } from 'utils/index'; import { safeParseFloat } from 'utils/index';
import { generateSerialNumbersForItem } from './helpers';
import { StockMovement } from './StockMovement'; import { StockMovement } from './StockMovement';
import { TransferItem } from './TransferItem'; import { TransferItem } from './TransferItem';
import { MovementTypeEnum } from './types'; import { MovementTypeEnum } from './types';
@@ -323,4 +325,46 @@ export class StockMovementItem extends TransferItem {
static createFilters: FiltersMap = { static createFilters: FiltersMap = {
item: () => ({ trackItem: true, itemType: 'Product' }), item: () => ({ trackItem: true, itemType: 'Product' }),
}; };
override async change(ch: ChangeArg): Promise<void> {
await super.change(ch);
const shouldGenerateSerialNumbers =
this.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt &&
this.item &&
this.quantity &&
this.quantity > 0;
if (ch.changed === 'item') {
await this.set('serialNumber', '');
if (shouldGenerateSerialNumbers) {
await this.generateAndSetSerialNumbers();
}
}
if (ch.changed === 'quantity') {
if (!this.quantity || this.quantity <= 0) {
await this.set('serialNumber', '');
} else if (shouldGenerateSerialNumbers) {
await this.generateAndSetSerialNumbers();
}
}
}
private async generateAndSetSerialNumbers(): Promise<void> {
if (!this.item || !this.quantity) {
return;
}
const serialNumbers = await generateSerialNumbersForItem(
this.fyo,
this.item,
Math.abs(this.quantity)
);
if (serialNumbers) {
await this.set('serialNumber', serialNumbers);
}
}
} }
+27
View File
@@ -25,6 +25,7 @@ import {
updateSerialNumbers, updateSerialNumbers,
validateBatch, validateBatch,
validateSerialNumber, validateSerialNumber,
generateSerialNumbersForItem,
} from './helpers'; } from './helpers';
import { ReturnDocItem } from './types'; import { ReturnDocItem } from './types';
import { getShipmentCOGSAmountFromSLEs } from 'reports/inventory/helpers'; import { getShipmentCOGSAmountFromSLEs } from 'reports/inventory/helpers';
@@ -468,6 +469,32 @@ export abstract class StockTransfer extends Transfer {
await this.set('terms', stDoc.terms); await this.set('terms', stDoc.terms);
await this.set('date', stDoc.date); await this.set('date', stDoc.date);
await this.set('items', stDoc.items); await this.set('items', stDoc.items);
if (this.items) {
for (const item of this.items) {
if (!item.item || !item.quantity) {
continue;
}
const hasSerialNumber = await this.fyo.getValue(
ModelNameEnum.Item,
item.item,
'hasSerialNumber'
);
if (hasSerialNumber) {
const serialNumbers = await generateSerialNumbersForItem(
this.fyo,
item.item,
Math.abs(item.quantity)
);
if (serialNumbers) {
await item.set('serialNumber', serialNumbers);
}
}
}
}
} }
async getInvoice(): Promise<Invoice | null> { async getInvoice(): Promise<Invoice | null> {
+78
View File
@@ -14,6 +14,10 @@ import { StockTransfer } from './StockTransfer';
import { TransferItem } from './TransferItem'; import { TransferItem } from './TransferItem';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { PurchaseInvoice } from 'models/baseModels/PurchaseInvoice/PurchaseInvoice'; import { PurchaseInvoice } from 'models/baseModels/PurchaseInvoice/PurchaseInvoice';
import {
generateSerialNumbersForItem,
getExistingActiveSerialNumbersForItem,
} from './helpers';
export class StockTransferItem extends TransferItem { export class StockTransferItem extends TransferItem {
item?: string; item?: string;
@@ -243,6 +247,80 @@ export class StockTransferItem extends TransferItem {
} }
}, },
}, },
serialNumber: {
formula: async () => {
if (this.serialNumber) {
return this.serialNumber;
}
if (!this.item || !this.parentdoc?.backReference) {
return undefined;
}
const hasSerialNumber = await this.fyo.getValue(
ModelNameEnum.Item,
this.item,
'hasSerialNumber'
);
if (!hasSerialNumber) {
return undefined;
}
const quantity = Math.abs(this.quantity ?? 0);
if (quantity <= 0) {
return undefined;
}
try {
if (
!this.isSales &&
this.parentdoc?.schemaName === ModelNameEnum.PurchaseReceipt
) {
const serialNumbers = await generateSerialNumbersForItem(
this.fyo,
this.item,
quantity
);
if (serialNumbers) {
return serialNumbers;
}
}
if (
this.isSales &&
this.parentdoc?.schemaName === ModelNameEnum.Shipment
) {
const salesInvoice = (await this.fyo.doc.getDoc(
ModelNameEnum.SalesInvoice,
this.parentdoc.backReference
)) as SalesInvoice;
const invoiceItem = salesInvoice?.items?.find(
(val) => val.item === this.item
);
if (invoiceItem?.serialNumber) {
return invoiceItem.serialNumber as string;
}
const serialNumbers = await getExistingActiveSerialNumbersForItem(
this.fyo,
this.item,
quantity
);
if (serialNumbers) {
return serialNumbers;
}
}
} catch (error) {}
return undefined;
},
dependsOn: ['item', 'quantity'],
},
}; };
validations: ValidationMap = { validations: ValidationMap = {
+192 -1
View File
@@ -6,11 +6,12 @@ import { ModelNameEnum } from 'models/types';
import { SerialNumber } from './SerialNumber'; import { SerialNumber } from './SerialNumber';
import type { StockMovement } from './StockMovement'; import type { StockMovement } from './StockMovement';
import type { StockMovementItem } from './StockMovementItem'; import type { StockMovementItem } from './StockMovementItem';
import type { StockTransfer } from './StockTransfer'; import { StockTransfer } from './StockTransfer';
import type { StockTransferItem } from './StockTransferItem'; import type { StockTransferItem } from './StockTransferItem';
import { Transfer } from './Transfer'; import { Transfer } from './Transfer';
import { TransferItem } from './TransferItem'; import { TransferItem } from './TransferItem';
import type { SerialNumberStatus } from './types'; import type { SerialNumberStatus } from './types';
import SerialNumberSeries from 'fyo/models/SerialNumberSeries';
export async function validateBatch( export async function validateBatch(
doc: StockMovement | StockTransfer | Invoice doc: StockMovement | StockTransfer | Invoice
@@ -340,3 +341,193 @@ function getSerialNumberStatusForStockMovement(
return isCancel ? 'Inactive' : 'Active'; return isCancel ? 'Inactive' : 'Active';
} }
export async function generateSerialNumbersForItem(
fyo: Fyo,
item: string,
quantity: number
): Promise<string> {
if (!quantity || quantity <= 0) {
return '';
}
const hasSerialNumber = await fyo.getValue(
ModelNameEnum.Item,
item,
'hasSerialNumber'
);
if (!hasSerialNumber) {
return '';
}
const serialNumberSeries = await fyo.getValue(
ModelNameEnum.Item,
item,
'serialNumberSeries'
);
if (!serialNumberSeries || typeof serialNumberSeries !== 'string') {
return '';
}
const seriesName = serialNumberSeries.trim();
if (!seriesName) {
return '';
}
const exists = await fyo.db.exists(
ModelNameEnum.SerialNumberSeries,
seriesName
);
if (!exists) {
return '';
}
const seriesDoc = (await fyo.doc.getDoc(
ModelNameEnum.SerialNumberSeries,
seriesName
)) as SerialNumberSeries;
const serialNumbers: string[] = [];
const padZeros = seriesDoc.padZeros as number;
let currentValue = await getHighestSerialNumberForItem(fyo, item, seriesName);
if (currentValue === null) {
currentValue =
((seriesDoc.current as number) || (seriesDoc.start as number)) - 1;
}
while (serialNumbers.length < quantity) {
currentValue++;
const serialNumber = getPaddedName(seriesName, currentValue, padZeros);
const snExists = await fyo.db.exists(
ModelNameEnum.SerialNumber,
serialNumber
);
if (!snExists) {
serialNumbers.push(serialNumber);
}
}
if (serialNumbers.length > 0) {
await seriesDoc.set('current', currentValue);
await seriesDoc.sync();
}
const result = serialNumbers.join('\n');
return result;
}
async function getHighestSerialNumberForItem(
fyo: Fyo,
item: string,
seriesName: string
): Promise<number | null> {
const serialNumbers = await fyo.db.getAllRaw(ModelNameEnum.SerialNumber, {
filters: { item: item },
fields: ['name'],
});
if (!serialNumbers || serialNumbers.length === 0) {
return null;
}
let highestValue = -1;
for (const sn of serialNumbers) {
const name = sn.name as string;
if (name.startsWith(seriesName)) {
const numericPart = name.substring(seriesName.length);
const value = parseInt(numericPart, 10);
if (!isNaN(value) && value > highestValue) {
highestValue = value;
}
}
}
return highestValue >= 0 ? highestValue : null;
}
function getPaddedName(prefix: string, next: number, padZeros: number): string {
return prefix + next.toString().padStart(padZeros ?? 4, '0');
}
export async function getExistingActiveSerialNumbersForItem(
fyo: Fyo,
item: string,
quantity: number
): Promise<string> {
if (!quantity || quantity <= 0) {
return '';
}
const hasSerialNumber = await fyo.getValue(
ModelNameEnum.Item,
item,
'hasSerialNumber'
);
if (!hasSerialNumber) {
return '';
}
const stockLedgerEntries = (await fyo.db.getAllRaw(
ModelNameEnum.StockLedgerEntry,
{
fields: ['serialNumber', 'date', 'quantity'],
filters: {
item: item,
serialNumber: ['!=', ''],
},
orderBy: ['date', 'created', 'name'],
order: 'asc',
}
)) as { serialNumber: string; date: string; quantity: number }[];
if (!stockLedgerEntries || stockLedgerEntries.length === 0) {
return '';
}
const serialNumberStockMap: Record<string, number> = {};
for (const entry of stockLedgerEntries) {
const sn = entry.serialNumber.trim();
if (!sn) continue;
serialNumberStockMap[sn] = (serialNumberStockMap[sn] || 0) + entry.quantity;
}
const availableSerialNumbers: string[] = [];
const seenSerialNumbers = new Set<string>();
for (const entry of stockLedgerEntries) {
const sn = entry.serialNumber.trim();
if (!sn) continue;
if (seenSerialNumbers.has(sn)) continue;
if ((serialNumberStockMap[sn] || 0) > 0) {
availableSerialNumbers.push(sn);
seenSerialNumbers.add(sn);
if (availableSerialNumbers.length >= quantity) {
break;
}
}
}
if (availableSerialNumbers.length === 0) {
return '';
}
const selectedSerialNumbers = availableSerialNumbers.slice(0, quantity);
return selectedSerialNumbers.join('\n');
}
+1
View File
@@ -28,6 +28,7 @@ export enum ModelNameEnum {
JournalEntryAccount = 'JournalEntryAccount', JournalEntryAccount = 'JournalEntryAccount',
Misc = 'Misc', Misc = 'Misc',
NumberSeries = 'NumberSeries', NumberSeries = 'NumberSeries',
SerialNumberSeries = 'SerialNumberSeries',
Lead = 'Lead', Lead = 'Lead',
Party = 'Party', Party = 'Party',
LoyaltyProgram = 'LoyaltyProgram', LoyaltyProgram = 'LoyaltyProgram',
+53 -7
View File
@@ -6,7 +6,7 @@ import { ColumnField, ReportData } from 'reports/types';
import { Field } from 'schemas/types'; import { Field } from 'schemas/types';
import { getStockBalanceEntries } from './helpers'; import { getStockBalanceEntries } from './helpers';
import { StockLedger } from './StockLedger'; import { StockLedger } from './StockLedger';
import { ReferenceType } from './types'; import { ReferenceType, SerialNumberStatus } from './types';
export class StockBalance extends StockLedger { export class StockBalance extends StockLedger {
static title = t`Stock Balance`; static title = t`Stock Balance`;
@@ -17,6 +17,9 @@ export class StockBalance extends StockLedger {
override referenceType: ReferenceType = 'All'; override referenceType: ReferenceType = 'All';
override referenceName = ''; override referenceName = '';
showSerialNumbers = false;
serialNumberFilter: SerialNumberStatus = 'All';
override async _getReportData(force?: boolean): Promise<ReportData> { override async _getReportData(force?: boolean): Promise<ReportData> {
if (this.shouldRefresh || force || !this._rawData?.length) { if (this.shouldRefresh || force || !this._rawData?.length) {
await this._setRawData(); await this._setRawData();
@@ -30,7 +33,12 @@ export class StockBalance extends StockLedger {
toDate: this.toDate, toDate: this.toDate,
}; };
const rawData = getStockBalanceEntries(this._rawData ?? [], filters); const rawData = getStockBalanceEntries(
this._rawData ?? [],
filters,
this.showSerialNumbers,
this.serialNumberFilter
);
return rawData.map((sbe, i) => { return rawData.map((sbe, i) => {
const row = { ...sbe, name: i + 1 } as RawValueMap; const row = { ...sbe, name: i + 1 } as RawValueMap;
@@ -81,12 +89,53 @@ export class StockBalance extends StockLedger {
label: t`To Date`, label: t`To Date`,
fieldname: 'toDate', fieldname: 'toDate',
}, },
{
fieldtype: 'Check',
placeholder: t`Serial Number`,
label: t`Serial Number`,
fieldname: 'showSerialNumbers',
},
...(this.showSerialNumbers
? ([
{
fieldtype: 'Select',
options: [
{ label: t`All`, value: 'All' },
{ label: t`In stock`, value: 'In stock' },
{ label: t`Out stock`, value: 'Out stock' },
],
placeholder: t`Serial Number Status`,
label: t`Serial Number Status`,
fieldname: 'serialNumberFilter',
default: 'All',
},
] as Field[])
: []),
] as Field[]; ] as Field[];
return filters; return filters;
} }
getColumns(): ColumnField[] { getColumns(): ColumnField[] {
const batch: ColumnField[] = [];
const serialNumber: ColumnField[] = [];
if (this.hasBatches) {
batch.push({
fieldname: 'batch',
label: 'Batch',
fieldtype: 'Link',
});
}
if (this.showSerialNumbers && this.hasSerialNumbers) {
serialNumber.push({
fieldname: 'serialNumber',
label: 'Serial Number',
fieldtype: 'Data',
});
}
return [ return [
{ {
fieldname: 'name', fieldname: 'name',
@@ -104,11 +153,8 @@ export class StockBalance extends StockLedger {
label: 'Location', label: 'Location',
fieldtype: 'Link', fieldtype: 'Link',
}, },
...(this.hasBatches ...batch,
? ([ ...serialNumber,
{ fieldname: 'batch', label: 'Batch', fieldtype: 'Link' },
] as ColumnField[])
: []),
{ {
fieldname: 'balanceQuantity', fieldname: 'balanceQuantity',
label: 'Balance Qty.', label: 'Balance Qty.',
+36 -10
View File
@@ -6,6 +6,7 @@ import { safeParseFloat, safeParseInt } from 'utils/index';
import type { import type {
ComputedStockLedgerEntry, ComputedStockLedgerEntry,
RawStockLedgerEntry, RawStockLedgerEntry,
SerialNumberStatus,
StockBalanceEntry, StockBalanceEntry,
} from './types'; } from './types';
import type { QueryFilter } from 'utils/db/types'; import type { QueryFilter } from 'utils/db/types';
@@ -192,11 +193,13 @@ export function getStockBalanceEntries(
fromDate?: string; fromDate?: string;
toDate?: string; toDate?: string;
batch?: string; batch?: string;
} },
showSerialNumbers = false,
serialNumberFilter: SerialNumberStatus = 'All'
): StockBalanceEntry[] { ): StockBalanceEntry[] {
const sbeMap: Record< const sbeMap: Record<
Item, Item,
Record<Location, Record<Batch, StockBalanceEntry>> Record<Location, Record<Batch, Record<string, StockBalanceEntry>>>
> = {}; > = {};
const fromDate = filters.fromDate ? Date.parse(filters.fromDate) : null; const fromDate = filters.fromDate ? Date.parse(filters.fromDate) : null;
@@ -215,19 +218,29 @@ export function getStockBalanceEntries(
continue; continue;
} }
if (
showSerialNumbers &&
(!sle.serialNumber || sle.serialNumber.trim() === '')
) {
continue;
}
const batch = sle.batch || ''; const batch = sle.batch || '';
const serialNumber = showSerialNumbers ? sle.serialNumber : '';
sbeMap[sle.item] ??= {}; sbeMap[sle.item] ??= {};
sbeMap[sle.item][sle.location] ??= {}; sbeMap[sle.item][sle.location] ??= {};
sbeMap[sle.item][sle.location][batch] ??= getSBE( sbeMap[sle.item][sle.location][batch] ??= {};
sbeMap[sle.item][sle.location][batch][serialNumber] ??= getSBE(
sle.item, sle.item,
sle.location, sle.location,
batch batch,
serialNumber
); );
const date = sle.date.valueOf(); const date = sle.date.valueOf();
if (fromDate && date < fromDate) { if (fromDate && date < fromDate) {
const sbe = sbeMap[sle.item][sle.location][batch]; const sbe = sbeMap[sle.item][sle.location][batch][serialNumber];
updateOpeningBalances(sbe, sle); updateOpeningBalances(sbe, sle);
continue; continue;
} }
@@ -236,21 +249,33 @@ export function getStockBalanceEntries(
continue; continue;
} }
const sbe = sbeMap[sle.item][sle.location][batch]; const sbe = sbeMap[sle.item][sle.location][batch][serialNumber];
updateCurrentBalances(sbe, sle); updateCurrentBalances(sbe, sle);
} }
return Object.values(sbeMap) let entries = Object.values(sbeMap)
.map((sbeBatched) => .map((sbeBatched) =>
Object.values(sbeBatched).map((sbes) => Object.values(sbes)) Object.values(sbeBatched).map((sbes) =>
Object.values(sbes).map((sbeWithSN) => Object.values(sbeWithSN))
)
) )
.flat(2); .flat(3);
// Filter by serial number status
if (serialNumberFilter === 'In stock') {
entries = entries.filter((entry) => entry.balanceQuantity > 0);
} else if (serialNumberFilter === 'Out stock') {
entries = entries.filter((entry) => entry.balanceQuantity <= 0);
}
return entries;
} }
function getSBE( function getSBE(
item: string, item: string,
location: string, location: string,
batch: string batch: string,
serialNumber: string
): StockBalanceEntry { ): StockBalanceEntry {
return { return {
name: 0, name: 0,
@@ -258,6 +283,7 @@ function getSBE(
item, item,
location, location,
batch, batch,
serialNumber,
balanceQuantity: 0, balanceQuantity: 0,
balanceValue: 0, balanceValue: 0,
+7 -3
View File
@@ -44,16 +44,17 @@ export interface StockBalanceEntry{
item: string; item: string;
location:string; location:string;
batch: string; batch: string;
serialNumber: string;
balanceQuantity: number; balanceQuantity: number;
balanceValue: number; balanceValue: number;
openingQuantity: number; openingQuantity: number;
openingValue:number; openingValue:number;
incomingQuantity:number; incomingQuantity:number;
incomingValue:number; incomingValue:number;
outgoingQuantity:number; outgoingQuantity:number;
outgoingValue:number; outgoingValue:number;
@@ -65,3 +66,6 @@ export type ReferenceType =
| ModelNameEnum.Shipment | ModelNameEnum.Shipment
| ModelNameEnum.PurchaseReceipt | ModelNameEnum.PurchaseReceipt
| 'All'; | 'All';
export type SerialNumberStatus = 'All' | 'In stock' | 'Out stock';
+6
View File
@@ -160,6 +160,12 @@
"default": false, "default": false,
"section": "Inventory" "section": "Inventory"
}, },
{
"fieldname": "serialNumberSeries",
"label": "Serial Number Series",
"fieldtype": "Data",
"section": "Inventory"
},
{ {
"fieldname": "uomConversions", "fieldname": "uomConversions",
"label": "UOM Conversions", "label": "UOM Conversions",
+38
View File
@@ -0,0 +1,38 @@
{
"name": "SerialNumberSeries",
"label": "Serial Number 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"]
}
+2
View File
@@ -14,6 +14,7 @@ import JournalEntry from './app/JournalEntry.json';
import JournalEntryAccount from './app/JournalEntryAccount.json'; import JournalEntryAccount from './app/JournalEntryAccount.json';
import Misc from './app/Misc.json'; import Misc from './app/Misc.json';
import NumberSeries from './app/NumberSeries.json'; import NumberSeries from './app/NumberSeries.json';
import SerialNumberSeries from './app/SerialNumberSeries.json';
import Party from './app/Party.json'; import Party from './app/Party.json';
import Lead from './app/Lead.json'; import Lead from './app/Lead.json';
import LoyaltyProgram from './app/LoyaltyProgram.json'; import LoyaltyProgram from './app/LoyaltyProgram.json';
@@ -106,6 +107,7 @@ export const appSchemas: Schema[] | SchemaStub[] = [
Currency as Schema, Currency as Schema,
Defaults as Schema, Defaults as Schema,
NumberSeries as Schema, NumberSeries as Schema,
SerialNumberSeries as Schema,
PrintSettings as Schema, PrintSettings as Schema,
+122 -1
View File
@@ -267,7 +267,7 @@
fieldtype: 'Text', fieldtype: 'Text',
fieldname: 'serialNumber', fieldname: 'serialNumber',
}" }"
:value="row.serialNumber" :value="itemSerialNumbers[row.item as string] || row.serialNumber"
:show-label="true" :show-label="true"
:border="true" :border="true"
:required="hasSerialNumber" :required="hasSerialNumber"
@@ -297,6 +297,7 @@ import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { showToast } from 'src/utils/interactive'; import { showToast } from 'src/utils/interactive';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import AutoComplete from 'src/components/Controls/AutoComplete.vue'; import AutoComplete from 'src/components/Controls/AutoComplete.vue';
import { getExistingActiveSerialNumbersForItem } from 'models/inventory/helpers';
export default defineComponent({ export default defineComponent({
name: 'SelectedItemRow', name: 'SelectedItemRow',
@@ -324,6 +325,9 @@ export default defineComponent({
profileDiscountSetting: null as boolean | null, profileDiscountSetting: null as boolean | null,
profileRateSetting: null as boolean | null, profileRateSetting: null as boolean | null,
transferUnitOptions: [] as Array<{ label: string; value: string }>, transferUnitOptions: [] as Array<{ label: string; value: string }>,
isMounted: false,
pendingTransferUnitChange: false,
transferUnitChangeOldQty: 0,
}; };
}, },
watch: { watch: {
@@ -346,6 +350,66 @@ export default defineComponent({
}, },
immediate: true, immediate: true,
}, },
'row.quantity': {
async handler(newQuantity, oldQuantity) {
if (
this.hasSerialNumber &&
newQuantity &&
newQuantity > 0 &&
this.isMounted &&
newQuantity !== oldQuantity
) {
await this.fetchSerialNumbers(false, true);
}
},
immediate: false,
},
'row.transferQuantity': {
async handler(newTransferQuantity, oldTransferQuantity) {
if (
this.pendingTransferUnitChange &&
newTransferQuantity !== this.transferUnitChangeOldQty
) {
this.pendingTransferUnitChange = false;
this.transferUnitChangeOldQty = 0;
await this.fetchSerialNumbers(true, false);
return;
}
if (
this.isUOMConversionEnabled &&
this.hasSerialNumber &&
newTransferQuantity &&
newTransferQuantity > 0 &&
this.isMounted &&
newTransferQuantity !== oldTransferQuantity &&
!this.pendingTransferUnitChange
) {
await this.fetchSerialNumbers(false, false);
}
},
immediate: false,
},
'row.transferUnit': {
async handler(newTransferUnit, oldTransferUnit) {
if (
this.isUOMConversionEnabled &&
this.hasSerialNumber &&
newTransferUnit &&
oldTransferUnit &&
newTransferUnit !== oldTransferUnit &&
this.isMounted
) {
delete this.itemSerialNumbers[this.row.item as string];
await this.row.set('serialNumber', '');
this.pendingTransferUnitChange = true;
this.transferUnitChangeOldQty = this.row.transferQuantity ?? 0;
}
},
immediate: false,
},
}, },
computed: { computed: {
isUOMConversionEnabled(): boolean { isUOMConversionEnabled(): boolean {
@@ -391,6 +455,14 @@ export default defineComponent({
this.profileRateSetting = !!this.fyo.singles.POSSettings?.canChangeRate; this.profileRateSetting = !!this.fyo.singles.POSSettings?.canChangeRate;
this.itemVisibility = await getItemVisibility(this.fyo); this.itemVisibility = await getItemVisibility(this.fyo);
} }
await this.$nextTick();
this.isMounted = true;
if (this.hasSerialNumber) {
await this.fetchSerialNumbers();
}
}, },
methods: { methods: {
@@ -483,6 +555,8 @@ export default defineComponent({
if (!serialNumber) { if (!serialNumber) {
return; return;
} }
this.row.set('serialNumber', serialNumber);
this.itemSerialNumbers[this.row.item as string] = serialNumber; this.itemSerialNumbers[this.row.item as string] = serialNumber;
validateSerialNumberCount( validateSerialNumberCount(
@@ -491,6 +565,52 @@ export default defineComponent({
this.row.item! this.row.item!
); );
}, },
async fetchSerialNumbers(forceRefetch = false, useDirectQuantity = false) {
if (!this.hasSerialNumber) {
return;
}
let quantity = 0;
if (useDirectQuantity) {
quantity = Math.abs(this.row.quantity ?? 0);
} else if (this.isUOMConversionEnabled && this.row.transferQuantity) {
quantity = Math.abs(this.row.transferQuantity);
} else if (this.row.quantity) {
quantity = Math.abs(this.row.quantity);
}
if (quantity <= 0) {
return;
}
const existingSerialNumbers =
this.itemSerialNumbers[this.row.item as string];
if (existingSerialNumbers && !forceRefetch) {
const existingCount = existingSerialNumbers
.split('\n')
.filter((s) => s.trim()).length;
if (existingCount === quantity) {
return;
} else {
}
}
try {
const serialNumbers = await getExistingActiveSerialNumbersForItem(
this.fyo,
this.row.item as string,
quantity
);
if (serialNumbers) {
await this.row.set('serialNumber', serialNumbers);
this.itemSerialNumbers[this.row.item as string] = serialNumbers;
} else {
}
} catch (error) {}
},
isRateReadOnly() { isRateReadOnly() {
const canChangeRate = this.profileRateSetting; const canChangeRate = this.profileRateSetting;
return this.row.isFreeItem || !canChangeRate; return this.row.isFreeItem || !canChangeRate;
@@ -514,6 +634,7 @@ export default defineComponent({
!hasManualDiscount && this.row.itemDiscountPercent !== 0; !hasManualDiscount && this.row.itemDiscountPercent !== 0;
const manualDiscountAmount = this.row.itemDiscountAmount; const manualDiscountAmount = this.row.itemDiscountAmount;
const manualDiscountPercent = this.row.itemDiscountPercent; const manualDiscountPercent = this.row.itemDiscountPercent;
if (!this.row.isReturn && quantity <= 0) { if (!this.row.isReturn && quantity <= 0) {
showToast({ showToast({
type: 'error', type: 'error',
+105 -25
View File
@@ -173,6 +173,7 @@ import {
ItemSerialNumbers, ItemSerialNumbers,
} from 'src/components/POS/types'; } from 'src/components/POS/types';
import { ValidationError } from 'fyo/utils/errors'; import { ValidationError } from 'fyo/utils/errors';
import { getExistingActiveSerialNumbersForItem } from 'models/inventory/helpers';
const COMPONENT_NAME = 'POS'; const COMPONENT_NAME = 'POS';
@@ -852,26 +853,27 @@ export default defineComponent({
return; return;
} }
const itemName = item.name;
if (item.hasBatch) { if (item.hasBatch) {
this.selectedItemForBatch = item.name; this.selectedItemForBatch = itemName;
this.pendingBatchItem = { item, quantity: quantity ?? 1 }; this.pendingBatchItem = { item, quantity: quantity ?? 1 };
this.toggleModal('BatchSelection', true); this.toggleModal('BatchSelection', true);
return; return;
} }
const isInventoryItem = await this.fyo.getValue( const isInventoryItem = await this.fyo.getValue(
ModelNameEnum.Item, ModelNameEnum.Item,
item.name, itemName,
'trackItem' 'trackItem'
); );
if (isInventoryItem) { if (isInventoryItem) {
const availableQty = this.itemQtyMap[item.name]?.availableQty ?? 0; const availableQty = this.itemQtyMap[itemName]?.availableQty ?? 0;
if (availableQty <= 0) { if (availableQty <= 0) {
throw new ValidationError( throw new ValidationError(
t`Item is out of stock (quantity is zero)` t`Item ${itemName} is out of stock (quantity is zero)`
); );
} }
} }
@@ -879,7 +881,7 @@ export default defineComponent({
const existingItems = const existingItems =
this.sinvDoc.items?.filter( this.sinvDoc.items?.filter(
(invoiceItem) => (invoiceItem) =>
invoiceItem.item === item.name && !invoiceItem.isFreeItem invoiceItem.item === itemName && !invoiceItem.isFreeItem
) ?? []; ) ?? [];
await validateQty( await validateQty(
@@ -890,7 +892,7 @@ export default defineComponent({
const itemsHsncode = (await this.fyo.getValue( const itemsHsncode = (await this.fyo.getValue(
'Item', 'Item',
item?.name, itemName,
'hsnCode' 'hsnCode'
)) as number; )) as number;
@@ -898,21 +900,38 @@ export default defineComponent({
const addQty = quantity ?? 1; const addQty = quantity ?? 1;
if (existingItems.length > 0) { if (existingItems.length > 0) {
for (let item of existingItems) { for (let existingItem of existingItems) {
const availableQty = await fyo.db.getStockQuantity( const availableQty = await this.fyo.db.getStockQuantity(
item.item as string, existingItem.item as string,
undefined, undefined,
undefined, undefined,
undefined, undefined,
item.batch existingItem.batch
); );
if ( if (
item.batch != null && existingItem.batch != null &&
availableQty != null && availableQty != null &&
availableQty > (item.quantity as number) availableQty > (existingItem.quantity as number)
) { ) {
const currentQty = item.quantity ?? 0; const currentQty = existingItem.quantity ?? 0;
await item.set('quantity', currentQty + addQty); await existingItem.set('quantity', currentQty + addQty);
if (item.hasSerialNumber) {
const qty = currentQty + addQty;
const serialNumbers =
await getExistingActiveSerialNumbersForItem(
this.fyo,
itemName,
qty
);
if (serialNumbers) {
this.itemSerialNumbers[itemName] = serialNumbers;
await existingItem.set('serialNumber', serialNumbers);
}
}
await this.applyPricingRule(); await this.applyPricingRule();
await this.sinvDoc.runFormulas(); await this.sinvDoc.runFormulas();
return; return;
@@ -922,10 +941,32 @@ export default defineComponent({
await this.sinvDoc.append('items', { await this.sinvDoc.append('items', {
rate: item.rate, rate: item.rate,
item: item.name, item: itemName,
quantity: addQty, quantity: addQty,
hsnCode: itemsHsncode, hsnCode: itemsHsncode,
}); });
if (item.hasSerialNumber) {
const serialNumbers = await getExistingActiveSerialNumbersForItem(
this.fyo,
itemName,
addQty
);
if (serialNumbers) {
this.itemSerialNumbers[itemName] = serialNumbers;
const newItemRows = this.sinvDoc.items?.filter(
(row) => row.item === itemName && !row.isFreeItem
);
if (newItemRows && newItemRows.length > 0) {
const newRow = newItemRows[newItemRows.length - 1];
await newRow.set('serialNumber', serialNumbers);
}
}
}
await this.applyPricingRule(); await this.applyPricingRule();
await this.sinvDoc.runFormulas(); await this.sinvDoc.runFormulas();
return; return;
@@ -939,15 +980,30 @@ export default defineComponent({
const currentQty = existingItems[0].quantity ?? 0; const currentQty = existingItems[0].quantity ?? 0;
const addQty = quantity ?? 1; const addQty = quantity ?? 1;
if (isInventoryItem) { if (isInventoryItem) {
const availableQty = this.itemQtyMap[item.name]?.availableQty ?? 0; const availableQty = this.itemQtyMap[itemName]?.availableQty ?? 0;
if (currentQty + addQty > availableQty) { if (currentQty + addQty > availableQty) {
throw new ValidationError( throw new ValidationError(
'Cannot add more than the available quantity' `Cannot add more than the available quantity for ${itemName}`
); );
} }
} }
await existingItems[0].set('quantity', currentQty + addQty); await existingItems[0].set('quantity', currentQty + addQty);
if (item.hasSerialNumber) {
const qty = currentQty + addQty;
const serialNumbers = await getExistingActiveSerialNumbersForItem(
this.fyo,
itemName,
qty
);
if (serialNumbers) {
this.itemSerialNumbers[itemName] = serialNumbers;
await existingItems[0].set('serialNumber', serialNumbers);
}
}
await this.applyPricingRule(); await this.applyPricingRule();
await this.sinvDoc.runFormulas(); await this.sinvDoc.runFormulas();
if (isInventoryItem) { if (isInventoryItem) {
@@ -962,24 +1018,48 @@ export default defineComponent({
await this.sinvDoc.append('items', { await this.sinvDoc.append('items', {
rate: item.rate, rate: item.rate,
item: item.name, item: itemName,
quantity: quantity ? quantity : 1, quantity: quantity ? quantity : 1,
hsnCode: itemsHsncode, hsnCode: itemsHsncode,
}); });
if (this.sinvDoc.priceList) { if (this.sinvDoc.priceList) {
let itemData = this.sinvDoc.items?.filter( const itemData = this.sinvDoc.items?.filter(
(val) => val.item == item.name (val) => val.item == itemName
) as SalesInvoiceItem[]; ) as SalesInvoiceItem[];
itemData[0].rate = await getItemRateFromPriceList( if (itemData.length > 0) {
itemData[0], itemData[0].rate = await getItemRateFromPriceList(
this.sinvDoc.priceList itemData[0],
this.sinvDoc.priceList
);
}
}
if (item.hasSerialNumber) {
const qty = quantity ?? 1;
const serialNumbers = await getExistingActiveSerialNumbersForItem(
this.fyo,
itemName,
qty
); );
if (serialNumbers) {
this.itemSerialNumbers[itemName] = serialNumbers;
const newItemRows = this.sinvDoc.items?.filter(
(row) => row.item === itemName && !row.isFreeItem
);
if (newItemRows && newItemRows.length > 0) {
const newRow = newItemRows[newItemRows.length - 1];
await newRow.set('serialNumber', serialNumbers);
}
}
} }
await this.applyPricingRule(); await this.applyPricingRule();
await this.sinvDoc.runFormulas(); await this.sinvDoc.runFormulas();
} catch (error) { } catch (error) {
return showToast({ return showToast({