diff --git a/models/baseModels/AccountingSettings/AccountingSettings.ts b/models/baseModels/AccountingSettings/AccountingSettings.ts index 0c7a548c..8e32ad4e 100644 --- a/models/baseModels/AccountingSettings/AccountingSettings.ts +++ b/models/baseModels/AccountingSettings/AccountingSettings.ts @@ -18,6 +18,7 @@ export class AccountingSettings extends Doc { enableLead?: boolean; enableFormCustomization?: boolean; enableInvoiceReturns?: boolean; + enablePricingRule?: boolean; static filters: FiltersMap = { writeOffAccount: () => ({ @@ -60,6 +61,8 @@ export class AccountingSettings extends Doc { override hidden: HiddenMap = { discountAccount: () => !this.enableDiscounting, gstin: () => this.fyo.singles.SystemSettings?.countryCode !== 'in', + enablePricingRule: () => + !this.fyo.singles.AccountingSettings?.enableDiscounting, }; async change(ch: ChangeArg) { diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index e366c00f..adebfe69 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -11,7 +11,14 @@ import { import { DEFAULT_CURRENCY } from 'fyo/utils/consts'; import { ValidationError } from 'fyo/utils/errors'; import { Transactional } from 'models/Transactional/Transactional'; -import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers'; +import { + addItem, + canApplyPricingRule, + filterPricingRules, + getExchangeRate, + getNumberSeries, + getPricingRulesConflicts, +} from 'models/helpers'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { validateBatch } from 'models/inventory/helpers'; import { ModelNameEnum } from 'models/types'; @@ -27,6 +34,9 @@ import { Tax } from '../Tax/Tax'; import { TaxSummary } from '../TaxSummary/TaxSummary'; import { ReturnDocItem } from 'models/inventory/types'; import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types'; +import { PricingRule } from '../PricingRule/PricingRule'; +import { ApplicablePricingRules } from './types'; +import { PricingRuleDetail } from '../PricingRuleDetail/PricingRuleDetail'; export type TaxDetail = { account: string; @@ -70,6 +80,8 @@ export abstract class Invoice extends Transactional { isReturned?: boolean; returnAgainst?: string; + pricingRuleDetail?: PricingRuleDetail[]; + get isSales() { return ( this.schemaName === 'SalesInvoice' || this.schemaName == 'SalesQuote' @@ -625,6 +637,17 @@ export abstract class Invoice extends Transactional { !!this.autoStockTransferLocation, dependsOn: [], }, + isPricingRuleApplied: { + formula: async () => { + const pricingRule = await this.getPricingRule(); + if (pricingRule) { + await this.appendPricingRuleDetail(pricingRule); + } + + return !!pricingRule?.length; + }, + dependsOn: ['items'], + }, }; getStockTransferred() { @@ -917,6 +940,14 @@ export abstract class Invoice extends Transactional { return transfer; } + async beforeSync(): Promise { + await super.beforeSync(); + + if (this.pricingRuleDetail?.length) { + await this.applyProductDiscount(); + } + } + async beforeCancel(): Promise { await super.beforeCancel(); await this._validateStockTransferCancelled(); @@ -1045,4 +1076,171 @@ export abstract class Invoice extends Transactional { async addItem(name: string) { return await addItem(name, this); } + + async getPricingRule(): Promise { + if (!this.isSales || !this.items) { + return; + } + const pricingRules: ApplicablePricingRules[] = []; + + for (const item of this.items) { + if (item.isFreeItem) { + continue; + } + + const pricingRuleDocNames = ( + await this.fyo.db.getAll(ModelNameEnum.PricingRuleItem, { + fields: ['parent'], + filters: { + item: item.item as string, + unit: item.unit as string, + }, + }) + ).map((doc) => doc.parent) as string[]; + + const pricingRuleDocsForItem = (await this.fyo.db.getAll( + ModelNameEnum.PricingRule, + { + fields: ['*'], + filters: { + name: ['in', pricingRuleDocNames], + isEnabled: true, + }, + orderBy: 'priority', + order: 'desc', + } + )) as PricingRule[]; + + const filtered = filterPricingRules( + pricingRuleDocsForItem, + this.date as Date, + item.quantity as number, + item.amount as Money + ); + + if (!filtered.length) { + continue; + } + + const isPricingRuleHasConflicts = getPricingRulesConflicts( + filtered, + item.item as string + ); + + if (isPricingRuleHasConflicts) { + continue; + } + + pricingRules.push({ + applyOnItem: item.item as string, + pricingRule: filtered[0], + }); + } + + return pricingRules; + } + + async appendPricingRuleDetail( + applicablePricingRule: ApplicablePricingRules[] + ) { + await this.set('pricingRuleDetail', null); + + for (const doc of applicablePricingRule) { + await this.append('pricingRuleDetail', { + referenceName: doc.pricingRule.name, + referenceItem: doc.applyOnItem, + }); + } + } + + async applyPriceDiscount() { + if (!this.pricingRuleDetail || !this.items) { + return; + } + + for (const doc of this.pricingRuleDetail) { + const pricingRuleDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + doc.referenceName + )) as PricingRule; + + if (pricingRuleDoc.discountType === 'Product Discount') { + continue; + } + + const appliedItems = pricingRuleDoc.appliedItems?.map( + (itemDoc) => itemDoc.item + ); + + for (const item of this.items) { + if (!appliedItems?.includes(item.item)) { + continue; + } + + const canApplyPRLOnItem = canApplyPricingRule( + pricingRuleDoc, + this.date as Date, + item.quantity as number, + item.amount as Money + ); + + if (!canApplyPRLOnItem) { + continue; + } + } + } + } + + async applyProductDiscount() { + if (!this.pricingRuleDetail || !this.items) { + return; + } + + for (const doc of this.pricingRuleDetail) { + const pricingRuleDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + doc.referenceName + )) as PricingRule; + + if (pricingRuleDoc.discountType === 'Price Discount') { + continue; + } + + const appliedItems = pricingRuleDoc.appliedItems?.map( + (itemDoc) => itemDoc.item + ); + + for (const item of this.items) { + if (!appliedItems?.includes(item.item)) { + continue; + } + + const canApplyPRLOnItem = canApplyPricingRule( + pricingRuleDoc, + this.date as Date, + item.quantity as number, + item.amount as Money + ); + + if (!canApplyPRLOnItem) { + continue; + } + + let freeItemQty = pricingRuleDoc.freeItemQuantity as number; + + if (pricingRuleDoc.isRecursive) { + freeItemQty = + (item.quantity as number) / (pricingRuleDoc.recurseEvery as number); + } + + await this.append('items', { + item: pricingRuleDoc.freeItem as string, + quantity: freeItemQty, + isFreeItem: true, + rate: pricingRuleDoc.freeItemRate, + unit: pricingRuleDoc.freeItemUnit, + }); + } + } + } } diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index 6c9371a1..cad6b396 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -19,6 +19,9 @@ import { Item } from '../Item/Item'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { PriceList } from '../PriceList/PriceList'; import { isPesa } from 'fyo/utils'; +import { canApplyPricingRule } from 'models/helpers'; +import { PricingRule } from '../PricingRule/PricingRule'; +import { SalesInvoiceItem } from '../SalesInvoiceItem/SalesInvoiceItem'; export abstract class InvoiceItem extends Doc { item?: string; @@ -404,6 +407,42 @@ export abstract class InvoiceItem extends Doc { }, dependsOn: ['item', 'quantity'], }, + isPricingRuleAppliedItem: { + formula: async () => { + if (!this.parentdoc?.pricingRuleDetail) { + return false; + } + + for (const prleDoc of this.parentdoc.pricingRuleDetail) { + const pricingRuleDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + prleDoc.referenceName + )) as PricingRule; + + if ( + !canApplyPricingRule( + pricingRuleDoc, + this.parentdoc.date as Date, + this.quantity as number, + this.amount as Money + ) + ) { + continue; + } + + const appliedItems = pricingRuleDoc.appliedItems?.map( + (item) => item.item + ); + + if (!appliedItems?.includes(this.item)) { + continue; + } + + await applyPricingRuleOnItem(this, pricingRuleDoc); + } + }, + dependsOn: ['item', 'quantity'], + }, }; validations: ValidationMap = { @@ -734,3 +773,30 @@ function getRate( return null; } + +async function applyPricingRuleOnItem( + sinvItemDoc: SalesInvoiceItem, + pricingRuleDoc: PricingRule +) { + switch (pricingRuleDoc.priceDiscountType) { + case 'rate': + await sinvItemDoc.set('rate', pricingRuleDoc.discountRate); + return; + + case 'amount': + await sinvItemDoc.set('setItemDiscountAmount', true); + const discountAmount = pricingRuleDoc.discountAmount?.mul( + sinvItemDoc.quantity as number + ); + + await sinvItemDoc.set('itemDiscountAmount', discountAmount); + return; + + case 'percentage': + await sinvItemDoc.set( + 'itemDiscountPercent', + pricingRuleDoc.discountPercentage + ); + return; + } +} diff --git a/models/baseModels/PriceList/PriceList.ts b/models/baseModels/PriceList/PriceList.ts index 10070dc1..95a0c50a 100644 --- a/models/baseModels/PriceList/PriceList.ts +++ b/models/baseModels/PriceList/PriceList.ts @@ -2,7 +2,7 @@ import { Doc } from 'fyo/model/doc'; import { ListViewSettings } from 'fyo/model/types'; import { PriceListItem } from './PriceListItem'; import { - getPriceListEnabledColumn, + getIsDocEnabledColumn, getPriceListStatusColumn, } from 'models/helpers'; @@ -14,11 +14,7 @@ export class PriceList extends Doc { static getListViewSettings(): ListViewSettings { return { - columns: [ - 'name', - getPriceListEnabledColumn(), - getPriceListStatusColumn(), - ], + columns: ['name', getIsDocEnabledColumn(), getPriceListStatusColumn()], }; } } diff --git a/models/helpers.ts b/models/helpers.ts index 2c67256c..648c1a26 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -22,6 +22,8 @@ import { StockMovement } from './inventory/StockMovement'; import { StockTransfer } from './inventory/StockTransfer'; import { InvoiceStatus, ModelNameEnum } from './types'; import { Lead } from './baseModels/Lead/Lead'; +import { PricingRule } from './baseModels/PricingRule/PricingRule'; +import { showToast } from 'src/utils/interactive'; export function getQuoteActions( fyo: Fyo, @@ -517,7 +519,7 @@ export function getPriceListStatusColumn(): ColumnConfig { }; } -export function getPriceListEnabledColumn(): ColumnConfig { +export function getIsDocEnabledColumn(): ColumnConfig { return { label: t`Enabled`, fieldname: 'enabled', @@ -657,3 +659,105 @@ export async function addItem(name: string, doc: M) { await item.set('item', name); } + +export function filterPricingRules( + pricingRuleDocsForItem: PricingRule[], + sinvDate: Date, + quantity: number, + amount: Money +): PricingRule[] | [] { + const filteredPricingRules: PricingRule[] | undefined = []; + + for (const pricingRuleDoc of pricingRuleDocsForItem) { + if (canApplyPricingRule(pricingRuleDoc, sinvDate, quantity, amount)) { + filteredPricingRules.push(pricingRuleDoc); + } + } + return filteredPricingRules; +} + +export function canApplyPricingRule( + pricingRuleDoc: PricingRule, + sinvDate: Date, + quantity: number, + amount: Money +): boolean { + // Filter by Quantity + if ( + (pricingRuleDoc.minQuantity as number) > 0 && + quantity < (pricingRuleDoc.minQuantity as number) + ) { + return false; + } + + if ( + (pricingRuleDoc.maxQuantity as number) > 0 && + quantity > (pricingRuleDoc.maxQuantity as number) + ) { + return false; + } + + // Filter by Amount + if ( + !pricingRuleDoc.minAmount?.isZero() && + amount.lte(pricingRuleDoc.minAmount as Money) + ) { + return false; + } + + if ( + !pricingRuleDoc.maxAmount?.isZero() && + amount.gte(pricingRuleDoc.maxAmount as Money) + ) { + return false; + } + + // Filter by Validity + if ( + pricingRuleDoc.validFrom && + sinvDate.toISOString() < pricingRuleDoc.validFrom.toISOString() + ) { + return false; + } + if ( + pricingRuleDoc.validTo && + sinvDate.toISOString() > pricingRuleDoc.validTo.toISOString() + ) { + return false; + } + + return true; +} + +export function getPricingRulesConflicts( + pricingRules: PricingRule[], + item: string +) { + const pricingRuleDocs = Array.from(pricingRules); + + const firstPricingRule = pricingRuleDocs.shift(); + if (!firstPricingRule) { + return; + } + + const conflictingPricingRuleNames: string[] = []; + for (const pricingRuleDoc of pricingRuleDocs.slice(0)) { + if (pricingRuleDoc.priority === firstPricingRule?.priority) { + conflictingPricingRuleNames.push(pricingRuleDoc.name as string); + } + } + + if (!conflictingPricingRuleNames.length) { + return false; + } + + showToast({ + type: 'error', + message: t`Pricing Rules ${ + firstPricingRule.name as string + }, ${conflictingPricingRuleNames.join( + ', ' + )} has the same Priority for the Item ${item}.`, + }); + return true; +} diff --git a/models/index.ts b/models/index.ts index a5690a69..dd940dc9 100644 --- a/models/index.ts +++ b/models/index.ts @@ -14,6 +14,8 @@ import { Payment } from './baseModels/Payment/Payment'; import { PaymentFor } from './baseModels/PaymentFor/PaymentFor'; import { PriceList } from './baseModels/PriceList/PriceList'; import { PriceListItem } from './baseModels/PriceList/PriceListItem'; +import { PricingRule } from './baseModels/PricingRule/PricingRule'; +import { PricingRuleItem } from './baseModels/PricingRuleItem/PricingRuleItem'; import { PrintSettings } from './baseModels/PrintSettings/PrintSettings'; import { PrintTemplate } from './baseModels/PrintTemplate'; import { PurchaseInvoice } from './baseModels/PurchaseInvoice/PurchaseInvoice'; @@ -61,6 +63,8 @@ export const models = { PrintSettings, PriceList, PriceListItem, + PricingRule, + PricingRuleItem, PurchaseInvoice, PurchaseInvoiceItem, SalesInvoice, diff --git a/models/types.ts b/models/types.ts index fe591def..0199be15 100644 --- a/models/types.ts +++ b/models/types.ts @@ -22,6 +22,8 @@ export enum ModelNameEnum { Payment = 'Payment', PaymentFor = 'PaymentFor', PriceList = 'PriceList', + PricingRule = 'PricingRule', + PricingRuleItem = 'PricingRuleItem', PrintSettings = 'PrintSettings', PrintTemplate = 'PrintTemplate', PurchaseInvoice = 'PurchaseInvoice',