From a9142287ccd8dba2f9cd58f16d0b6fe5fd9fdb05 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 21 Jan 2026 23:35:40 +0000
Subject: [PATCH 01/21] chore(deps): bump lodash from 4.17.21 to 4.17.23
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)
---
updated-dependencies:
- dependency-name: lodash
dependency-version: 4.17.23
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
yarn.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index 8e077552..0e887ccb 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"electron-store": "^8.0.1",
"feather-icons": "^4.28.0",
"knex": "^2.4.0",
- "lodash": "^4.17.21",
+ "lodash": "^4.17.23",
"luxon": "^2.5.2",
"node-fetch": "2",
"pesa": "^1.1.12",
diff --git a/yarn.lock b/yarn.lock
index a3102a60..b12e286d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3835,10 +3835,10 @@ lodash.uniqby@4.5.0:
lodash._baseiteratee "~4.7.0"
lodash._baseuniq "~4.6.0"
-lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.21:
- version "4.17.21"
- resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
- integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.23:
+ version "4.17.23"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
+ integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
log-symbols@^4.1.0:
version "4.1.0"
From 0aab1167a444940bfc406d12fbbd822a4741aad7 Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Mon, 12 Jan 2026 12:18:02 +0530
Subject: [PATCH 02/21] fix: correct round-off account source allocation
---
models/baseModels/Payment/Payment.ts | 14 ++++++----
.../baseModels/SalesInvoice/SalesInvoice.ts | 28 +++++++++++++++----
2 files changed, 31 insertions(+), 11 deletions(-)
diff --git a/models/baseModels/Payment/Payment.ts b/models/baseModels/Payment/Payment.ts
index 6e7992fb..b790c941 100644
--- a/models/baseModels/Payment/Payment.ts
+++ b/models/baseModels/Payment/Payment.ts
@@ -461,7 +461,10 @@ export class Payment extends Transactional {
);
const previousOutstandingAmount = referenceDoc.outstandingAmount as Money;
- const outstandingAmount = previousOutstandingAmount.sub(row.amount!);
+ const isReturnInvoice = (referenceDoc as Invoice).isReturn;
+ const outstandingAmount = isReturnInvoice
+ ? previousOutstandingAmount.add(row.amount!)
+ : previousOutstandingAmount.sub(row.amount!);
await referenceDoc.setAndSync({ outstandingAmount });
}
}
@@ -505,11 +508,10 @@ export class Payment extends Transactional {
ref.referenceType!,
ref.referenceName
);
-
- const outstandingAmount = (refDoc.outstandingAmount as Money).add(
- ref.amount!
- );
-
+ const isReturnInvoice = (refDoc as Invoice).isReturn;
+ const outstandingAmount = isReturnInvoice
+ ? (refDoc.outstandingAmount as Money).sub(ref.amount!)
+ : (refDoc.outstandingAmount as Money).add(ref.amount!);
await refDoc.setAndSync({ outstandingAmount });
}
}
diff --git a/models/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts
index a2ae5e7e..090b6a04 100644
--- a/models/baseModels/SalesInvoice/SalesInvoice.ts
+++ b/models/baseModels/SalesInvoice/SalesInvoice.ts
@@ -43,12 +43,12 @@ export class SalesInvoice extends Invoice {
this.loyaltyProgram
)) as LoyaltyProgram;
- let totalAmount;
+ let loyaltyAmount;
if (this.isReturn) {
- totalAmount = this.fyo.pesa(await getReturnLoyaltyPoints(this));
+ loyaltyAmount = this.fyo.pesa(await getReturnLoyaltyPoints(this));
} else {
- totalAmount = await getAddedLPWithGrandTotal(
+ loyaltyAmount = await getAddedLPWithGrandTotal(
this.fyo,
this.loyaltyProgram as string,
this.loyaltyPoints as number
@@ -57,10 +57,28 @@ export class SalesInvoice extends Invoice {
await posting.debit(
loyaltyProgramDoc.expenseAccount as string,
- totalAmount
+ loyaltyAmount
);
- await posting.credit(this.account!, totalAmount);
+ await posting.credit(this.account!, loyaltyAmount);
+
+ const { debit, credit } = posting._getTotalDebitAndCredit();
+ const difference = debit.sub(credit);
+ const absoluteValue = difference.abs();
+
+ if (!absoluteValue.eq(0)) {
+ if (difference.gt(0)) {
+ await posting.credit(
+ loyaltyProgramDoc.expenseAccount as string,
+ absoluteValue
+ );
+ } else {
+ await posting.debit(
+ loyaltyProgramDoc.expenseAccount as string,
+ absoluteValue
+ );
+ }
+ }
}
if (this.taxes) {
From afab27070b5389744ddd3105111a5522a6f38c33 Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Fri, 30 Jan 2026 15:50:32 +0530
Subject: [PATCH 03/21] fix: refactor the code
---
.../baseModels/SalesInvoice/SalesInvoice.ts | 20 -------------------
1 file changed, 20 deletions(-)
diff --git a/models/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts
index 090b6a04..f4d94a1d 100644
--- a/models/baseModels/SalesInvoice/SalesInvoice.ts
+++ b/models/baseModels/SalesInvoice/SalesInvoice.ts
@@ -59,26 +59,6 @@ export class SalesInvoice extends Invoice {
loyaltyProgramDoc.expenseAccount as string,
loyaltyAmount
);
-
- await posting.credit(this.account!, loyaltyAmount);
-
- const { debit, credit } = posting._getTotalDebitAndCredit();
- const difference = debit.sub(credit);
- const absoluteValue = difference.abs();
-
- if (!absoluteValue.eq(0)) {
- if (difference.gt(0)) {
- await posting.credit(
- loyaltyProgramDoc.expenseAccount as string,
- absoluteValue
- );
- } else {
- await posting.debit(
- loyaltyProgramDoc.expenseAccount as string,
- absoluteValue
- );
- }
- }
}
if (this.taxes) {
From 8b64671537c591dd0638d79670a5730d3bfe619b Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Fri, 19 Dec 2025 18:28:11 +0530
Subject: [PATCH 04/21] fix: add loyalty point expiry by checking date
---
jobs/checkLoyaltyProgramExpiry.ts | 96 +++++++++++++
main/initSheduler.ts | 9 ++
models/baseModels/Invoice/Invoice.ts | 103 ++++++++++++-
.../LoyaltyProgram/LoyaltyProgram.ts | 31 +++-
.../baseModels/SalesInvoice/SalesInvoice.ts | 9 ++
.../tests/testLoyaltyProgram.spec.ts | 21 ++-
models/helpers.ts | 135 ++++++++++++++++--
schemas/app/LoyaltyProgram.json | 22 ++-
src/components/StatusPill.vue | 11 ++
src/pages/POS/LoyaltyProgramModal.vue | 24 ++--
10 files changed, 430 insertions(+), 31 deletions(-)
create mode 100644 jobs/checkLoyaltyProgramExpiry.ts
diff --git a/jobs/checkLoyaltyProgramExpiry.ts b/jobs/checkLoyaltyProgramExpiry.ts
new file mode 100644
index 00000000..b1ba026f
--- /dev/null
+++ b/jobs/checkLoyaltyProgramExpiry.ts
@@ -0,0 +1,96 @@
+import { parentPort } from 'worker_threads';
+import { DatabaseManager } from '../backend/database/manager';
+import { ModelNameEnum } from '../models/types';
+
+if (parentPort) {
+ parentPort.postMessage({ type: 'check-loyalty-program-expiry' });
+}
+
+export async function checkLoyaltyProgramExpiry() {
+ const dm = new DatabaseManager();
+
+ try {
+ const currentDate = new Date();
+
+ const loyaltyPrograms = (await dm.db?.getAll(ModelNameEnum.LoyaltyProgram, {
+ fields: ['name', 'toDate', 'status', 'isEnabled'],
+ filters: {
+ status: ['!=', 'Expired'],
+ isEnabled: true,
+ },
+ })) as Array<{
+ name: string;
+ toDate: string;
+ status: string;
+ isEnabled: boolean;
+ }>;
+
+ let expiredCount = 0;
+ let processedCount = 0;
+
+ if (loyaltyPrograms) {
+ for (const program of loyaltyPrograms) {
+ processedCount++;
+
+ if (program.toDate && new Date(program.toDate) <= currentDate) {
+ await dm.db?.knex!(ModelNameEnum.LoyaltyProgram)
+ .where({ name: program.name })
+ .update({
+ status: 'Expired',
+ isEnabled: false,
+ });
+
+ expiredCount++;
+ }
+ }
+ }
+
+ const result = {
+ timestamp: currentDate.toISOString(),
+ processedPrograms: processedCount,
+ expiredPrograms: expiredCount,
+ message: `Loyalty program expiry check completed. ${expiredCount} programs expired out of ${processedCount} processed.`,
+ };
+
+ if (parentPort) {
+ parentPort.postMessage({
+ type: 'loyalty-program-expiry-complete',
+ data: result,
+ });
+ }
+
+ return result;
+ } catch (error) {
+ const errorResult = {
+ timestamp: new Date().toISOString(),
+ error: error instanceof Error ? error.message : 'Unknown error',
+ message: 'Loyalty program expiry check failed',
+ };
+
+ if (parentPort) {
+ parentPort.postMessage({
+ type: 'loyalty-program-expiry-error',
+ data: errorResult,
+ });
+ }
+
+ throw error;
+ } finally {
+ await dm.call('close');
+ }
+}
+
+checkLoyaltyProgramExpiry().catch((error) => {
+ const errorResult = {
+ timestamp: new Date().toISOString(),
+ error: error instanceof Error ? error.message : 'Unknown error',
+ message: 'Loyalty program expiry check failed',
+ };
+
+ if (parentPort) {
+ parentPort.postMessage({
+ type: 'loyalty-program-expiry-error',
+ data: errorResult,
+ });
+ }
+});
diff --git a/main/initSheduler.ts b/main/initSheduler.ts
index 9a4a8551..ea80c2a0 100644
--- a/main/initSheduler.ts
+++ b/main/initSheduler.ts
@@ -24,6 +24,15 @@ export async function initScheduler(interval: string) {
},
},
},
+ {
+ name: 'checkLoyaltyProgramExpiry',
+ interval: '0 1 * * *',
+ worker: {
+ workerData: {
+ useTsNode: true,
+ },
+ },
+ },
],
worker: {
argv: ['--require', 'ts-node/register'],
diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts
index 2fa9b2d5..1fcf0e1d 100644
--- a/models/baseModels/Invoice/Invoice.ts
+++ b/models/baseModels/Invoice/Invoice.ts
@@ -28,6 +28,8 @@ import {
getReturnLoyaltyPoints,
getItemQtyMap,
getItemVisibility,
+ validateLoyaltyProgram,
+ getLoyaltyProgramTier,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
@@ -212,13 +214,49 @@ export abstract class Invoice extends Transactional {
this.party
)) as Party;
- if ((this.loyaltyPoints as number) > (partyDoc?.loyaltyPoints || 0)) {
+ if (this.redeemLoyaltyPoints && (this.loyaltyPoints as number) > 0) {
+ const currentPoints = partyDoc?.loyaltyPoints || 0;
+
+ let pointsToBeEarned = 0;
+ if (!this.isReturn && this.loyaltyProgram) {
+ const loyaltyProgramDoc = (await this.fyo.doc.getDoc(
+ ModelNameEnum.LoyaltyProgram,
+ this.loyaltyProgram
+ )) as LoyaltyProgram;
+
+ const tier = getLoyaltyProgramTier(
+ loyaltyProgramDoc,
+ this?.grandTotal as Money
+ );
+
+ if (tier) {
+ const collectionFactor = tier.collectionFactor as number;
+ pointsToBeEarned =
+ Math.round(this?.grandTotal?.float || 0) * collectionFactor;
+ }
+ }
+
+ const totalAvailablePoints = currentPoints + pointsToBeEarned;
+ if ((this.loyaltyPoints as number) > totalAvailablePoints) {
+ throw new ValidationError(
+ t`${
+ this.party as string
+ } only has ${currentPoints} points (${pointsToBeEarned} will be earned from this transaction)`
+ );
+ }
+ } else if (
+ (this.loyaltyPoints as number) > (partyDoc?.loyaltyPoints || 0)
+ ) {
throw new ValidationError(
t`${this.party as string} only has ${
partyDoc.loyaltyPoints as number
} points`
);
}
+
+ if (this.loyaltyProgram) {
+ await validateLoyaltyProgram(this, this.loyaltyProgram);
+ }
}
async afterSubmit() {
@@ -275,6 +313,10 @@ export abstract class Invoice extends Transactional {
if (this.schemaName === ModelNameEnum.SalesInvoice) {
this.updateUsedCountOfCoupons();
}
+
+ if (this.loyaltyProgram) {
+ await this.updateUsedCountOfLoyaltyProgram();
+ }
}
async afterCancel() {
@@ -284,6 +326,10 @@ export abstract class Invoice extends Transactional {
await this._updateIsItemsReturned();
await this._removeLoyaltyPointEntry();
this.reduceUsedCountOfCoupons();
+
+ if (this.loyaltyProgram) {
+ await this.reduceUsedCountOfLoyaltyProgram();
+ }
}
async _removeLoyaltyPointEntry() {
@@ -838,6 +884,36 @@ export abstract class Invoice extends Transactional {
});
}
+ async updateUsedCountOfLoyaltyProgram() {
+ if (!this.loyaltyProgram) {
+ return;
+ }
+
+ const loyaltyProgramDoc = await this.fyo.doc.getDoc(
+ ModelNameEnum.LoyaltyProgram,
+ this.loyaltyProgram
+ );
+
+ await loyaltyProgramDoc.setAndSync({
+ used: (loyaltyProgramDoc.used as number) + 1,
+ });
+ }
+
+ async reduceUsedCountOfLoyaltyProgram() {
+ if (!this.loyaltyProgram) {
+ return;
+ }
+
+ const loyaltyProgramDoc = await this.fyo.doc.getDoc(
+ ModelNameEnum.LoyaltyProgram,
+ this.loyaltyProgram
+ );
+
+ await loyaltyProgramDoc.setAndSync({
+ used: (loyaltyProgramDoc.used as number) - 1,
+ });
+ }
+
async updateIsItemsFullyReturned(doc?: Invoice) {
if (!doc?.returnAgainst || doc.schemaName !== ModelNameEnum.SalesInvoice) {
return;
@@ -898,11 +974,32 @@ export abstract class Invoice extends Transactional {
this.loyaltyProgram
)) as LoyaltyProgram;
- const expiryDate = this.date as Date;
+ // Check if loyalty program is enabled
+ if (!loyaltyProgramDoc.isEnabled) {
+ return;
+ }
+
+ const invoiceDate = this.date as Date;
const fromDate = loyaltyProgramDoc.fromDate as Date;
const toDate = loyaltyProgramDoc.toDate as Date;
- if (fromDate <= expiryDate && toDate >= expiryDate) {
+ const normalizedInvoiceDate = new Date(invoiceDate);
+ normalizedInvoiceDate.setHours(0, 0, 0, 0);
+
+ const normalizedFromDate = new Date(fromDate);
+ normalizedFromDate.setHours(0, 0, 0, 0);
+
+ const normalizedToDate = new Date(toDate);
+ normalizedToDate.setHours(0, 0, 0, 0);
+
+ if (normalizedToDate.getTime() < normalizedInvoiceDate.getTime()) {
+ return;
+ }
+
+ if (
+ normalizedInvoiceDate.getTime() >= normalizedFromDate.getTime() &&
+ normalizedInvoiceDate.getTime() <= normalizedToDate.getTime()
+ ) {
const party = (await this.loadAndGetLink('party')) as Party;
await createLoyaltyPointEntry(this);
diff --git a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts
index 89f73399..45d5b3f7 100644
--- a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts
+++ b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts
@@ -1,11 +1,38 @@
+import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
-import { FiltersMap, ListViewSettings } from 'fyo/model/types';
+import { FiltersMap, ListViewSettings, ValidationMap } from 'fyo/model/types';
+import { ValidationError } from 'fyo/utils/errors';
import { CollectionRulesItems } from '../CollectionRulesItems/CollectionRulesItems';
import { AccountRootTypeEnum } from '../Account/types';
+import { getLoyaltyProgramStatusColumn } from '../../helpers';
export class LoyaltyProgram extends Doc {
collectionRules?: CollectionRulesItems[];
expiryDuration?: number;
+ maximumUse?: number;
+ used?: number;
+
+ validations: ValidationMap = {
+ used: (value: DocValue) => {
+ const used = value as number;
+ const maximumUse = this.maximumUse as number;
+
+ if (used < 0) {
+ throw new ValidationError('Used count cannot be negative');
+ }
+
+ if (maximumUse > 0 && used > maximumUse) {
+ throw new ValidationError('Used count cannot exceed maximum use limit');
+ }
+ },
+ maximumUse: (value: DocValue) => {
+ const maximumUse = value as number;
+
+ if (maximumUse < 0) {
+ throw new ValidationError('Maximum use cannot be negative');
+ }
+ },
+ };
static filters: FiltersMap = {
expenseAccount: () => ({
@@ -16,7 +43,7 @@ export class LoyaltyProgram extends Doc {
static getListViewSettings(): ListViewSettings {
return {
- columns: ['name', 'fromDate', 'toDate', 'expiryDuration'],
+ columns: ['name', getLoyaltyProgramStatusColumn(), 'fromDate', 'toDate'],
};
}
}
diff --git a/models/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts
index a2ae5e7e..7fc42efe 100644
--- a/models/baseModels/SalesInvoice/SalesInvoice.ts
+++ b/models/baseModels/SalesInvoice/SalesInvoice.ts
@@ -115,6 +115,15 @@ export class SalesInvoice extends Invoice {
ModelNameEnum.LoyaltyProgram,
this.loyaltyProgram
)) as LoyaltyProgram;
+ const toDate = loyaltyProgramDoc?.toDate as Date;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (toDate && new Date(toDate).getTime() < today.getTime()) {
+ throw new ValidationError(
+ t`Loyalty program has expired and cannot be applied`
+ );
+ }
if (!this?.grandTotal) {
return;
diff --git a/models/baseModels/tests/testLoyaltyProgram.spec.ts b/models/baseModels/tests/testLoyaltyProgram.spec.ts
index 2be654b8..8a1b9657 100644
--- a/models/baseModels/tests/testLoyaltyProgram.spec.ts
+++ b/models/baseModels/tests/testLoyaltyProgram.spec.ts
@@ -27,10 +27,17 @@ const partyData = {
email: 'john@whoe.com',
};
+const today = new Date();
+const fromDate = new Date(today);
+fromDate.setDate(today.getDate() - 10);
+
+const toDate = new Date(today);
+toDate.setDate(today.getDate() + 20);
+
const loyaltyProgramData = {
name: 'program',
- fromDate: new Date('12/10/2024'),
- toDate: new Date('12/30/2024'),
+ fromDate: fromDate,
+ toDate: toDate,
email: 'sample@gmail.com',
mobile: '1234567890',
expenseAccount: accountData.name,
@@ -118,11 +125,11 @@ async function loyaltyPointEntryDoc(sinvName: string) {
}
}
-async function createSalesInvoice() {
+async function createSalesInvoice(invoiceDate?: Date) {
const sinvDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
account: 'Debtors',
party: partyData.name,
- date: new Date('12/11/2024'),
+ date: invoiceDate || new Date(),
items: [
{
item: itemData.name,
@@ -189,10 +196,10 @@ test('create Sales Invoice and verify loyalty points are created correctly', asy
});
test('create SINV with future date and verify loyalty points are not created', async (t) => {
- const futureDate = new Date(new Date().setDate(new Date().getDate() + 20));
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 30);
- const sinvDoc = await createSalesInvoice();
- sinvDoc.date = futureDate;
+ const sinvDoc = await createSalesInvoice(futureDate);
await sinvDoc.sync();
await sinvDoc.submit();
diff --git a/models/helpers.ts b/models/helpers.ts
index 07e13435..3f1e73b8 100644
--- a/models/helpers.ts
+++ b/models/helpers.ts
@@ -738,6 +738,64 @@ export function getDocStatusListColumn(): ColumnConfig {
};
}
+export function getLoyaltyProgramStatusColumn(): ColumnConfig {
+ return {
+ label: t`Status`,
+ fieldname: 'status',
+ fieldtype: 'Select',
+ render(doc) {
+ const status = getLoyaltyProgramStatus(doc);
+ const color = loyaltyProgramStatusColor[status] ?? 'gray';
+ const label = getLoyaltyProgramStatusText(status);
+
+ return {
+ template: `${label}`,
+ metadata: {
+ status,
+ color,
+ label,
+ },
+ };
+ },
+ };
+}
+
+export function getLoyaltyProgramStatus(doc?: RenderData | Doc): string {
+ if (!doc) {
+ return '';
+ }
+
+ const currentDate = new Date();
+ currentDate.setHours(0, 0, 0, 0);
+
+ const toDate = doc.toDate as Date;
+
+ if (toDate && toDate <= currentDate) {
+ return 'Expired';
+ }
+
+ return 'Active';
+}
+
+export const loyaltyProgramStatusColor: Record = {
+ Active: 'green',
+ Disabled: 'gray',
+ Expired: 'red',
+};
+
+export function getLoyaltyProgramStatusText(status: string): string {
+ switch (status) {
+ case 'Active':
+ return t`Active`;
+ case 'Disabled':
+ return t`Disabled`;
+ case 'Expired':
+ return t`Expired`;
+ default:
+ return '';
+ }
+}
+
type ModelsWithItems = Invoice | StockTransfer | StockMovement;
export async function addItem(name: string, doc: M) {
if (!doc.canEdit) {
@@ -883,6 +941,15 @@ export async function createLoyaltyPointEntry(doc: Invoice) {
if (!loyaltyProgramDoc.isEnabled) {
return;
}
+
+ const toDate = loyaltyProgramDoc.toDate as Date;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (toDate && new Date(toDate).getTime() < today.getTime()) {
+ return;
+ }
+
const expiryDate = new Date(Date.now());
expiryDate.setDate(
@@ -951,18 +1018,22 @@ export function getLoyaltyProgramTier(
let loyaltyProgramTier: CollectionRulesItems | undefined;
for (const row of loyaltyProgramData.collectionRules) {
- if (isPesa(row.minimumTotalSpent)) {
- const minimumSpent = row.minimumTotalSpent;
+ if (row.minimumTotalSpent !== undefined && row.minimumTotalSpent !== null) {
+ let minimumSpent: Money;
- if (!minimumSpent.lte(grandTotal)) {
- continue;
+ if (isPesa(row.minimumTotalSpent)) {
+ minimumSpent = row.minimumTotalSpent;
+ } else {
+ minimumSpent = new Money(row.minimumTotalSpent as number);
}
- if (
- !loyaltyProgramTier ||
- minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money)
- ) {
- loyaltyProgramTier = row;
+ if (minimumSpent.lte(grandTotal)) {
+ if (
+ !loyaltyProgramTier ||
+ minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money)
+ ) {
+ loyaltyProgramTier = row;
+ }
}
}
}
@@ -1537,6 +1608,52 @@ export async function validateCouponCode(
}
}
+export async function validateLoyaltyProgram(
+ doc: Invoice,
+ loyaltyProgramName: string
+) {
+ const loyaltyProgram = await doc.fyo.db.getAll(ModelNameEnum.LoyaltyProgram, {
+ fields: ['fromDate', 'toDate', 'maximumUse', 'used', 'isEnabled'],
+ filters: { name: loyaltyProgramName },
+ });
+
+ if (!loyaltyProgram[0]?.isEnabled) {
+ throw new ValidationError(
+ 'Loyalty program cannot be applied as it is not enabled'
+ );
+ }
+
+ if (
+ (loyaltyProgram[0]?.maximumUse as number) > 0 &&
+ (loyaltyProgram[0]?.used as number) >=
+ (loyaltyProgram[0]?.maximumUse as number)
+ ) {
+ throw new ValidationError(
+ 'Loyalty program has reached maximum usage limit'
+ );
+ }
+
+ if (
+ loyaltyProgram[0].fromDate &&
+ (doc.date as Date) < (loyaltyProgram[0].fromDate as Date)
+ ) {
+ throw new ValidationError('Loyalty program is not yet active');
+ }
+
+ const toDate = loyaltyProgram[0].toDate as Date;
+ if (toDate) {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const normalizedToDate = new Date(toDate);
+ normalizedToDate.setHours(0, 0, 0, 0);
+
+ // Only throw error if toDate is clearly in the past
+ if (normalizedToDate.getTime() < today.getTime()) {
+ throw new ValidationError('Loyalty program has expired');
+ }
+ }
+}
+
export function removeFreeItems(sinvDoc: SalesInvoice) {
if (!sinvDoc || !sinvDoc.items) {
return;
diff --git a/schemas/app/LoyaltyProgram.json b/schemas/app/LoyaltyProgram.json
index 5c5c1c71..4b9df873 100644
--- a/schemas/app/LoyaltyProgram.json
+++ b/schemas/app/LoyaltyProgram.json
@@ -51,7 +51,7 @@
"label": "Expiry Duration",
"fieldtype": "Int",
"default": 1,
- "required": true
+ "hidden": true
},
{
"fieldname": "expenseAccount",
@@ -59,6 +59,23 @@
"fieldtype": "Link",
"target": "Account",
"required": true
+ },
+ {
+ "fieldname": "maximumUse",
+ "label": "Maximum Use",
+ "fieldtype": "Int",
+ "default": 0,
+ "required": true,
+ "section": "Validity and Usage"
+ },
+ {
+ "fieldname": "used",
+ "label": "Used",
+ "fieldtype": "Int",
+ "default": 0,
+ "required": true,
+ "readOnly": true,
+ "section": "Validity and Usage"
}
],
"quickEditFields": [
@@ -67,7 +84,8 @@
"toDate",
"conversionFactor",
"expenseAccount",
- "expiryDuration"
+ "maximumUse",
+ "used"
],
"keywordFields": ["name"]
}
diff --git a/src/components/StatusPill.vue b/src/components/StatusPill.vue
index f2d1743a..b9252a99 100644
--- a/src/components/StatusPill.vue
+++ b/src/components/StatusPill.vue
@@ -8,6 +8,7 @@ import { Doc } from 'fyo/model/doc';
import { isPesa } from 'fyo/utils';
import { Invoice } from 'models/baseModels/Invoice/Invoice';
import { Party } from 'models/baseModels/Party/Party';
+import { LoyaltyProgram } from 'models/baseModels/LoyaltyProgram/LoyaltyProgram';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { getBgTextColorClass } from 'src/utils/colors';
@@ -77,6 +78,7 @@ export default defineComponent({
ReturnIssued: this.t`Return Issued`,
Unpaid: this.t`Unpaid`,
PartlyPaid: this.t`Partly Paid`,
+ Expired: this.t`Expired`,
}[this.status];
},
color(): UIColors {
@@ -99,6 +101,7 @@ const statusColorMap: Record = {
ReturnIssued: 'gray',
Unpaid: 'red',
PartlyPaid: 'yellow',
+ Expired: 'red',
};
function getStatus(doc: Doc) {
@@ -110,6 +113,14 @@ function getStatus(doc: Doc) {
return 'NotSaved';
}
+ if (doc instanceof LoyaltyProgram) {
+ const currentDate = new Date();
+ if (doc.toDate && doc.toDate instanceof Date && doc.toDate <= currentDate) {
+ return 'Expired';
+ }
+ return 'Saved';
+ }
+
if (doc instanceof Party && doc.outstandingAmount?.isZero() !== true) {
return 'Outstanding';
}
diff --git a/src/pages/POS/LoyaltyProgramModal.vue b/src/pages/POS/LoyaltyProgramModal.vue
index a4e921d9..d99f3cbc 100644
--- a/src/pages/POS/LoyaltyProgramModal.vue
+++ b/src/pages/POS/LoyaltyProgramModal.vue
@@ -128,6 +128,22 @@ export default defineComponent({
return;
}
+ const loyaltyProgramDoc = await this.fyo.db.getAll(
+ ModelNameEnum.LoyaltyProgram,
+ {
+ fields: ['conversionFactor', 'toDate'],
+ filters: { name: partyData.loyaltyProgram as string },
+ }
+ );
+
+ const toDate = loyaltyProgramDoc[0]?.toDate as Date;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (toDate && new Date(toDate).getTime() < today.getTime()) {
+ throw new Error(t`Loyalty program has expired and cannot be applied`);
+ }
+
if (this.loyaltyPoints >= newValue) {
this.sinvDoc.loyaltyPoints = newValue;
} else {
@@ -138,14 +154,6 @@ export default defineComponent({
);
}
- const loyaltyProgramDoc = await this.fyo.db.getAll(
- ModelNameEnum.LoyaltyProgram,
- {
- fields: ['conversionFactor'],
- filters: { name: partyData.loyaltyProgram as string },
- }
- );
-
const loyaltyPoint =
newValue * ((loyaltyProgramDoc[0]?.conversionFactor as number) || 0);
From e061179db7a3b3b213cc5b86d39a1fd3105749cb Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Thu, 22 Jan 2026 14:41:25 +0530
Subject: [PATCH 05/21] fix: removed the unused codes
---
jobs/checkLoyaltyProgramExpiry.ts | 48 +------------------------------
1 file changed, 1 insertion(+), 47 deletions(-)
diff --git a/jobs/checkLoyaltyProgramExpiry.ts b/jobs/checkLoyaltyProgramExpiry.ts
index b1ba026f..838aeb44 100644
--- a/jobs/checkLoyaltyProgramExpiry.ts
+++ b/jobs/checkLoyaltyProgramExpiry.ts
@@ -1,11 +1,6 @@
-import { parentPort } from 'worker_threads';
import { DatabaseManager } from '../backend/database/manager';
import { ModelNameEnum } from '../models/types';
-if (parentPort) {
- parentPort.postMessage({ type: 'check-loyalty-program-expiry' });
-}
-
export async function checkLoyaltyProgramExpiry() {
const dm = new DatabaseManager();
@@ -25,13 +20,8 @@ export async function checkLoyaltyProgramExpiry() {
isEnabled: boolean;
}>;
- let expiredCount = 0;
- let processedCount = 0;
-
if (loyaltyPrograms) {
for (const program of loyaltyPrograms) {
- processedCount++;
-
if (program.toDate && new Date(program.toDate) <= currentDate) {
await dm.db?.knex!(ModelNameEnum.LoyaltyProgram)
.where({ name: program.name })
@@ -39,41 +29,16 @@ export async function checkLoyaltyProgramExpiry() {
status: 'Expired',
isEnabled: false,
});
-
- expiredCount++;
}
}
}
const result = {
timestamp: currentDate.toISOString(),
- processedPrograms: processedCount,
- expiredPrograms: expiredCount,
- message: `Loyalty program expiry check completed. ${expiredCount} programs expired out of ${processedCount} processed.`,
};
- if (parentPort) {
- parentPort.postMessage({
- type: 'loyalty-program-expiry-complete',
- data: result,
- });
- }
-
return result;
} catch (error) {
- const errorResult = {
- timestamp: new Date().toISOString(),
- error: error instanceof Error ? error.message : 'Unknown error',
- message: 'Loyalty program expiry check failed',
- };
-
- if (parentPort) {
- parentPort.postMessage({
- type: 'loyalty-program-expiry-error',
- data: errorResult,
- });
- }
-
throw error;
} finally {
await dm.call('close');
@@ -81,16 +46,5 @@ export async function checkLoyaltyProgramExpiry() {
}
checkLoyaltyProgramExpiry().catch((error) => {
- const errorResult = {
- timestamp: new Date().toISOString(),
- error: error instanceof Error ? error.message : 'Unknown error',
- message: 'Loyalty program expiry check failed',
- };
-
- if (parentPort) {
- parentPort.postMessage({
- type: 'loyalty-program-expiry-error',
- data: errorResult,
- });
- }
+ throw error;
});
From b28df59d943aa3e19675ba6d1b64da21163c4ea8 Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Sat, 24 Jan 2026 14:00:25 +0530
Subject: [PATCH 06/21] fix: formatted code
---
schemas/app/LoyaltyProgram.json | 2 --
1 file changed, 2 deletions(-)
diff --git a/schemas/app/LoyaltyProgram.json b/schemas/app/LoyaltyProgram.json
index 4b9df873..608f97f9 100644
--- a/schemas/app/LoyaltyProgram.json
+++ b/schemas/app/LoyaltyProgram.json
@@ -65,7 +65,6 @@
"label": "Maximum Use",
"fieldtype": "Int",
"default": 0,
- "required": true,
"section": "Validity and Usage"
},
{
@@ -73,7 +72,6 @@
"label": "Used",
"fieldtype": "Int",
"default": 0,
- "required": true,
"readOnly": true,
"section": "Validity and Usage"
}
From 446585dee7b6c7880e89403da3d4db4df205a276 Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Sat, 31 Jan 2026 13:45:03 +0530
Subject: [PATCH 07/21] fix: expire loyalty program on maximum usage
---
models/baseModels/Invoice/Invoice.ts | 14 ++++++++++++-
models/helpers.ts | 31 ++++++++++++++++++++++++++++
src/pages/POS/POS.vue | 20 ++++++++++++++++--
src/pages/POS/POSQuickActions.vue | 4 +++-
4 files changed, 65 insertions(+), 4 deletions(-)
diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts
index 1fcf0e1d..51eed4bb 100644
--- a/models/baseModels/Invoice/Invoice.ts
+++ b/models/baseModels/Invoice/Invoice.ts
@@ -30,6 +30,7 @@ import {
getItemVisibility,
validateLoyaltyProgram,
getLoyaltyProgramTier,
+ isLoyaltyProgramMaxedOut,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
@@ -1086,7 +1087,18 @@ export abstract class Invoice extends Transactional {
ModelNameEnum.Party,
this.party
);
- return partyDoc?.loyaltyProgram as string;
+ const loyaltyProgramName = partyDoc?.loyaltyProgram as string;
+
+ if (!loyaltyProgramName) {
+ return '';
+ }
+
+ const maxedOut = await isLoyaltyProgramMaxedOut(
+ this.fyo,
+ loyaltyProgramName
+ );
+
+ return maxedOut ? '' : loyaltyProgramName;
},
dependsOn: ['party', 'name'],
},
diff --git a/models/helpers.ts b/models/helpers.ts
index 3f1e73b8..802877ef 100644
--- a/models/helpers.ts
+++ b/models/helpers.ts
@@ -1654,6 +1654,37 @@ export async function validateLoyaltyProgram(
}
}
+export async function isLoyaltyProgramMaxedOut(
+ fyo: Fyo,
+ loyaltyProgramName: string
+): Promise {
+ if (!loyaltyProgramName) {
+ return false;
+ }
+
+ const loyaltyProgram = await fyo.db.getAll(ModelNameEnum.LoyaltyProgram, {
+ fields: ['maximumUse', 'used', 'isEnabled'],
+ filters: { name: loyaltyProgramName },
+ });
+
+ if (!loyaltyProgram[0]) {
+ return false;
+ }
+
+ if (!loyaltyProgram[0]?.isEnabled) {
+ return true;
+ }
+
+ const maximumUse = loyaltyProgram[0]?.maximumUse as number;
+ const used = loyaltyProgram[0]?.used as number;
+
+ if (!maximumUse) {
+ return false;
+ }
+
+ return used >= maximumUse;
+}
+
export function removeFreeItems(sinvDoc: SalesInvoice) {
if (!sinvDoc || !sinvDoc.items) {
return;
diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue
index cdd6b032..2bb2c11d 100644
--- a/src/pages/POS/POS.vue
+++ b/src/pages/POS/POS.vue
@@ -166,6 +166,7 @@ import {
removeFreeItems,
getItemRateFromPriceList,
getItemVisibility,
+ isLoyaltyProgramMaxedOut,
} from 'models/helpers';
import {
POSItem,
@@ -495,7 +496,21 @@ export default defineComponent({
filters: { name: value },
});
- this.loyaltyProgram = party[0]?.loyaltyProgram as string;
+ const loyaltyProgramName = party[0]?.loyaltyProgram as string;
+
+ if (loyaltyProgramName) {
+ const isMaxedOut = await isLoyaltyProgramMaxedOut(
+ this.fyo,
+ loyaltyProgramName
+ );
+ if (isMaxedOut) {
+ this.loyaltyProgram = '';
+ this.loyaltyPoints = 0;
+ return;
+ }
+ }
+
+ this.loyaltyProgram = loyaltyProgramName;
this.loyaltyPoints = party[0]?.loyaltyPoints as number;
},
@@ -644,7 +659,8 @@ export default defineComponent({
this.fyo.singles.AccountingSettings?.enablePriceList &&
this.loyaltyPoints &&
this.sinvDoc.party &&
- this.sinvDoc.items?.length
+ this.sinvDoc.items?.length &&
+ this.loyaltyProgram
) {
this.toggleModal('LoyaltyProgram', true);
}
diff --git a/src/pages/POS/POSQuickActions.vue b/src/pages/POS/POSQuickActions.vue
index 51f118b7..54572f48 100644
--- a/src/pages/POS/POSQuickActions.vue
+++ b/src/pages/POS/POSQuickActions.vue
@@ -76,7 +76,9 @@
Date: Thu, 29 Jan 2026 13:06:22 +0530
Subject: [PATCH 08/21] fix: added erp item visibility
---
fyo/model/doc.ts | 35 +++++++++++++++++--
.../ERPNextSyncSettings.ts | 3 ++
models/baseModels/InvoiceItem/InvoiceItem.ts | 26 ++++++++++++--
models/baseModels/Item/Item.ts | 2 ++
models/helpers.ts | 6 ++++
models/inventory/Point of Sale/POSSettings.ts | 5 ++-
schemas/app/Item.json | 7 ++++
.../inventory/Point of Sale/POSSettings.json | 22 ++++++++++++
src/components/POS/Classic/ItemsGrid.vue | 5 +++
src/components/POS/Classic/ItemsTable.vue | 28 ++++++++++-----
.../POS/Modern/ModernPOSItemsGrid.vue | 5 +++
.../POS/Modern/ModernPOSItemsTable.vue | 30 +++++++++++-----
src/components/POS/types.ts | 2 +-
src/pages/POS/ClassicPOS.vue | 6 ++++
src/pages/POS/ModernPOS.vue | 6 ++++
src/pages/POS/POS.vue | 10 ++++++
src/utils/erpnextSync.ts | 4 +++
17 files changed, 178 insertions(+), 24 deletions(-)
diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts
index 14965ceb..50f7bb7b 100644
--- a/fyo/model/doc.ts
+++ b/fyo/model/doc.ts
@@ -920,6 +920,35 @@ export class Doc extends Observable {
return this;
}
+ async _hasERPSyncableItems(): Promise {
+ const isSalesInvoice = this.schemaName === ModelNameEnum.SalesInvoice;
+ if (!isSalesInvoice) {
+ return true;
+ }
+
+ const items = (this.get('items') as Doc[]) ?? [];
+
+ for (const item of items) {
+ const itemName = item.get('item') as string;
+ if (!itemName) {
+ continue;
+ }
+
+ try {
+ const itemDoc = await this.fyo.doc.getDoc('Item', itemName);
+ const isInventoryItem = !!itemDoc.get('trackItem');
+ const isFromERP = !!itemDoc.get('datafromErp');
+
+ if (!isInventoryItem && isFromERP) {
+ return true;
+ }
+ } catch (err) {
+ continue;
+ }
+ }
+
+ return false;
+ }
async sync(): Promise {
this._syncing = true;
@@ -936,10 +965,12 @@ export class Doc extends Observable {
if (this._addDocToSyncQueue && !!this.shouldDocSyncToERPNext) {
const isSalesInvoice = this.schemaName === ModelNameEnum.SalesInvoice;
+ const hasERPSyncableItems = await this._hasERPSyncableItems();
if (
- !(isSalesInvoice && this.isSyncedWithErp) ||
- (isSalesInvoice && !!this.isReturn)
+ hasERPSyncableItems &&
+ (!(isSalesInvoice && this.isSyncedWithErp) ||
+ (isSalesInvoice && !!this.isReturn))
) {
if (isSalesInvoice && !this.isReturn) {
await this.setAndSync('isSyncedWithErp', true);
diff --git a/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts b/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts
index b163e153..ee17eebc 100644
--- a/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts
+++ b/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts
@@ -40,6 +40,9 @@ export class ERPNextSyncSettings extends Doc {
batchSyncType: () => {
return !this.fyo.singles.InventorySettings?.enableBatches;
},
+ // syncDataFromServer: () => {
+ // return !this.deviceID;
+ // },
};
async change(ch: ChangeArg) {
diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts
index 590702d2..14ed7ef0 100644
--- a/models/baseModels/InvoiceItem/InvoiceItem.ts
+++ b/models/baseModels/InvoiceItem/InvoiceItem.ts
@@ -19,8 +19,13 @@ import { Item } from '../Item/Item';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { isPesa } from 'fyo/utils';
import { PricingRule } from '../PricingRule/PricingRule';
-import { getItemRateFromPriceList, getPricingRule } from 'models/helpers';
+import {
+ getItemRateFromPriceList,
+ getPricingRule,
+ getItemVisibility,
+} from 'models/helpers';
import { SalesInvoice } from '../SalesInvoice/SalesInvoice';
+import { QueryFilter } from 'utils/db/types';
export abstract class InvoiceItem extends Doc {
item?: string;
@@ -646,13 +651,28 @@ export abstract class InvoiceItem extends Doc {
};
static filters: FiltersMap = {
- item: (doc: Doc) => {
+ item: async (doc: Doc): Promise => {
let itemNotFor = 'Sales';
if (doc.isSales) {
itemNotFor = 'Purchases';
}
- return { for: ['not in', [itemNotFor]] };
+ const filters: QueryFilter = {
+ for: ['not in', [itemNotFor]],
+ };
+
+ const enableERPNextSync =
+ doc.fyo.singles.AccountingSettings?.enableERPNextSync;
+
+ if (enableERPNextSync) {
+ const itemVisibility = await getItemVisibility(doc.fyo);
+
+ if (itemVisibility === 'ERP Sync Items') {
+ filters.datafromErp = true;
+ }
+ }
+
+ return filters;
},
batch: async (doc: Doc) => {
const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, {
diff --git a/models/baseModels/Item/Item.ts b/models/baseModels/Item/Item.ts
index 9971a1ba..5f349386 100644
--- a/models/baseModels/Item/Item.ts
+++ b/models/baseModels/Item/Item.ts
@@ -30,6 +30,7 @@ export class Item extends Doc {
hsnCode?: number;
hasSerialNumber?: boolean;
serialNumberSeries?: string;
+ datafromErp?: boolean;
uomConversions: UOMConversionItem[] = [];
formulas: FormulaMap = {
@@ -237,5 +238,6 @@ export class Item extends Doc {
trackItem: () => this.inserted,
hasBatch: () => this.inserted,
hasSerialNumber: () => this.inserted,
+ datafromErp: () => true,
};
}
diff --git a/models/helpers.ts b/models/helpers.ts
index 07e13435..37e8744c 100644
--- a/models/helpers.ts
+++ b/models/helpers.ts
@@ -116,6 +116,12 @@ export async function getItemQtyMap(doc: SalesInvoice): Promise {
export async function getItemVisibility(fyo: Fyo): Promise {
const posProfileName = fyo.singles.POSSettings?.posProfile as string;
+ const enableERPNextSync = fyo.singles.AccountingSettings?.enableERPNextSync;
+
+ if (enableERPNextSync) {
+ // When ERP sync is enabled, use itemVisibilityERP from POSSettings
+ return fyo.singles.POSSettings?.itemVisibilityERP as ItemVisibility;
+ }
if (posProfileName) {
const posProfile = await fyo.doc.getDoc(
diff --git a/models/inventory/Point of Sale/POSSettings.ts b/models/inventory/Point of Sale/POSSettings.ts
index 5fbcc9e6..2a70b60a 100644
--- a/models/inventory/Point of Sale/POSSettings.ts
+++ b/models/inventory/Point of Sale/POSSettings.ts
@@ -16,6 +16,7 @@ export class POSSettings extends Doc {
itemWeightDigits?: number;
defaultAccount?: string;
itemVisibility?: string;
+ itemVisibilityERP?: 'ERP Sync Items';
posUI?: 'Classic' | 'Modern';
canChangeRate?: boolean;
canEditDiscount?: boolean;
@@ -46,6 +47,8 @@ export class POSSettings extends Doc {
!this.fyo.singles.InventorySettings?.enableBarcodes ||
!this.weightEnabledBarcode,
itemVisibility: () =>
- !this.fyo.singles.AccountingSettings?.enablePointOfSaleWithOutInventory,
+ !!this.fyo.singles.AccountingSettings?.enableERPNextSync,
+ itemVisibilityERP: () =>
+ !this.fyo.singles.AccountingSettings?.enableERPNextSync,
};
}
diff --git a/schemas/app/Item.json b/schemas/app/Item.json
index 02a483fa..d97b7677 100644
--- a/schemas/app/Item.json
+++ b/schemas/app/Item.json
@@ -172,6 +172,13 @@
"fieldtype": "Table",
"target": "UOMConversionItem",
"section": "Inventory"
+ },
+ {
+ "fieldname": "datafromErp",
+ "fieldtype": "Check",
+ "hidden": false,
+ "default": false,
+ "section": "Default"
}
],
"quickEditFields": [
diff --git a/schemas/app/inventory/Point of Sale/POSSettings.json b/schemas/app/inventory/Point of Sale/POSSettings.json
index 0d2887bb..657a28eb 100644
--- a/schemas/app/inventory/Point of Sale/POSSettings.json
+++ b/schemas/app/inventory/Point of Sale/POSSettings.json
@@ -121,6 +121,28 @@
"required": true,
"section": "Default"
},
+ {
+ "fieldname": "itemVisibilityERP",
+ "label": "Item Visibility",
+ "fieldtype": "Select",
+ "options": [
+ {
+ "value": "ERP Sync Items",
+ "label": "ERP Sync Items"
+ },
+ {
+ "value": "Inventory Items",
+ "label": "Inventory Items"
+ },
+ {
+ "value": "Non-Inventory Items",
+ "label": "Non-Inventory Items"
+ }
+ ],
+ "default": "ERP Sync Items",
+ "required": true,
+ "section": "Default"
+ },
{
"fieldname": "canChangeRate",
"label": "Can Change Rate",
diff --git a/src/components/POS/Classic/ItemsGrid.vue b/src/components/POS/Classic/ItemsGrid.vue
index 08c289a2..0081f134 100644
--- a/src/components/POS/Classic/ItemsGrid.vue
+++ b/src/components/POS/Classic/ItemsGrid.vue
@@ -63,6 +63,7 @@
@@ -81,14 +81,14 @@ export default defineComponent({
props: {
items: Array,
itemQtyMap: Object,
- isErpSync: {
- type: Boolean,
- default: false,
+ itemVisibility: {
+ type: String,
+ default: 'Inventory Items',
},
},
computed: {
ratio() {
- if (this.isErpSync) {
+ if (this.itemVisibility === 'ERP Sync Items') {
return [1, 1.5, 0.8];
}
return [1, 1, 1, 0.7];
@@ -119,7 +119,7 @@ export default defineComponent({
},
] as Field[];
- if (!this.isErpSync) {
+ if (this.itemVisibility !== 'ERP Sync Items') {
fields.splice(2, 0, {
fieldname: 'availableQty',
label: 'Qty',
diff --git a/src/components/POS/Modern/ModernPOSItemsGrid.vue b/src/components/POS/Modern/ModernPOSItemsGrid.vue
index cf30ee13..9df82aad 100644
--- a/src/components/POS/Modern/ModernPOSItemsGrid.vue
+++ b/src/components/POS/Modern/ModernPOSItemsGrid.vue
@@ -66,7 +66,7 @@
@@ -113,7 +113,7 @@
:key="df.fieldname"
size="large"
:df="df"
- :value="row[df.fieldname]"
+ :value="(row as POSItem)[df.fieldname as keyof POSItem]"
:readOnly="true"
/>
@@ -137,14 +137,14 @@ export default defineComponent({
props: {
items: Array,
itemQtyMap: Object,
- isErpSync: {
- type: Boolean,
- default: false,
+ itemVisibility: {
+ type: String,
+ default: 'Inventory Items',
},
},
computed: {
ratio() {
- if (this.isErpSync) {
+ if (this.itemVisibility === 'ERP Sync Items') {
return [1, 1.5, 0.8];
}
return [1, 1, 1, 0.7];
@@ -175,7 +175,7 @@ export default defineComponent({
},
] as Field[];
- if (!this.isErpSync) {
+ if (this.itemVisibility !== 'ERP Sync Items') {
fields.splice(2, 0, {
fieldname: 'availableQty',
label: t`Qty`,
diff --git a/src/pages/POS/ClassicPOS.vue b/src/pages/POS/ClassicPOS.vue
index 335f5cd2..23832681 100644
--- a/src/pages/POS/ClassicPOS.vue
+++ b/src/pages/POS/ClassicPOS.vue
@@ -134,7 +134,7 @@
v-if="tableView"
:items="items"
:item-qty-map="itemQuantityMap as ItemQtyMap"
- :is-erp-sync="isErpSync"
+ :item-visibility="itemVisibility"
@add-item="(item) => emitEvent('addItem', item)"
/>
@@ -142,7 +142,7 @@
v-else
:items="items"
:item-qty-map="itemQuantityMap as ItemQtyMap"
- :is-erp-sync="isErpSync"
+ :item-visibility="itemVisibility"
@add-item="(item) => emitEvent('addItem', item)"
/>
@@ -485,9 +485,9 @@ export default defineComponent({
type: Array as PropType,
default: () => [],
},
- isErpSync: {
- type: Boolean,
- default: false,
+ itemVisibility: {
+ type: String,
+ default: 'Inventory Items',
},
profile: {
type: Object as PropType,
diff --git a/src/pages/POS/ModernPOS.vue b/src/pages/POS/ModernPOS.vue
index df46a2fe..71723617 100644
--- a/src/pages/POS/ModernPOS.vue
+++ b/src/pages/POS/ModernPOS.vue
@@ -345,7 +345,7 @@
v-if="tableView"
:items="items"
:item-qty-map="itemQuantityMap as ItemQtyMap"
- :is-erp-sync="isErpSync"
+ :item-visibility="itemVisibility"
@add-item="(item:string) => emitEvent('addItem', item)"
/>
@@ -353,7 +353,7 @@
v-else
:items="items"
:item-qty-map="itemQuantityMap as ItemQtyMap"
- :is-erp-sync="isErpSync"
+ :item-visibility="itemVisibility"
@add-item="(item:string) => emitEvent('addItem', item)"
/>
@@ -491,9 +491,9 @@ export default defineComponent({
type: Array as PropType,
default: () => [],
},
- isErpSync: {
- type: Boolean,
- default: false,
+ itemVisibility: {
+ type: String,
+ default: 'Inventory Items',
},
profile: {
type: Object as PropType,
diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue
index 038d190e..154a85dd 100644
--- a/src/pages/POS/POS.vue
+++ b/src/pages/POS/POS.vue
@@ -27,7 +27,7 @@
:selected-item-group="selectedItemGroup"
:is-pos-shift-open="isPosShiftOpen"
:items="(items as [] as POSItem[])"
- :is-erp-sync="isErpSync"
+ :item-visibility="itemVisibility"
:sinv-doc="(sinvDoc as SalesInvoice)"
:disable-pay-button="disablePayButton"
:open-payment-modal="openPaymentModal"
@@ -84,7 +84,7 @@
:selected-item-group="selectedItemGroup"
:is-pos-shift-open="isPosShiftOpen"
:items="(items as [] as POSItem[])"
- :is-erp-sync="isErpSync"
+ :item-visibility="itemVisibility"
:sinv-doc="(sinvDoc as SalesInvoice)"
:disable-pay-button="disablePayButton"
:open-payment-modal="openPaymentModal"
@@ -169,6 +169,7 @@ import {
getItemRateFromPriceList,
getItemVisibility,
} from 'models/helpers';
+import { ItemVisibility } from 'src/components/POS/types';
import {
POSItem,
ItemQtyMap,
@@ -264,7 +265,7 @@ export default defineComponent({
quickQtyKeyUpHandler: null as ((e: KeyboardEvent) => void) | null,
selectedItemForBatch: '' as string,
pendingBatchItem: null as { item: POSItem; quantity: number } | null,
- isErpSyncValue: false,
+ itemVisibilityValue: 'Inventory Items' as ItemVisibility,
};
},
computed: {
@@ -274,8 +275,8 @@ export default defineComponent({
return !!fyo.singles.AccountingSettings?.enableDiscounting;
},
isPosShiftOpen: () => !!fyo.singles.POSSettings?.isShiftOpen,
- isErpSync() {
- return this.isErpSyncValue;
+ itemVisibility() {
+ return this.itemVisibilityValue;
},
disablePayButton(): boolean {
if (!this.sinvDoc.items?.length || !this.sinvDoc.party) {
@@ -301,7 +302,7 @@ export default defineComponent({
async mounted() {
await this.setItems();
await this.loadPOSProfile();
- this.isErpSyncValue = !!fyo.singles.AccountingSettings?.enableERPNextSync;
+ this.itemVisibilityValue = await getItemVisibility(this.fyo);
},
async activated() {
toggleSidebar(false);
From bf4999d9469d3d2fcecf7587cae9f787b0ba4720 Mon Sep 17 00:00:00 2001
From: Gadha2311
Date: Fri, 6 Feb 2026 10:16:25 +0530
Subject: [PATCH 16/21] 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 17/21] 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 @@
-
+
+
-
+
$emit('setExpandedBatchId', rowName)
+ "
@run-sinv-formulas="runSinvFormulas"
@apply-pricing-rule="$emit('applyPricingRule')"
@selected-row="selectedItemRow"
@@ -70,7 +74,7 @@ import RowEditForm from 'src/pages/CommonForm/RowEditForm.vue';
import SelectedItemRow from './SelectedItemRow.vue';
import { isNumeric } from 'src/utils';
import { inject } from 'vue';
-import { defineComponent } from 'vue';
+import { defineComponent, PropType } from 'vue';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { Field } from 'schemas/types';
@@ -90,12 +94,13 @@ export default defineComponent({
sinvDoc: inject('sinvDoc') as SalesInvoice,
};
},
- data() {
- return {
- isExapanded: false,
- };
+ props: {
+ expandedBatchId: {
+ type: String as PropType,
+ 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 @@
-
+
$emit('setExpandedBatchId', rowName)
+ "
@selected-row="selectedItemRow"
@run-sinv-formulas="runSinvFormulas"
@apply-pricing-rule="$emit('applyPricingRule')"
@@ -59,7 +63,7 @@ import RowEditForm from 'src/pages/CommonForm/RowEditForm.vue';
import ModernPOSSelectedItemRow from './ModernPOSSelectedItemRow.vue';
import { isNumeric } from 'src/utils';
import { t } from 'fyo';
-import { inject, defineComponent } from 'vue';
+import { inject, defineComponent, PropType } from 'vue';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { Field } from 'schemas/types';
@@ -79,12 +83,18 @@ export default defineComponent({
sinvDoc: inject('sinvDoc') as SalesInvoice,
};
},
- data() {
- return {
- isExapanded: false,
- };
+ props: {
+ expandedBatchId: {
+ type: String as PropType,
+ 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 @@
/>
$emit('setExpandedBatchId', rowName)
+ "
@apply-pricing-rule="emitEvent('applyPricingRule')"
@selected-row="(row) => $emit('selectedRow', row)"
/>
@@ -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 @@
/>
$emit('setExpandedBatchId', rowName)
+ "
@selected-row="selectedRow"
@apply-pricing-rule="emitEvent('applyPricingRule')"
@toggle-modal="emitEvent('toggleModal', 'Keyboard')"
@@ -502,8 +506,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/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 18/21] 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 @@
-