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 @@

@@ -141,6 +142,7 @@ v-else :items="items" :item-qty-map="itemQuantityMap as ItemQtyMap" + :is-erp-sync="isErpSync" @add-item="(item) => emitEvent('addItem', item)" /> @@ -483,6 +485,10 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + isErpSync: { + type: Boolean, + default: false, + }, profile: { type: Object as PropType, required: false, diff --git a/src/pages/POS/ModernPOS.vue b/src/pages/POS/ModernPOS.vue index 7d278ee8..df46a2fe 100644 --- a/src/pages/POS/ModernPOS.vue +++ b/src/pages/POS/ModernPOS.vue @@ -345,6 +345,7 @@ v-if="tableView" :items="items" :item-qty-map="itemQuantityMap as ItemQtyMap" + :is-erp-sync="isErpSync" @add-item="(item:string) => emitEvent('addItem', item)" /> @@ -352,6 +353,7 @@ v-else :items="items" :item-qty-map="itemQuantityMap as ItemQtyMap" + :is-erp-sync="isErpSync" @add-item="(item:string) => emitEvent('addItem', item)" /> @@ -489,6 +491,10 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + isErpSync: { + type: Boolean, + default: false, + }, profile: { type: Object as PropType, required: false, diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index cdd6b032..cc9c1799 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -27,6 +27,7 @@ :selected-item-group="selectedItemGroup" :is-pos-shift-open="isPosShiftOpen" :items="(items as [] as POSItem[])" + :is-erp-sync="isErpSync" :sinv-doc="(sinvDoc as SalesInvoice)" :disable-pay-button="disablePayButton" :open-payment-modal="openPaymentModal" @@ -83,6 +84,7 @@ :selected-item-group="selectedItemGroup" :is-pos-shift-open="isPosShiftOpen" :items="(items as [] as POSItem[])" + :is-erp-sync="isErpSync" :sinv-doc="(sinvDoc as SalesInvoice)" :disable-pay-button="disablePayButton" :open-payment-modal="openPaymentModal" @@ -262,6 +264,7 @@ export default defineComponent({ quickQtyKeyUpHandler: null as ((e: KeyboardEvent) => void) | null, selectedItemForBatch: '' as string, pendingBatchItem: null as { item: POSItem; quantity: number } | null, + isErpSyncValue: false, }; }, computed: { @@ -271,6 +274,9 @@ export default defineComponent({ return !!fyo.singles.AccountingSettings?.enableDiscounting; }, isPosShiftOpen: () => !!fyo.singles.POSSettings?.isShiftOpen, + isErpSync() { + return this.isErpSyncValue; + }, disablePayButton(): boolean { if (!this.sinvDoc.items?.length || !this.sinvDoc.party) { return true; @@ -295,6 +301,7 @@ export default defineComponent({ async mounted() { await this.setItems(); await this.loadPOSProfile(); + this.isErpSyncValue = !!fyo.singles.AccountingSettings?.enableERPNextSync; }, async activated() { toggleSidebar(false); @@ -694,8 +701,11 @@ export default defineComponent({ if (itemVisibility === 'Inventory Items') { filters.trackItem = true; + } else if (itemVisibility === 'ERP Sync Items') { + filters.datafromErp = true; } else { filters.trackItem = false; + filters.datafromErp = false; } if (this.selectedItemGroup) { diff --git a/src/utils/erpnextSync.ts b/src/utils/erpnextSync.ts index 6197767f..5cfb7cc5 100644 --- a/src/utils/erpnextSync.ts +++ b/src/utils/erpnextSync.ts @@ -150,6 +150,10 @@ export async function syncDocumentsFromERPNext(fyo: Fyo) { continue; } + if (getDocTypeName(doc) === ModelNameEnum.Item) { + doc.datafromErp = true; + } + try { if ((doc.fbooksDocName as string) || (doc.name as string)) { const isDocExists = await fyo.db.exists( From 37972fa55b2ab1effd71b669a30d2828b113586d Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Thu, 29 Jan 2026 16:50:38 +0530 Subject: [PATCH 09/21] fix:formatted code --- fyo/model/doc.ts | 31 +++++++------------ .../ERPNextSyncSettings.ts | 3 -- models/helpers.ts | 1 - models/inventory/Point of Sale/POSSettings.ts | 1 + schemas/app/Item.json | 2 +- .../inventory/Point of Sale/POSSettings.json | 9 ------ 6 files changed, 13 insertions(+), 34 deletions(-) diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 50f7bb7b..5345e568 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -46,6 +46,7 @@ import { import { validateOptions, validateRequired } from './validationFunction'; import { getShouldDocSyncToERPNext } from 'src/utils/erpnextSync'; import { ModelNameEnum } from 'models/types'; +import { DocItem } from 'models/inventory/types'; export class Doc extends Observable { /* eslint-disable @typescript-eslint/no-floating-promises */ @@ -925,29 +926,19 @@ export class Doc extends Observable { 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) { + for (const item of this.items as DocItem[]) { + if (!item.item) { continue; } + const isFromERP = await this.fyo.getValue( + ModelNameEnum.Item, + item.item, + 'datafromErp' + ); + if (isFromERP) continue; + else return false; } - - return false; + return true; } async sync(): Promise { diff --git a/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts b/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts index ee17eebc..b163e153 100644 --- a/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts +++ b/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts @@ -40,9 +40,6 @@ export class ERPNextSyncSettings extends Doc { batchSyncType: () => { return !this.fyo.singles.InventorySettings?.enableBatches; }, - // syncDataFromServer: () => { - // return !this.deviceID; - // }, }; async change(ch: ChangeArg) { diff --git a/models/helpers.ts b/models/helpers.ts index 37e8744c..ac458d13 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -119,7 +119,6 @@ export async function getItemVisibility(fyo: Fyo): Promise { const enableERPNextSync = fyo.singles.AccountingSettings?.enableERPNextSync; if (enableERPNextSync) { - // When ERP sync is enabled, use itemVisibilityERP from POSSettings return fyo.singles.POSSettings?.itemVisibilityERP as ItemVisibility; } diff --git a/models/inventory/Point of Sale/POSSettings.ts b/models/inventory/Point of Sale/POSSettings.ts index 2a70b60a..3060b290 100644 --- a/models/inventory/Point of Sale/POSSettings.ts +++ b/models/inventory/Point of Sale/POSSettings.ts @@ -47,6 +47,7 @@ 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 d97b7677..d1dca717 100644 --- a/schemas/app/Item.json +++ b/schemas/app/Item.json @@ -176,7 +176,7 @@ { "fieldname": "datafromErp", "fieldtype": "Check", - "hidden": false, + "hidden": true, "default": false, "section": "Default" } diff --git a/schemas/app/inventory/Point of Sale/POSSettings.json b/schemas/app/inventory/Point of Sale/POSSettings.json index 657a28eb..f798a7af 100644 --- a/schemas/app/inventory/Point of Sale/POSSettings.json +++ b/schemas/app/inventory/Point of Sale/POSSettings.json @@ -96,13 +96,6 @@ "default": 0, "section": "Barcode" }, - { - "fieldname": "itemWeightDigits", - "label": "item Weight Digits", - "fieldtype": "Int", - "default": 0, - "section": "Barcode" - }, { "fieldname": "itemVisibility", "label": "Item Visibility", @@ -118,7 +111,6 @@ } ], "default": "Inventory Items", - "required": true, "section": "Default" }, { @@ -140,7 +132,6 @@ } ], "default": "ERP Sync Items", - "required": true, "section": "Default" }, { From 0d9c079e1d75905f6174b3b12847e34e6b18905f Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Fri, 30 Jan 2026 14:31:13 +0530 Subject: [PATCH 10/21] fix: rebase code --- models/baseModels/Item/Item.ts | 1 - schemas/app/Item.json | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/models/baseModels/Item/Item.ts b/models/baseModels/Item/Item.ts index 5f349386..1d0542b4 100644 --- a/models/baseModels/Item/Item.ts +++ b/models/baseModels/Item/Item.ts @@ -238,6 +238,5 @@ export class Item extends Doc { trackItem: () => this.inserted, hasBatch: () => this.inserted, hasSerialNumber: () => this.inserted, - datafromErp: () => true, }; } diff --git a/schemas/app/Item.json b/schemas/app/Item.json index d1dca717..09f1bcca 100644 --- a/schemas/app/Item.json +++ b/schemas/app/Item.json @@ -178,7 +178,8 @@ "fieldtype": "Check", "hidden": true, "default": false, - "section": "Default" + "section": "Default", + "readOnly": true } ], "quickEditFields": [ From 5e558badc492968f4b43a4756c00c61755fda007 Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Mon, 2 Feb 2026 14:55:38 +0530 Subject: [PATCH 11/21] fix: added filter in item visibility --- models/baseModels/InvoiceItem/InvoiceItem.ts | 10 +++++++++- src/pages/POS/POS.vue | 3 +++ src/utils/erpnextSync.ts | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index 14ed7ef0..365facda 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -667,8 +667,16 @@ export abstract class InvoiceItem extends Doc { if (enableERPNextSync) { const itemVisibility = await getItemVisibility(doc.fyo); - if (itemVisibility === 'ERP Sync Items') { + if (itemVisibility === 'Inventory Items') { + filters.trackItem = true; + } else if (itemVisibility === 'ERP Sync Items') { filters.datafromErp = true; + } else if (itemVisibility === 'Non-Inventory Items') { + filters.trackItem = false; + filters.datafromErp = false; + } else { + filters.trackItem = false; + filters.datafromErp = false; } } diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index cc9c1799..d7c9b23a 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -703,6 +703,9 @@ export default defineComponent({ filters.trackItem = true; } else if (itemVisibility === 'ERP Sync Items') { filters.datafromErp = true; + } else if (itemVisibility === 'Non-Inventory Items') { + filters.trackItem = false; + filters.datafromErp = false; } else { filters.trackItem = false; filters.datafromErp = false; diff --git a/src/utils/erpnextSync.ts b/src/utils/erpnextSync.ts index 5cfb7cc5..2672c9da 100644 --- a/src/utils/erpnextSync.ts +++ b/src/utils/erpnextSync.ts @@ -243,6 +243,10 @@ async function createNewDocument( token: string, deviceID: string ) { + if (getDocTypeName(doc) === ModelNameEnum.Item) { + doc.datafromErp = true; + } + const newDoc = fyo.doc.getNewDoc(getDocTypeName(doc), doc); await performPreSync(fyo, doc); await appendDocValues(newDoc as DocValueMap, doc); @@ -451,8 +455,13 @@ async function updateExistingDocument( token: string, deviceID: string ) { + const docType = getDocTypeName(doc); + + if (docType === ModelNameEnum.Item) { + } + const existingDoc = await fyo.doc.getDoc( - getDocTypeName(doc), + docType, (doc.fbooksDocName as string) || (doc.name as string) ); @@ -537,6 +546,10 @@ export async function performInitialFullSync(fyo: Fyo) { if (docsByType[docType] && docsByType[docType].length > 0) { for (const doc of docsByType[docType]) { try { + if (docType === ModelNameEnum.Item) { + doc.datafromErp = true; + } + const isDocExists = await fyo.db.exists( docType, (doc.fbooksDocName as string) || (doc.name as string) From 8e10bf313df965bb2b7bc5bdc3a2061235585d31 Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Mon, 2 Feb 2026 17:11:24 +0530 Subject: [PATCH 12/21] fix: update status when loyalty program reaches maximum usage --- jobs/checkLoyaltyProgramExpiry.ts | 28 ++++++++++++------- models/baseModels/Invoice/Invoice.ts | 4 +-- .../LoyaltyProgram/LoyaltyProgram.ts | 10 +++++++ models/helpers.ts | 14 +++++++--- src/components/StatusPill.vue | 23 +++++++++++++-- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/jobs/checkLoyaltyProgramExpiry.ts b/jobs/checkLoyaltyProgramExpiry.ts index 838aeb44..e6e6b2ab 100644 --- a/jobs/checkLoyaltyProgramExpiry.ts +++ b/jobs/checkLoyaltyProgramExpiry.ts @@ -7,22 +7,30 @@ export async function checkLoyaltyProgramExpiry() { try { const currentDate = new Date(); - const loyaltyPrograms = (await dm.db?.getAll(ModelNameEnum.LoyaltyProgram, { - fields: ['name', 'toDate', 'status', 'isEnabled'], + const loyaltyPrograms = await dm.db?.getAll(ModelNameEnum.LoyaltyProgram, { + fields: ['name', 'toDate', 'status', 'isEnabled', 'maximumUse', 'used'], filters: { - status: ['!=', 'Expired'], + status: ['not in', ['Expired', 'Maxed']], isEnabled: true, }, - })) as Array<{ - name: string; - toDate: string; - status: string; - isEnabled: boolean; - }>; + }); if (loyaltyPrograms) { for (const program of loyaltyPrograms) { - if (program.toDate && new Date(program.toDate) <= currentDate) { + const maximumUse = Number(program.maximumUse) || 0; + const used = Number(program.used) || 0; + + if (maximumUse > 0 && used >= maximumUse) { + await dm.db?.knex!(ModelNameEnum.LoyaltyProgram) + .where({ name: program.name }) + .update({ + status: 'Maxed', + isEnabled: false, + }); + continue; + } + + if (program.toDate && new Date(String(program.toDate)) <= currentDate) { await dm.db?.knex!(ModelNameEnum.LoyaltyProgram) .where({ name: program.name }) .update({ diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index 51eed4bb..655c6230 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -886,7 +886,7 @@ export abstract class Invoice extends Transactional { } async updateUsedCountOfLoyaltyProgram() { - if (!this.loyaltyProgram) { + if (!this.loyaltyProgram || !this.redeemLoyaltyPoints) { return; } @@ -901,7 +901,7 @@ export abstract class Invoice extends Transactional { } async reduceUsedCountOfLoyaltyProgram() { - if (!this.loyaltyProgram) { + if (!this.loyaltyProgram || !this.redeemLoyaltyPoints) { return; } diff --git a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts index 45d5b3f7..d2bb47bd 100644 --- a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts +++ b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts @@ -11,6 +11,7 @@ export class LoyaltyProgram extends Doc { expiryDuration?: number; maximumUse?: number; used?: number; + status?: 'Active' | 'Expired' | 'Maxed' | 'Disabled'; validations: ValidationMap = { used: (value: DocValue) => { @@ -34,6 +35,15 @@ export class LoyaltyProgram extends Doc { }, }; + async afterSubmit() { + const maximumUse = (this.maximumUse as number) || 0; + const used = (this.used as number) || 0; + + if (maximumUse > 0 && used >= maximumUse) { + await this.setAndSync({ status: 'Maxed', isEnabled: false }); + } + } + static filters: FiltersMap = { expenseAccount: () => ({ rootType: AccountRootTypeEnum.Expense, diff --git a/models/helpers.ts b/models/helpers.ts index 802877ef..f0a21bc8 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -765,6 +765,13 @@ export function getLoyaltyProgramStatus(doc?: RenderData | Doc): string { return ''; } + const maximumUse = doc.maximumUse as number; + const used = doc.used as number; + + if (maximumUse > 0 && used >= maximumUse) { + return 'Maxed'; + } + const currentDate = new Date(); currentDate.setHours(0, 0, 0, 0); @@ -781,6 +788,7 @@ export const loyaltyProgramStatusColor: Record = { Active: 'green', Disabled: 'gray', Expired: 'red', + Maxed: 'orange', }; export function getLoyaltyProgramStatusText(status: string): string { @@ -791,6 +799,8 @@ export function getLoyaltyProgramStatusText(status: string): string { return t`Disabled`; case 'Expired': return t`Expired`; + case 'Maxed': + return t`Maxed`; default: return ''; } @@ -1658,10 +1668,6 @@ 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 }, diff --git a/src/components/StatusPill.vue b/src/components/StatusPill.vue index b9252a99..358d9f4e 100644 --- a/src/components/StatusPill.vue +++ b/src/components/StatusPill.vue @@ -79,6 +79,8 @@ export default defineComponent({ Unpaid: this.t`Unpaid`, PartlyPaid: this.t`Partly Paid`, Expired: this.t`Expired`, + Maxed: this.t`Maxed`, + Active: this.t`Active`, }[this.status]; }, color(): UIColors { @@ -102,6 +104,8 @@ const statusColorMap: Record = { Unpaid: 'red', PartlyPaid: 'yellow', Expired: 'red', + Maxed: 'orange', + Active: 'green', }; function getStatus(doc: Doc) { @@ -115,10 +119,23 @@ function getStatus(doc: Doc) { if (doc instanceof LoyaltyProgram) { const currentDate = new Date(); - if (doc.toDate && doc.toDate instanceof Date && doc.toDate <= currentDate) { - return 'Expired'; + currentDate.setHours(0, 0, 0, 0); + + const maximumUse = doc.maximumUse as number; + const used = doc.used as number; + + if (maximumUse > 0 && used >= maximumUse) { + return 'Maxed'; } - return 'Saved'; + + if (doc.toDate && doc.toDate instanceof Date) { + const toDate = new Date(doc.toDate); + toDate.setHours(0, 0, 0, 0); + if (toDate <= currentDate) { + return 'Expired'; + } + } + return 'Active'; } if (doc instanceof Party && doc.outstandingAmount?.isZero() !== true) { From 47ef5c321b3dfece58d901181c16447de65e1155 Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Mon, 2 Feb 2026 17:19:41 +0530 Subject: [PATCH 13/21] fix: rebased code --- models/baseModels/InvoiceItem/InvoiceItem.ts | 3 --- src/pages/POS/POS.vue | 3 --- src/utils/erpnextSync.ts | 3 --- 3 files changed, 9 deletions(-) diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index 365facda..b089ee41 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -674,9 +674,6 @@ export abstract class InvoiceItem extends Doc { } else if (itemVisibility === 'Non-Inventory Items') { filters.trackItem = false; filters.datafromErp = false; - } else { - filters.trackItem = false; - filters.datafromErp = false; } } diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index d7c9b23a..038d190e 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -706,9 +706,6 @@ export default defineComponent({ } else if (itemVisibility === 'Non-Inventory Items') { filters.trackItem = false; filters.datafromErp = false; - } else { - filters.trackItem = false; - filters.datafromErp = false; } if (this.selectedItemGroup) { diff --git a/src/utils/erpnextSync.ts b/src/utils/erpnextSync.ts index 2672c9da..728c2836 100644 --- a/src/utils/erpnextSync.ts +++ b/src/utils/erpnextSync.ts @@ -457,9 +457,6 @@ async function updateExistingDocument( ) { const docType = getDocTypeName(doc); - if (docType === ModelNameEnum.Item) { - } - const existingDoc = await fyo.doc.getDoc( docType, (doc.fbooksDocName as string) || (doc.name as string) From cb092ede86062434e60f4fde8c266da9102e7a3e Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Wed, 4 Feb 2026 15:07:22 +0530 Subject: [PATCH 14/21] fix: added validation for maxed loyaltyprogram --- jobs/checkLoyaltyProgramExpiry.ts | 15 +--- models/baseModels/Invoice/Invoice.ts | 78 +++++++++++++------ .../LoyaltyProgram/LoyaltyProgram.ts | 21 +---- models/baseModels/Party/Party.ts | 12 +++ models/helpers.ts | 74 ++++++++++-------- schemas/app/SalesInvoice.json | 4 +- src/components/StatusPill.vue | 4 +- src/pages/POS/POS.vue | 8 +- 8 files changed, 119 insertions(+), 97 deletions(-) diff --git a/jobs/checkLoyaltyProgramExpiry.ts b/jobs/checkLoyaltyProgramExpiry.ts index e6e6b2ab..ab5eb70c 100644 --- a/jobs/checkLoyaltyProgramExpiry.ts +++ b/jobs/checkLoyaltyProgramExpiry.ts @@ -10,26 +10,13 @@ export async function checkLoyaltyProgramExpiry() { const loyaltyPrograms = await dm.db?.getAll(ModelNameEnum.LoyaltyProgram, { fields: ['name', 'toDate', 'status', 'isEnabled', 'maximumUse', 'used'], filters: { - status: ['not in', ['Expired', 'Maxed']], + status: ['not in', ['Expired']], isEnabled: true, }, }); if (loyaltyPrograms) { for (const program of loyaltyPrograms) { - const maximumUse = Number(program.maximumUse) || 0; - const used = Number(program.used) || 0; - - if (maximumUse > 0 && used >= maximumUse) { - await dm.db?.knex!(ModelNameEnum.LoyaltyProgram) - .where({ name: program.name }) - .update({ - status: 'Maxed', - isEnabled: false, - }); - continue; - } - if (program.toDate && new Date(String(program.toDate)) <= currentDate) { await dm.db?.knex!(ModelNameEnum.LoyaltyProgram) .where({ name: program.name }) diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index 655c6230..049470af 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -30,7 +30,7 @@ import { getItemVisibility, validateLoyaltyProgram, getLoyaltyProgramTier, - isLoyaltyProgramMaxedOut, + isLoyaltyProgramExpiredAndMaxed, } from 'models/helpers'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { validateBatch } from 'models/inventory/helpers'; @@ -886,7 +886,7 @@ export abstract class Invoice extends Transactional { } async updateUsedCountOfLoyaltyProgram() { - if (!this.loyaltyProgram || !this.redeemLoyaltyPoints) { + if (!this.loyaltyProgram) { return; } @@ -895,13 +895,27 @@ export abstract class Invoice extends Transactional { this.loyaltyProgram ); - await loyaltyProgramDoc.setAndSync({ - used: (loyaltyProgramDoc.used as number) + 1, - }); + const maximumUse = loyaltyProgramDoc.maximumUse as number; + const used = (loyaltyProgramDoc.used as number) || 0; + + if (this.redeemLoyaltyPoints) { + const newUsedCount = used + 1; + + if (maximumUse > 0 && newUsedCount >= maximumUse) { + await loyaltyProgramDoc.setAndSync({ + used: newUsedCount, + isEnabled: false, + }); + } else { + await loyaltyProgramDoc.setAndSync({ + used: newUsedCount, + }); + } + } } async reduceUsedCountOfLoyaltyProgram() { - if (!this.loyaltyProgram || !this.redeemLoyaltyPoints) { + if (!this.loyaltyProgram) { return; } @@ -910,9 +924,22 @@ export abstract class Invoice extends Transactional { this.loyaltyProgram ); - await loyaltyProgramDoc.setAndSync({ - used: (loyaltyProgramDoc.used as number) - 1, - }); + const maximumUse = loyaltyProgramDoc.maximumUse as number; + const used = (loyaltyProgramDoc.used as number) || 0; + const newUsedCount = used - 1; + + if (this.redeemLoyaltyPoints) { + if (newUsedCount < maximumUse) { + await loyaltyProgramDoc.setAndSync({ + used: newUsedCount, + isEnabled: true, + }); + } else { + await loyaltyProgramDoc.setAndSync({ + used: newUsedCount, + }); + } + } } async updateIsItemsFullyReturned(doc?: Invoice) { @@ -975,11 +1002,6 @@ export abstract class Invoice extends Transactional { this.loyaltyProgram )) as LoyaltyProgram; - // 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; @@ -1093,12 +1115,7 @@ export abstract class Invoice extends Transactional { return ''; } - const maxedOut = await isLoyaltyProgramMaxedOut( - this.fyo, - loyaltyProgramName - ); - - return maxedOut ? '' : loyaltyProgramName; + return loyaltyProgramName; }, dependsOn: ['party', 'name'], }, @@ -1108,6 +1125,17 @@ export abstract class Invoice extends Transactional { return 0; } + const loyaltyProgramName = this.loyaltyProgram as string; + if (loyaltyProgramName) { + const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed( + this.fyo, + loyaltyProgramName + ); + if (isExpiredAndMaxed) { + return 0; + } + } + const loyaltyPoints = await this.fyo.getValue( ModelNameEnum.Party, this.party, @@ -1115,7 +1143,7 @@ export abstract class Invoice extends Transactional { ); return loyaltyPoints || 0; }, - dependsOn: ['party'], + dependsOn: ['party', 'loyaltyProgram'], }, currency: { formula: async () => { @@ -1308,7 +1336,13 @@ export abstract class Invoice extends Transactional { loyaltyProgram: () => !this.loyaltyProgram, availableLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn, loyaltyPoints: () => !this.redeemLoyaltyPoints || this.isReturn, - redeemLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn, + redeemLoyaltyPoints: () => { + if (!this.loyaltyProgram || this.isReturn) { + return true; + } + + return (this.availableLoyaltyPoints ?? 0) <= 0; + }, coupons: () => this.isSubmitted && !this.coupons?.length, priceList: () => !this.fyo.singles.AccountingSettings?.enablePriceList || diff --git a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts index d2bb47bd..20d8db45 100644 --- a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts +++ b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts @@ -1,9 +1,8 @@ import { DocValue } from 'fyo/core/types'; import { Doc } from 'fyo/model/doc'; -import { FiltersMap, ListViewSettings, ValidationMap } from 'fyo/model/types'; +import { 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 { @@ -11,7 +10,7 @@ export class LoyaltyProgram extends Doc { expiryDuration?: number; maximumUse?: number; used?: number; - status?: 'Active' | 'Expired' | 'Maxed' | 'Disabled'; + status?: 'Active' | 'Expired' | 'Disabled' | 'Maxed'; validations: ValidationMap = { used: (value: DocValue) => { @@ -35,22 +34,6 @@ export class LoyaltyProgram extends Doc { }, }; - async afterSubmit() { - const maximumUse = (this.maximumUse as number) || 0; - const used = (this.used as number) || 0; - - if (maximumUse > 0 && used >= maximumUse) { - await this.setAndSync({ status: 'Maxed', isEnabled: false }); - } - } - - static filters: FiltersMap = { - expenseAccount: () => ({ - rootType: AccountRootTypeEnum.Expense, - isGroup: false, - }), - }; - static getListViewSettings(): ListViewSettings { return { columns: ['name', getLoyaltyProgramStatusColumn(), 'fromDate', 'toDate'], diff --git a/models/baseModels/Party/Party.ts b/models/baseModels/Party/Party.ts index bba7181f..e277bd0d 100644 --- a/models/baseModels/Party/Party.ts +++ b/models/baseModels/Party/Party.ts @@ -14,6 +14,7 @@ import { import { Money } from 'pesa'; import { PartyRole } from './types'; import { ModelNameEnum } from 'models/types'; +import { isLoyaltyProgramExpiredAndMaxed } from 'models/helpers'; export class Party extends Doc { role?: PartyRole; @@ -66,6 +67,17 @@ export class Party extends Doc { } async _getTotalLoyaltyPoints() { + const loyaltyProgramName = this.loyaltyProgram as string; + if (loyaltyProgramName) { + const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed( + this.fyo, + loyaltyProgramName + ); + if (isExpiredAndMaxed) { + return 0; + } + } + const data = (await this.fyo.db.getAll(ModelNameEnum.LoyaltyPointEntry, { fields: ['name', 'loyaltyPoints', 'expiryDate', 'postingDate'], filters: { diff --git a/models/helpers.ts b/models/helpers.ts index f0a21bc8..467940c2 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -765,13 +765,6 @@ export function getLoyaltyProgramStatus(doc?: RenderData | Doc): string { return ''; } - const maximumUse = doc.maximumUse as number; - const used = doc.used as number; - - if (maximumUse > 0 && used >= maximumUse) { - return 'Maxed'; - } - const currentDate = new Date(); currentDate.setHours(0, 0, 0, 0); @@ -781,6 +774,13 @@ export function getLoyaltyProgramStatus(doc?: RenderData | Doc): string { return 'Expired'; } + const maximumUse = doc.maximumUse as number; + const used = doc.used as number; + + if (maximumUse > 0 && used >= maximumUse) { + return 'Maxed'; + } + return 'Active'; } @@ -1664,33 +1664,6 @@ export async function validateLoyaltyProgram( } } -export async function isLoyaltyProgramMaxedOut( - fyo: Fyo, - loyaltyProgramName: string -): Promise { - 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; @@ -1765,3 +1738,36 @@ export function roundFreeItemQty( ): number { return Math[roundingMethod](quantity); } + +export async function isLoyaltyProgramExpiredAndMaxed( + fyo: Fyo, + loyaltyProgramName: string +): Promise { + if (!loyaltyProgramName) { + return false; + } + + const loyaltyProgram = await fyo.db.getAll(ModelNameEnum.LoyaltyProgram, { + fields: ['toDate', 'maximumUse', 'used', 'isEnabled'], + filters: { name: loyaltyProgramName }, + }); + + if (!loyaltyProgram.length) { + return false; + } + + const program = loyaltyProgram[0]; + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + + const toDate = program.toDate as Date; + const isExpired = + toDate && new Date(toDate).getTime() < currentDate.getTime(); + + const maximumUse = (program.maximumUse as number) || 0; + const used = (program.used as number) || 0; + const isMaxed = maximumUse > 0 && used >= maximumUse; + + const result = isExpired || isMaxed; + return result; +} diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json index f372a543..fcdb4645 100644 --- a/schemas/app/SalesInvoice.json +++ b/schemas/app/SalesInvoice.json @@ -82,7 +82,7 @@ "fieldtype": "Link", "target": "LoyaltyProgram", "label": "Loyalty Program", - "section": "References", + "section": "Loyalty Points Redemption", "readOnly": true }, { @@ -90,7 +90,7 @@ "fieldtype": "Int", "label": "Available Loyalty Points", "readOnly": true, - "section": "References" + "section": "Loyalty Points Redemption" }, { "fieldname": "redeemLoyaltyPoints", diff --git a/src/components/StatusPill.vue b/src/components/StatusPill.vue index 358d9f4e..71baed7d 100644 --- a/src/components/StatusPill.vue +++ b/src/components/StatusPill.vue @@ -79,8 +79,8 @@ export default defineComponent({ Unpaid: this.t`Unpaid`, PartlyPaid: this.t`Partly Paid`, Expired: this.t`Expired`, - Maxed: this.t`Maxed`, Active: this.t`Active`, + Maxed: this.t`Maxed`, }[this.status]; }, color(): UIColors { @@ -104,8 +104,8 @@ const statusColorMap: Record = { Unpaid: 'red', PartlyPaid: 'yellow', Expired: 'red', - Maxed: 'orange', Active: 'green', + Maxed: 'orange', }; function getStatus(doc: Doc) { diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index 2bb2c11d..d854be35 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -166,7 +166,7 @@ import { removeFreeItems, getItemRateFromPriceList, getItemVisibility, - isLoyaltyProgramMaxedOut, + isLoyaltyProgramExpiredAndMaxed, } from 'models/helpers'; import { POSItem, @@ -499,12 +499,12 @@ export default defineComponent({ const loyaltyProgramName = party[0]?.loyaltyProgram as string; if (loyaltyProgramName) { - const isMaxedOut = await isLoyaltyProgramMaxedOut( + const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed( this.fyo, loyaltyProgramName ); - if (isMaxedOut) { - this.loyaltyProgram = ''; + if (isExpiredAndMaxed) { + this.loyaltyProgram = loyaltyProgramName; this.loyaltyPoints = 0; return; } From 4b4ae969b90598418ff9da665a7b1b5e1294aad5 Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Wed, 4 Feb 2026 16:07:47 +0530 Subject: [PATCH 15/21] fix: display availableQty according to item visibility --- src/components/POS/Classic/ItemsGrid.vue | 8 ++++---- src/components/POS/Classic/ItemsTable.vue | 12 ++++++------ src/components/POS/Modern/ModernPOSItemsGrid.vue | 8 ++++---- src/components/POS/Modern/ModernPOSItemsTable.vue | 14 +++++++------- src/pages/POS/ClassicPOS.vue | 10 +++++----- src/pages/POS/ModernPOS.vue | 10 +++++----- src/pages/POS/POS.vue | 13 +++++++------ 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/components/POS/Classic/ItemsGrid.vue b/src/components/POS/Classic/ItemsGrid.vue index 0081f134..8847e941 100644 --- a/src/components/POS/Classic/ItemsGrid.vue +++ b/src/components/POS/Classic/ItemsGrid.vue @@ -63,7 +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 @@ -

+
+
-
+
, + default: undefined, + }, }, - emits: ['applyPricingRule', 'selectedRow'], + emits: ['applyPricingRule', 'selectedRow', 'setExpandedBatchId'], computed: { ratio() { return [0.1, 0.9, 0.8, 0.8, 0.8, 0.8, 0.2]; diff --git a/src/components/POS/Modern/ModernPOSSelectedItemRow.vue b/src/components/POS/Modern/ModernPOSSelectedItemRow.vue index 5f5b2c47..dd24a726 100644 --- a/src/components/POS/Modern/ModernPOSSelectedItemRow.vue +++ b/src/components/POS/Modern/ModernPOSSelectedItemRow.vue @@ -3,11 +3,11 @@
-
+
, + default: undefined, + }, }, - emits: ['toggleModal', 'selectedRow', 'applyPricingRule'], + emits: [ + 'toggleModal', + 'selectedRow', + 'applyPricingRule', + 'setExpandedBatchId', + ], computed: { ratio() { return [0.1, 0.8, 0.4, 0.8, 0.8, 0.3]; diff --git a/src/pages/POS/ClassicPOS.vue b/src/pages/POS/ClassicPOS.vue index de59942f..22c14510 100644 --- a/src/pages/POS/ClassicPOS.vue +++ b/src/pages/POS/ClassicPOS.vue @@ -184,6 +184,10 @@ /> @@ -496,8 +500,13 @@ export default defineComponent({ type: String, default: '', }, + expandedBatchId: { + type: String as PropType, + default: undefined, + }, }, emits: [ + 'setExpandedBatchId', 'addItem', 'toggleView', 'toggleModal', diff --git a/src/pages/POS/ModernPOS.vue b/src/pages/POS/ModernPOS.vue index 7d278ee8..ba52e67d 100644 --- a/src/pages/POS/ModernPOS.vue +++ b/src/pages/POS/ModernPOS.vue @@ -119,6 +119,10 @@ /> , + default: undefined, + }, }, emits: [ + 'setExpandedBatchId', 'addItem', 'toggleView', 'toggleModal', diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index cdd6b032..241e798d 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -43,6 +43,8 @@ :open-return-sales-invoice-modal="openReturnSalesInvoiceModal" :open-batch-selection-modal="openBatchSelectionModal" :selected-item-for-batch="selectedItemForBatch" + :expanded-batch-id="expandedBatchId" + @set-expanded-batch-id="setExpandedBatchId" @add-item="addItem" @toggle-view="toggleView" @set-sinv-doc="setSinvDoc" @@ -100,6 +102,8 @@ :open-return-sales-invoice-modal="openReturnSalesInvoiceModal" :open-batch-selection-modal="openBatchSelectionModal" :selected-item-for-batch="selectedItemForBatch" + :expanded-batch-id="expandedBatchId" + @set-expanded-batch-id="setExpandedBatchId" @add-item="addItem" @toggle-view="toggleView" @set-sinv-doc="setSinvDoc" @@ -262,6 +266,7 @@ export default defineComponent({ quickQtyKeyUpHandler: null as ((e: KeyboardEvent) => void) | null, selectedItemForBatch: '' as string, pendingBatchItem: null as { item: POSItem; quantity: number } | null, + expandedBatchId: undefined as string | null | undefined, }; }, computed: { @@ -317,6 +322,9 @@ export default defineComponent({ setQuickQtySelectedRow(row: SalesInvoiceItem) { this.quickQtyRow = row; }, + setExpandedBatchId(rowName: string | null) { + this.expandedBatchId = rowName; + }, addQuickQtyListeners() { this.quickQtyKeyDownHandler = (e: KeyboardEvent) => this.onQuickQtyKeyDown(e); 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 @@

-
+
-
+