mirror of
https://github.com/frappe/books.git
synced 2026-08-24 10:04:45 -05:00
Merge pull request #1419 from Gadha2311/serial-autogenerate
feat: add auto-generation for serial numbers
This commit is contained in:
@@ -29,6 +29,7 @@ export class Item extends Doc {
|
||||
itemGroup?: string;
|
||||
hsnCode?: number;
|
||||
hasSerialNumber?: boolean;
|
||||
serialNumberSeries?: string;
|
||||
uomConversions: UOMConversionItem[] = [];
|
||||
|
||||
formulas: FormulaMap = {
|
||||
@@ -92,6 +93,38 @@ export class Item extends Doc {
|
||||
});
|
||||
|
||||
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 = {
|
||||
@@ -125,6 +158,21 @@ export class Item extends Doc {
|
||||
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[] {
|
||||
@@ -177,6 +225,7 @@ export class Item extends Doc {
|
||||
!(
|
||||
this.fyo.singles.InventorySettings?.enableSerialNumber && this.trackItem
|
||||
),
|
||||
serialNumberSeries: () => !this.hasSerialNumber,
|
||||
uomConversions: () =>
|
||||
!this.fyo.singles.InventorySettings?.enableUomConversions,
|
||||
itemGroup: () => !this.fyo.singles.AccountingSettings?.enableitemGroup,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
getStockLedgerEntries,
|
||||
} from 'reports/inventory/helpers';
|
||||
import { LoyaltyPointEntry } from './baseModels/LoyaltyPointEntry/LoyaltyPointEntry';
|
||||
import { generateSerialNumbersForItem } from './inventory/helpers';
|
||||
|
||||
export function getQuoteActions(
|
||||
fyo: Fyo,
|
||||
@@ -759,6 +760,16 @@ export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from 'fyo';
|
||||
import { DocValue } from 'fyo/core/types';
|
||||
import {
|
||||
ChangeArg,
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
HiddenMap,
|
||||
@@ -12,6 +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 { StockMovement } from './StockMovement';
|
||||
import { TransferItem } from './TransferItem';
|
||||
import { MovementTypeEnum } from './types';
|
||||
@@ -323,4 +325,46 @@ export class StockMovementItem extends TransferItem {
|
||||
static createFilters: FiltersMap = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
updateSerialNumbers,
|
||||
validateBatch,
|
||||
validateSerialNumber,
|
||||
generateSerialNumbersForItem,
|
||||
} from './helpers';
|
||||
import { ReturnDocItem } from './types';
|
||||
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('date', stDoc.date);
|
||||
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> {
|
||||
|
||||
@@ -14,6 +14,10 @@ import { StockTransfer } from './StockTransfer';
|
||||
import { TransferItem } from './TransferItem';
|
||||
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
|
||||
import { PurchaseInvoice } from 'models/baseModels/PurchaseInvoice/PurchaseInvoice';
|
||||
import {
|
||||
generateSerialNumbersForItem,
|
||||
getExistingActiveSerialNumbersForItem,
|
||||
} from './helpers';
|
||||
|
||||
export class StockTransferItem extends TransferItem {
|
||||
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 = {
|
||||
|
||||
+192
-1
@@ -6,11 +6,12 @@ import { ModelNameEnum } from 'models/types';
|
||||
import { SerialNumber } from './SerialNumber';
|
||||
import type { StockMovement } from './StockMovement';
|
||||
import type { StockMovementItem } from './StockMovementItem';
|
||||
import type { StockTransfer } from './StockTransfer';
|
||||
import { StockTransfer } from './StockTransfer';
|
||||
import type { StockTransferItem } from './StockTransferItem';
|
||||
import { Transfer } from './Transfer';
|
||||
import { TransferItem } from './TransferItem';
|
||||
import type { SerialNumberStatus } from './types';
|
||||
import SerialNumberSeries from 'fyo/models/SerialNumberSeries';
|
||||
|
||||
export async function validateBatch(
|
||||
doc: StockMovement | StockTransfer | Invoice
|
||||
@@ -340,3 +341,193 @@ function getSerialNumberStatusForStockMovement(
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum ModelNameEnum {
|
||||
JournalEntryAccount = 'JournalEntryAccount',
|
||||
Misc = 'Misc',
|
||||
NumberSeries = 'NumberSeries',
|
||||
SerialNumberSeries = 'SerialNumberSeries',
|
||||
Lead = 'Lead',
|
||||
Party = 'Party',
|
||||
LoyaltyProgram = 'LoyaltyProgram',
|
||||
|
||||
Reference in New Issue
Block a user