diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 14965ceb..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 */ @@ -920,6 +921,25 @@ export class Doc extends Observable { return this; } + async _hasERPSyncableItems(): Promise { + const isSalesInvoice = this.schemaName === ModelNameEnum.SalesInvoice; + if (!isSalesInvoice) { + return true; + } + 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 true; + } async sync(): Promise { this._syncing = true; @@ -936,10 +956,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/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/jobs/checkLoyaltyProgramExpiry.ts b/jobs/checkLoyaltyProgramExpiry.ts new file mode 100644 index 00000000..ab5eb70c --- /dev/null +++ b/jobs/checkLoyaltyProgramExpiry.ts @@ -0,0 +1,45 @@ +import { DatabaseManager } from '../backend/database/manager'; +import { ModelNameEnum } from '../models/types'; + +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', 'maximumUse', 'used'], + filters: { + status: ['not in', ['Expired']], + isEnabled: true, + }, + }); + + if (loyaltyPrograms) { + for (const program of loyaltyPrograms) { + if (program.toDate && new Date(String(program.toDate)) <= currentDate) { + await dm.db?.knex!(ModelNameEnum.LoyaltyProgram) + .where({ name: program.name }) + .update({ + status: 'Expired', + isEnabled: false, + }); + } + } + } + + const result = { + timestamp: currentDate.toISOString(), + }; + + return result; + } catch (error) { + throw error; + } finally { + await dm.call('close'); + } +} + +checkLoyaltyProgramExpiry().catch((error) => { + throw error; +}); 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..f91c176f 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -28,6 +28,9 @@ import { getReturnLoyaltyPoints, getItemQtyMap, getItemVisibility, + validateLoyaltyProgram, + getLoyaltyProgramTier, + isLoyaltyProgramExpiredAndMaxed, } from 'models/helpers'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { validateBatch } from 'models/inventory/helpers'; @@ -196,6 +199,23 @@ export abstract class Invoice extends Transactional { if (this.isQuote) { return; } + if (!this.submitted && this.loyaltyProgram) { + const isExpiredOrMaxed = await isLoyaltyProgramExpiredAndMaxed( + this.fyo, + this.loyaltyProgram + ); + + if (isExpiredOrMaxed) { + const { showToast } = await import('src/utils/interactive'); + + showToast({ + type: 'warning', + message: t`Loyalty program has expired or reached maximum usage`, + duration: 'short', + }); + } + } + if ( this.enableDiscounting && !this.fyo.singles?.AccountingSettings?.discountAccount @@ -212,13 +232,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 +331,10 @@ export abstract class Invoice extends Transactional { if (this.schemaName === ModelNameEnum.SalesInvoice) { this.updateUsedCountOfCoupons(); } + + if (this.loyaltyProgram) { + await this.updateUsedCountOfLoyaltyProgram(); + } } async afterCancel() { @@ -284,6 +344,10 @@ export abstract class Invoice extends Transactional { await this._updateIsItemsReturned(); await this._removeLoyaltyPointEntry(); this.reduceUsedCountOfCoupons(); + + if (this.loyaltyProgram) { + await this.reduceUsedCountOfLoyaltyProgram(); + } } async _removeLoyaltyPointEntry() { @@ -712,17 +776,26 @@ export abstract class Invoice extends Transactional { if (item.batch) { const returnData = totalQtyOfReturnedItems[item.item as string]; if (typeof returnData === 'object' && returnData?.batches) { - returnDocItems = docItems.map((docItem) => ({ - ...docItem, - name: undefined, - quantity: -returnData?.batches![docItem.batch as string] || 0, - })); + returnDocItems = docItems.map((docItem: DocValueMap) => { + const qty = -returnData?.batches![docItem.batch as string] || 0; + const transferQty = + qty / ((docItem.unitConversionFactor as number) || 1); + return { + ...docItem, + name: undefined, + quantity: qty, + transferQuantity: transferQty, + }; + }); } } else { - returnDocItems = docItems.map((docItem) => ({ + returnDocItems = docItems.map((docItem: DocValueMap) => ({ ...docItem, name: undefined, quantity: -(totalQtyOfReturnedItems[docItem.item as string] || 0), + qty: + -(totalQtyOfReturnedItems[docItem.item as string] as number) / + (item.unitConversionFactor as number), transferQuantity: -( (totalQtyOfReturnedItems[docItem.item as string] as number) / (item.unitConversionFactor as number) @@ -788,6 +861,7 @@ export abstract class Invoice extends Transactional { serialNumber, name: undefined, quantity: quantity, + qty: transferQuantity, transferQuantity, }); } @@ -838,6 +912,63 @@ export abstract class Invoice extends Transactional { }); } + async updateUsedCountOfLoyaltyProgram() { + if (!this.loyaltyProgram) { + return; + } + + const loyaltyProgramDoc = await this.fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + this.loyaltyProgram + ); + + 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) { + return; + } + + const loyaltyProgramDoc = await this.fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + this.loyaltyProgram + ); + + 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) { if (!doc?.returnAgainst || doc.schemaName !== ModelNameEnum.SalesInvoice) { return; @@ -898,11 +1029,27 @@ export abstract class Invoice extends Transactional { this.loyaltyProgram )) as LoyaltyProgram; - const expiryDate = this.date as Date; + 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); @@ -989,7 +1136,13 @@ export abstract class Invoice extends Transactional { ModelNameEnum.Party, this.party ); - return partyDoc?.loyaltyProgram as string; + const loyaltyProgramName = partyDoc?.loyaltyProgram as string; + + if (!loyaltyProgramName) { + return ''; + } + + return loyaltyProgramName; }, dependsOn: ['party', 'name'], }, @@ -999,6 +1152,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, @@ -1006,7 +1170,7 @@ export abstract class Invoice extends Transactional { ); return loyaltyPoints || 0; }, - dependsOn: ['party'], + dependsOn: ['party', 'loyaltyProgram'], }, currency: { formula: async () => { @@ -1199,7 +1363,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/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index 590702d2..95b58790 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, @@ -19,8 +20,20 @@ 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 { getSuggestedBatchName } from 'models/inventory/helpers'; +import { ValuationMethod } from 'models/inventory/types'; +import { + getRawStockLedgerEntries, + getStockLedgerEntries, + getStockBalanceEntries, +} from 'reports/inventory/helpers'; +import { QueryFilter } from 'utils/db/types'; export abstract class InvoiceItem extends Doc { item?: string; @@ -107,6 +120,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; @@ -606,15 +640,115 @@ 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 { + let inventoryLocation: string | undefined; + + 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 { + 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) { + throw new ValidationError( + this.fyo.t` + Batch ${batchName} only has ${availableQuantity} quantity available + but ${requiredQuantity} is required + ` + ); + } + } + hidden: HiddenMap = { itemDiscountedTotal: () => { if (!this.enableDiscounting) { @@ -646,24 +780,121 @@ 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 === '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; + } + } + + return filters; }, 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', []] }; + } + + let suggestedBatch: string | undefined; + + if (!doc.isSales) { + suggestedBatch = await getSuggestedBatchName( + doc.fyo, + doc.item as string + ); + + if (suggestedBatch) { + await doc.set('batch', suggestedBatch); + } + } + + 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); + + const allBatches = new Set(batchesWithStock); + if (suggestedBatch) { + allBatches.add(suggestedBatch); + } + + const finalBatchList = Array.from(allBatches); + + return { + name: ['in', finalBatchList], + }; + } catch (error) { + const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, { + fields: ['name'], + filters: { item: doc.item 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', finalBatchList], + }; + } }, 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..a9e8c518 100644 --- a/models/baseModels/Item/Item.ts +++ b/models/baseModels/Item/Item.ts @@ -26,10 +26,12 @@ export class Item extends Doc { itemType?: 'Product' | 'Service'; for?: 'Purchases' | 'Sales' | 'Both'; hasBatch?: boolean; + batchSeries?: string; itemGroup?: string; hsnCode?: number; hasSerialNumber?: boolean; serialNumberSeries?: string; + datafromErp?: boolean; uomConversions: UOMConversionItem[] = []; formulas: FormulaMap = { @@ -100,6 +102,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 +134,27 @@ 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(); + } + } } static filters: FiltersMap = { @@ -173,6 +203,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 +271,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/LoyaltyProgram/LoyaltyProgram.ts b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts index 89f73399..20d8db45 100644 --- a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts +++ b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts @@ -1,22 +1,42 @@ +import { DocValue } from 'fyo/core/types'; import { Doc } from 'fyo/model/doc'; -import { FiltersMap, ListViewSettings } 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 { collectionRules?: CollectionRulesItems[]; expiryDuration?: number; + maximumUse?: number; + used?: number; + status?: 'Active' | 'Expired' | 'Disabled' | 'Maxed'; - static filters: FiltersMap = { - expenseAccount: () => ({ - rootType: AccountRootTypeEnum.Expense, - isGroup: false, - }), + 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 getListViewSettings(): ListViewSettings { return { - columns: ['name', 'fromDate', 'toDate', 'expiryDuration'], + 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/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/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/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts index a2ae5e7e..9d4014d7 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,8 @@ export class SalesInvoice extends Invoice { await posting.debit( loyaltyProgramDoc.expenseAccount as string, - totalAmount + loyaltyAmount ); - - await posting.credit(this.account!, totalAmount); } if (this.taxes) { @@ -115,6 +113,13 @@ 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()) { + return; + } 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..aaa3fcbc 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, @@ -116,6 +119,11 @@ 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) { + return fyo.singles.POSSettings?.itemVisibilityERP as ItemVisibility; + } if (posProfileName) { const posProfile = await fyo.doc.getDoc( @@ -738,6 +746,74 @@ 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'; + } + + const maximumUse = doc.maximumUse as number; + const used = doc.used as number; + + if (maximumUse > 0 && used >= maximumUse) { + return 'Maxed'; + } + + return 'Active'; +} + +export const loyaltyProgramStatusColor: Record = { + Active: 'green', + Disabled: 'gray', + Expired: 'red', + Maxed: 'orange', +}; + +export function getLoyaltyProgramStatusText(status: string): string { + switch (status) { + case 'Active': + return t`Active`; + case 'Disabled': + return t`Disabled`; + case 'Expired': + return t`Expired`; + case 'Maxed': + return t`Maxed`; + default: + return ''; + } +} + type ModelsWithItems = Invoice | StockTransfer | StockMovement; export async function addItem(name: string, doc: M) { if (!doc.canEdit) { @@ -761,6 +837,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 @@ -883,6 +966,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 +1043,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 +1633,43 @@ 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]?.maximumUse as number) > 0 && + (loyaltyProgram[0]?.used as number) >= + (loyaltyProgram[0]?.maximumUse as number) + ) { + return; + } + + 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); + + if (normalizedToDate.getTime() < today.getTime()) { + return; + } + } +} + export function removeFreeItems(sinvDoc: SalesInvoice) { if (!sinvDoc || !sinvDoc.items) { return; @@ -1611,3 +1744,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/models/inventory/Point of Sale/POSSettings.ts b/models/inventory/Point of Sale/POSSettings.ts index 5fbcc9e6..3060b290 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,9 @@ export class POSSettings extends Doc { !this.fyo.singles.InventorySettings?.enableBarcodes || !this.weightEnabledBarcode, itemVisibility: () => - !this.fyo.singles.AccountingSettings?.enablePointOfSaleWithOutInventory, + !this.fyo.singles.AccountingSettings?.enablePointOfSaleWithOutInventory || + !!this.fyo.singles.AccountingSettings?.enableERPNextSync, + itemVisibilityERP: () => + !this.fyo.singles.AccountingSettings?.enableERPNextSync, }; } 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 4bed96a5..db2078b2 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,35 @@ export async function validateBatch( if (doc.schemaName === ModelNameEnum.SalesQuote) { return; } + + if ( + doc.schemaName === ModelNameEnum.PurchaseInvoice || + doc.schemaName === ModelNameEnum.PurchaseReceipt || + doc.schemaName === ModelNameEnum.StockMovement || + doc.schemaName === ModelNameEnum.Shipment + ) { + 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 +561,137 @@ 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 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; + // 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 (highestNumber >= 0) { + nextNumber = highestNumber + 1; + } else { + nextNumber = (batchSeriesDoc.start as number) ?? 1001; + } + } else { + nextNumber = (batchSeriesDoc.start as number) ?? 1001; + } + + const batchName = `${seriesName}${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 num = parseInt(batchName, 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/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/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..34105c8b 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", @@ -172,6 +178,14 @@ "fieldtype": "Table", "target": "UOMConversionItem", "section": "Inventory" + }, + { + "fieldname": "datafromErp", + "fieldtype": "Check", + "hidden": true, + "default": false, + "section": "Default", + "readOnly": true } ], "quickEditFields": [ diff --git a/schemas/app/LoyaltyProgram.json b/schemas/app/LoyaltyProgram.json index 5c5c1c71..608f97f9 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,21 @@ "fieldtype": "Link", "target": "Account", "required": true + }, + { + "fieldname": "maximumUse", + "label": "Maximum Use", + "fieldtype": "Int", + "default": 0, + "section": "Validity and Usage" + }, + { + "fieldname": "used", + "label": "Used", + "fieldtype": "Int", + "default": 0, + "readOnly": true, + "section": "Validity and Usage" } ], "quickEditFields": [ @@ -67,7 +82,8 @@ "toDate", "conversionFactor", "expenseAccount", - "expiryDuration" + "maximumUse", + "used" ], "keywordFields": ["name"] } 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/schemas/app/inventory/Point of Sale/POSSettings.json b/schemas/app/inventory/Point of Sale/POSSettings.json index 0d2887bb..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,27 @@ } ], "default": "Inventory Items", - "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", "section": "Default" }, { 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, diff --git a/src/components/POS/Classic/ItemsGrid.vue b/src/components/POS/Classic/ItemsGrid.vue index 08c289a2..8847e941 100644 --- a/src/components/POS/Classic/ItemsGrid.vue +++ b/src/components/POS/Classic/ItemsGrid.vue @@ -63,6 +63,7 @@

@@ -81,13 +81,20 @@ export default defineComponent({ props: { items: Array, itemQtyMap: Object, + itemVisibility: { + type: String, + default: 'Inventory Items', + }, }, computed: { ratio() { + if (this.itemVisibility === 'ERP Sync Items') { + return [1, 1.5, 0.8]; + } return [1, 1, 1, 0.7]; }, tableFields() { - return [ + const fields = [ { fieldname: 'name', fieldtype: 'Data', @@ -102,13 +109,6 @@ export default defineComponent({ fieldtype: 'Currency', readOnly: true, }, - { - fieldname: 'availableQty', - label: 'Qty', - placeholder: 'Available Qty', - fieldtype: 'Float', - readOnly: true, - }, { fieldname: 'unit', label: 'Unit', @@ -118,6 +118,18 @@ export default defineComponent({ readOnly: true, }, ] as Field[]; + + if (this.itemVisibility !== 'ERP Sync Items') { + fields.splice(2, 0, { + fieldname: 'availableQty', + label: 'Qty', + placeholder: 'Available Qty', + fieldtype: 'Float', + readOnly: true, + }); + } + + return fields; }, }, methods: { diff --git a/src/components/POS/Classic/SelectedItemRow.vue b/src/components/POS/Classic/SelectedItemRow.vue index f3b37aad..650a0f87 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/ModernPOSItemsGrid.vue b/src/components/POS/Modern/ModernPOSItemsGrid.vue index 0c06aed3..9df82aad 100644 --- a/src/components/POS/Modern/ModernPOSItemsGrid.vue +++ b/src/components/POS/Modern/ModernPOSItemsGrid.vue @@ -66,6 +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,13 +137,20 @@ export default defineComponent({ props: { items: Array, itemQtyMap: Object, + itemVisibility: { + type: String, + default: 'Inventory Items', + }, }, computed: { ratio() { - return [1, 1, 0.6, 0.7]; + if (this.itemVisibility === 'ERP Sync Items') { + return [1, 1.5, 0.8]; + } + return [1, 1, 1, 0.7]; }, tableFields() { - return [ + const fields = [ { fieldname: 'name', fieldtype: 'Data', @@ -158,13 +165,6 @@ export default defineComponent({ fieldtype: 'Currency', readOnly: true, }, - { - fieldname: 'availableQty', - label: t`Qty`, - placeholder: 'Available Qty', - fieldtype: 'Float', - readOnly: true, - }, { fieldname: 'unit', label: t`Unit`, @@ -174,6 +174,18 @@ export default defineComponent({ readOnly: true, }, ] as Field[]; + + if (this.itemVisibility !== 'ERP Sync Items') { + fields.splice(2, 0, { + fieldname: 'availableQty', + label: t`Qty`, + placeholder: 'Available Qty', + fieldtype: 'Float', + readOnly: true, + }); + } + + return fields; }, firstColumnItems() { return this.items?.slice(0, Math.ceil(this.items.length / 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/components/POS/types.ts b/src/components/POS/types.ts index 21023acb..cc49e511 100644 --- a/src/components/POS/types.ts +++ b/src/components/POS/types.ts @@ -10,7 +10,7 @@ export type ItemGroupMap = Record; export type DiscountType = 'percent' | 'amount'; -export type ItemVisibility = 'Inventory Items' | 'Non-Inventory Items' +export type ItemVisibility = 'Inventory Items' | 'Non-Inventory Items' | 'ERP Sync Items' export const modalNames = [ 'Keyboard', diff --git a/src/components/StatusPill.vue b/src/components/StatusPill.vue index f2d1743a..71baed7d 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,9 @@ export default defineComponent({ ReturnIssued: this.t`Return Issued`, Unpaid: this.t`Unpaid`, PartlyPaid: this.t`Partly Paid`, + Expired: this.t`Expired`, + Active: this.t`Active`, + Maxed: this.t`Maxed`, }[this.status]; }, color(): UIColors { @@ -99,6 +103,9 @@ const statusColorMap: Record = { ReturnIssued: 'gray', Unpaid: 'red', PartlyPaid: 'yellow', + Expired: 'red', + Active: 'green', + Maxed: 'orange', }; function getStatus(doc: Doc) { @@ -110,6 +117,27 @@ function getStatus(doc: Doc) { return 'NotSaved'; } + if (doc instanceof LoyaltyProgram) { + const currentDate = new Date(); + 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'; + } + + 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) { return 'Outstanding'; } diff --git a/src/pages/POS/ClassicPOS.vue b/src/pages/POS/ClassicPOS.vue index de59942f..c471c8f3 100644 --- a/src/pages/POS/ClassicPOS.vue +++ b/src/pages/POS/ClassicPOS.vue @@ -134,6 +134,7 @@ v-if="tableView" :items="items" :item-qty-map="itemQuantityMap as ItemQtyMap" + :item-visibility="itemVisibility" @add-item="(item) => emitEvent('addItem', item)" /> @@ -141,6 +142,7 @@ v-else :items="items" :item-qty-map="itemQuantityMap as ItemQtyMap" + :item-visibility="itemVisibility" @add-item="(item) => emitEvent('addItem', item)" /> @@ -184,6 +186,10 @@ /> @@ -483,6 +489,10 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + itemVisibility: { + type: String, + default: 'Inventory Items', + }, profile: { type: Object as PropType, required: false, @@ -496,8 +506,13 @@ export default defineComponent({ type: String, default: '', }, + expandedBatchId: { + type: String as PropType, + default: undefined, + }, }, emits: [ + 'setExpandedBatchId', 'addItem', 'toggleView', 'toggleModal', diff --git a/src/pages/POS/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); diff --git a/src/pages/POS/ModernPOS.vue b/src/pages/POS/ModernPOS.vue index 7d278ee8..34355469 100644 --- a/src/pages/POS/ModernPOS.vue +++ b/src/pages/POS/ModernPOS.vue @@ -119,6 +119,10 @@ /> @@ -352,6 +357,7 @@ v-else :items="items" :item-qty-map="itemQuantityMap as ItemQtyMap" + :item-visibility="itemVisibility" @add-item="(item:string) => emitEvent('addItem', item)" /> @@ -489,6 +495,10 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + itemVisibility: { + type: String, + default: 'Inventory Items', + }, profile: { type: Object as PropType, required: false, @@ -502,8 +512,13 @@ export default defineComponent({ type: String, default: '', }, + expandedBatchId: { + type: String as PropType, + default: undefined, + }, }, emits: [ + 'setExpandedBatchId', 'addItem', 'toggleView', 'toggleModal', diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index cdd6b032..66724655 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[])" + :item-visibility="itemVisibility" :sinv-doc="(sinvDoc as SalesInvoice)" :disable-pay-button="disablePayButton" :open-payment-modal="openPaymentModal" @@ -43,6 +44,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" @@ -83,6 +86,7 @@ :selected-item-group="selectedItemGroup" :is-pos-shift-open="isPosShiftOpen" :items="(items as [] as POSItem[])" + :item-visibility="itemVisibility" :sinv-doc="(sinvDoc as SalesInvoice)" :disable-pay-button="disablePayButton" :open-payment-modal="openPaymentModal" @@ -100,6 +104,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" @@ -166,7 +172,9 @@ import { removeFreeItems, getItemRateFromPriceList, getItemVisibility, + isLoyaltyProgramExpiredAndMaxed, } from 'models/helpers'; +import { ItemVisibility } from 'src/components/POS/types'; import { POSItem, ItemQtyMap, @@ -262,6 +270,8 @@ 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, + itemVisibilityValue: 'Inventory Items' as ItemVisibility, }; }, computed: { @@ -271,6 +281,9 @@ export default defineComponent({ return !!fyo.singles.AccountingSettings?.enableDiscounting; }, isPosShiftOpen: () => !!fyo.singles.POSSettings?.isShiftOpen, + itemVisibility() { + return this.itemVisibilityValue; + }, disablePayButton(): boolean { if (!this.sinvDoc.items?.length || !this.sinvDoc.party) { return true; @@ -295,6 +308,7 @@ export default defineComponent({ async mounted() { await this.setItems(); await this.loadPOSProfile(); + this.itemVisibilityValue = await getItemVisibility(this.fyo); }, async activated() { toggleSidebar(false); @@ -317,6 +331,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); @@ -495,7 +512,21 @@ export default defineComponent({ filters: { name: value }, }); - this.loyaltyProgram = party[0]?.loyaltyProgram as string; + const loyaltyProgramName = party[0]?.loyaltyProgram as string; + + if (loyaltyProgramName) { + const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed( + this.fyo, + loyaltyProgramName + ); + if (isExpiredAndMaxed) { + this.loyaltyProgram = loyaltyProgramName; + this.loyaltyPoints = 0; + return; + } + } + + this.loyaltyProgram = loyaltyProgramName; this.loyaltyPoints = party[0]?.loyaltyPoints as number; }, @@ -644,7 +675,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); } @@ -694,8 +726,11 @@ export default defineComponent({ if (itemVisibility === 'Inventory Items') { filters.trackItem = true; - } else { + } else if (itemVisibility === 'ERP Sync Items') { + filters.datafromErp = true; + } else if (itemVisibility === 'Non-Inventory Items') { filters.trackItem = false; + filters.datafromErp = false; } if (this.selectedItemGroup) { 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 @@