From e1524afb44a0dfabf3e52d6058c9d1f670bb6d9a Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 30 Jan 2024 18:24:01 +0530 Subject: [PATCH 01/19] feat: pricing rule schemas --- schemas/app/AccountingSettings.json | 7 + schemas/app/NumberSeries.json | 4 + schemas/app/PricingRule.json | 242 ++++++++++++++++++++++++++++ schemas/app/PricingRuleDetail.json | 23 +++ schemas/app/PricingRuleItem.json | 25 +++ schemas/app/SalesInvoice.json | 15 ++ schemas/app/SalesInvoiceItem.json | 16 +- 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 schemas/app/PricingRule.json create mode 100644 schemas/app/PricingRuleDetail.json create mode 100644 schemas/app/PricingRuleItem.json diff --git a/schemas/app/AccountingSettings.json b/schemas/app/AccountingSettings.json index 4360352b..eda1a88b 100644 --- a/schemas/app/AccountingSettings.json +++ b/schemas/app/AccountingSettings.json @@ -107,6 +107,13 @@ "default": false, "section": "Features" }, + { + "fieldname": "enablePricingRule", + "label": "Enable Pricing Rule", + "fieldtype": "Check", + "default": false, + "section": "Features" + }, { "fieldname": "fiscalYearStart", "label": "Fiscal Year Start Date", diff --git a/schemas/app/NumberSeries.json b/schemas/app/NumberSeries.json index 0526e587..ceb717e6 100644 --- a/schemas/app/NumberSeries.json +++ b/schemas/app/NumberSeries.json @@ -62,6 +62,10 @@ { "value": "PurchaseReceipt", "label": "Purchase Receipt" + }, + { + "value": "PricingRule", + "label": "Pricing Rule" } ], "default": "-", diff --git a/schemas/app/PricingRule.json b/schemas/app/PricingRule.json new file mode 100644 index 00000000..552e7cbe --- /dev/null +++ b/schemas/app/PricingRule.json @@ -0,0 +1,242 @@ +{ + "name": "PricingRule", + "label": "Pricing Rule", + "naming": "numberSeries", + "isSubmittable": false, + "fields": [ + { + "fieldname": "numberSeries", + "label": "Number Series", + "fieldtype": "Link", + "target": "NumberSeries", + "create": true, + "required": true, + "default": "PRLE-", + "section": "Default" + }, + { + "fieldname": "isEnabled", + "label": "Is Pricing Rule Enabled", + "fieldtype": "Check", + "default": true, + "section": "Default" + }, + { + "fieldname": "title", + "label": "Title", + "fieldtype": "Data", + "required": true, + "section": "Default" + }, + { + "fieldname": "appliedItems", + "label": "Applied Items", + "fieldtype": "Table", + "target": "PricingRuleItem", + "required": true, + "edit": true, + "section": "Items" + }, + { + "fieldname": "discountType", + "label": "Discount Type", + "fieldtype": "Select", + "required": true, + "section": "Default", + "options": [ + { + "value": "Price Discount", + "label": "Price Discount" + }, + { + "value": "Product Discount", + "label": "Product Discount" + } + ] + }, + { + "fieldname": "priceDiscountType", + "label": "Price Discount Type", + "fieldtype": "Select", + "section": "Price Discount Scheme", + "options": [ + { + "value": "rate", + "label": "Rate" + }, + { + "value": "percentage", + "label": "Discount Percentage" + }, + { + "value": "amount", + "label": "Discount Amount" + } + ] + }, + { + "fieldname": "discountRate", + "label": "Rate", + "fieldtype": "Currency", + "section": "Price Discount Scheme" + }, + { + "fieldname": "discountPercentage", + "label": "Discount Percentage", + "fieldtype": "Float", + "section": "Price Discount Scheme" + }, + { + "fieldname": "discountAmount", + "label": "Discount Amount", + "fieldtype": "Currency", + "section": "Price Discount Scheme" + }, + { + "fieldname": "forPriceList", + "label": "For Price List", + "fieldtype": "Link", + "target": "PriceList", + "section": "Price Discount Scheme" + }, + { + "fieldname": "freeItem", + "label": "Free Item", + "fieldtype": "Link", + "target": "Item", + "section": "Product Discount Scheme" + }, + { + "fieldname": "freeItemQuantity", + "label": "Quantity", + "fieldtype": "Float", + "section": "Product Discount Scheme" + }, + { + "fieldname": "freeItemUnit", + "label": "UOM", + "fieldtype": "Link", + "target": "UOM", + "section": "Product Discount Scheme" + }, + { + "fieldname": "freeItemRate", + "label": "Rate", + "fieldtype": "Currency", + "section": "Product Discount Scheme" + }, + { + "fieldname": "roundFreeItemQty", + "label": "Round Free Item Quantity", + "fieldtype": "Check", + "default": false, + "section": "Product Discount Scheme" + }, + { + "fieldname": "roundingMethod", + "label": "Rounding Method", + "fieldtype": "Select", + "required": true, + "default": "round", + "section": "Product Discount Scheme", + "options": [ + { + "value": "floor", + "label": "Floor" + }, + { + "value": "round", + "label": "Round" + }, + { + "value": "ceil", + "label": "Ceil" + } + ] + }, + { + "fieldname": "isRecursive", + "label": "Is Recursive", + "fieldtype": "Check", + "default": false, + "section": "Product Discount Scheme" + }, + { + "fieldname": "recurseEvery", + "label": "Recurse Every (As Per Transaction UOM)", + "fieldtype": "Float", + "section": "Product Discount Scheme" + }, + { + "fieldname": "minQuantity", + "label": "Min Qty (As Per Stock Unit)", + "fieldtype": "Float", + "section": "Quantity and Amount" + }, + { + "fieldname": "maxQuantity", + "label": "Max Qty (As Per Stock UOM)", + "fieldtype": "Float", + "section": "Quantity and Amount" + }, + { + "fieldname": "minAmount", + "label": "Min Amount", + "fieldtype": "Currency", + "section": "Quantity and Amount" + }, + { + "fieldname": "maxAmount", + "label": "Max Amount", + "fieldtype": "Currency", + "section": "Quantity and Amount" + }, + { + "fieldname": "validFrom", + "label": "Valid From", + "fieldtype": "Date", + "section": "Validity" + }, + { + "fieldname": "validTo", + "label": "Valid To", + "fieldtype": "Date", + "section": "Validity" + }, + { + "fieldname": "thresholdForSuggestion", + "label": "Threshold for Suggestion", + "fieldtype": "Float", + "section": "Threshold and Priority" + }, + { + "fieldname": "priority", + "label": "Priority", + "fieldtype": "Select", + "section": "Threshold and Priority", + "required": true, + "options": [ + { "value": "1", "label": 1 }, + { "value": "2", "label": 2 }, + { "value": "3", "label": 3 }, + { "value": "4", "label": 4 }, + { "value": "5", "label": 5 }, + { "value": "6", "label": 6 }, + { "value": "7", "label": 7 }, + { "value": "8", "label": 8 }, + { "value": "9", "label": 9 }, + { "value": "10", "label": 10 }, + { "value": "11", "label": 11 }, + { "value": "12", "label": 12 }, + { "value": "13", "label": 13 }, + { "value": "14", "label": 14 }, + { "value": "15", "label": 15 }, + { "value": "16", "label": 16 }, + { "value": "17", "label": 17 }, + { "value": "18", "label": 18 }, + { "value": "19", "label": 19 }, + { "value": "20", "label": 20 } + ] + } + ] +} diff --git a/schemas/app/PricingRuleDetail.json b/schemas/app/PricingRuleDetail.json new file mode 100644 index 00000000..febee44e --- /dev/null +++ b/schemas/app/PricingRuleDetail.json @@ -0,0 +1,23 @@ +{ + "name": "PricingRuleDetail", + "label": "Pricing Rule Detail", + "isSingle": false, + "isChild": true, + "fields": [ + { + "label": "Pricing Rule", + "fieldname": "referenceName", + "fieldtype": "Link", + "target": "PricingRule", + "readOnly": true + }, + { + "label": "Item", + "fieldname": "referenceItem", + "fieldtype": "Link", + "target": "Item", + "readOnly": true + } + ], + "tableFields": ["referenceName", "referenceItem"] +} diff --git a/schemas/app/PricingRuleItem.json b/schemas/app/PricingRuleItem.json new file mode 100644 index 00000000..6062bf92 --- /dev/null +++ b/schemas/app/PricingRuleItem.json @@ -0,0 +1,25 @@ +{ + "name": "PricingRuleItem", + "label": "Pricing Rule Item", + "isChild": true, + "fields": [ + { + "fieldname": "item", + "label": "Item", + "fieldtype": "Link", + "target": "Item", + "required": true + }, + { + "fieldname": "unit", + "label": "Unit Type", + "placeholder": "Unit Type", + "fieldtype": "Link", + "target": "UOM", + "create": true, + "section": "Default" + } + ], + "tableFields": ["item", "unit"], + "quickEditFields": ["item", "unit"] +} diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json index 84e0dfd0..9b8de0fc 100644 --- a/schemas/app/SalesInvoice.json +++ b/schemas/app/SalesInvoice.json @@ -75,6 +75,21 @@ "fieldtype": "Check", "default": false, "hidden": true + }, + { + "fieldname": "isPricingRuleApplied", + "fieldtype": "Check", + "default": false, + "hidden": true + }, + { + "fieldname": "pricingRuleDetail", + "fieldtype": "Table", + "label": "Pricing Rule Detail", + "target": "PricingRuleDetail", + "edit": false, + "readOnly": true, + "section": "References" } ], "keywordFields": ["name", "party"] diff --git a/schemas/app/SalesInvoiceItem.json b/schemas/app/SalesInvoiceItem.json index bff7b705..6e8e26d1 100644 --- a/schemas/app/SalesInvoiceItem.json +++ b/schemas/app/SalesInvoiceItem.json @@ -1,5 +1,19 @@ { "name": "SalesInvoiceItem", "label": "Sales Invoice Item", - "extends": "InvoiceItem" + "extends": "InvoiceItem", + "fields": [ + { + "fieldname": "isFreeItem", + "fieldtype": "Check", + "default": false, + "hidden": true + }, + { + "fieldname": "isPricingRuleAppliedItem", + "fieldtype": "Check", + "default": false, + "hidden": true + } + ] } From 6f3152187a77495485176fe02bc2abc2d756ec5e Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 30 Jan 2024 18:24:47 +0530 Subject: [PATCH 02/19] feat: register schemas for pricing rule --- schemas/schemas.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/schemas/schemas.ts b/schemas/schemas.ts index a551d529..822e1372 100644 --- a/schemas/schemas.ts +++ b/schemas/schemas.ts @@ -20,6 +20,9 @@ import Payment from './app/Payment.json'; import PaymentFor from './app/PaymentFor.json'; import PriceList from './app/PriceList.json'; import PriceListItem from './app/PriceListItem.json'; +import PricingRule from './app/PricingRule.json'; +import PricingRuleItem from './app/PricingRuleItem.json'; +import PricingRuleDetail from './app/PricingRuleDetail.json'; import PrintSettings from './app/PrintSettings.json'; import PrintTemplate from './app/PrintTemplate.json'; import PurchaseInvoice from './app/PurchaseInvoice.json'; @@ -122,6 +125,10 @@ export const appSchemas: Schema[] | SchemaStub[] = [ PriceList as Schema, PriceListItem as SchemaStub, + PricingRule as Schema, + PricingRuleItem as SchemaStub, + PricingRuleDetail as SchemaStub, + Tax as Schema, TaxDetail as Schema, TaxSummary as Schema, From 3346a733f3d1ddd55550764d8ecaab94fe61edc0 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 30 Jan 2024 18:25:16 +0530 Subject: [PATCH 03/19] feat: pricing rule models --- models/baseModels/PricingRule/PricingRule.ts | 96 +++++++++++++++++++ .../PricingRuleDetail/PricingRuleDetail.ts | 5 + .../PricingRuleItem/PricingRuleItem.ts | 19 ++++ 3 files changed, 120 insertions(+) create mode 100644 models/baseModels/PricingRule/PricingRule.ts create mode 100644 models/baseModels/PricingRuleDetail/PricingRuleDetail.ts create mode 100644 models/baseModels/PricingRuleItem/PricingRuleItem.ts diff --git a/models/baseModels/PricingRule/PricingRule.ts b/models/baseModels/PricingRule/PricingRule.ts new file mode 100644 index 00000000..4b8f15d4 --- /dev/null +++ b/models/baseModels/PricingRule/PricingRule.ts @@ -0,0 +1,96 @@ +import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; +import { PricingRuleItem } from '../PricingRuleItem/PricingRuleItem'; +import { getIsDocEnabledColumn } from 'models/helpers'; +import { + FiltersMap, + FormulaMap, + HiddenMap, + ListViewSettings, + RequiredMap, + ValidationMap, +} from 'fyo/model/types'; + +export class PricingRule extends Doc { + isEnabled?: boolean; + title?: string; + appliedItems?: PricingRuleItem[]; + discountType?: 'Price Discount' | 'Product Discount'; + + priceDiscountType?: 'rate' | 'percentage' | 'amount'; + discountRate?: Money; + discountPercentage?: number; + discountAmount?: Money; + + forPriceList?: string; + + freeItem?: string; + freeItemQuantity?: number; + freeItemUnit?: string; + freeItemRate?: Money; + roundFreeItemQty?: number; + roundingMethod?: string; + + isRecursive?: boolean; + recurseEvery?: number; + recurseOver?: number; + + minQuantity?: number; + maxQuantity?: number; + + minAmount?: Money; + maxAmount?: Money; + + validFrom?: Date; + validTo?: Date; + + thresholdForSuggestion?: number; + priority?: number; + + get isDiscountTypeIsPriceDiscount() { + return this.discountType === 'Price Discount'; + } + + formulas: FormulaMap = {}; + + validations: ValidationMap = {}; + + required: RequiredMap = { + priceDiscountType: () => this.isDiscountTypeIsPriceDiscount, + }; + + static getListViewSettings(): ListViewSettings { + return { + columns: ['name', 'title', getIsDocEnabledColumn(), 'discountType'], + }; + } + + hidden: HiddenMap = { + location: () => !this.fyo.singles.AccountingSettings?.enableInventory, + + priceDiscountType: () => !this.isDiscountTypeIsPriceDiscount, + discountRate: () => + !this.isDiscountTypeIsPriceDiscount || this.priceDiscountType !== 'rate', + discountPercentage: () => + !this.isDiscountTypeIsPriceDiscount || + this.priceDiscountType !== 'percentage', + discountAmount: () => + !this.isDiscountTypeIsPriceDiscount || + this.priceDiscountType !== 'amount', + forPriceList: () => + !this.isDiscountTypeIsPriceDiscount || this.priceDiscountType === 'rate', + + freeItem: () => this.isDiscountTypeIsPriceDiscount, + freeItemQuantity: () => this.isDiscountTypeIsPriceDiscount, + freeItemUnit: () => this.isDiscountTypeIsPriceDiscount, + freeItemRate: () => this.isDiscountTypeIsPriceDiscount, + roundFreeItemQty: () => this.isDiscountTypeIsPriceDiscount, + roundingMethod: () => + this.isDiscountTypeIsPriceDiscount || !this.roundFreeItemQty, + isRecursive: () => this.isDiscountTypeIsPriceDiscount, + recurseEvery: () => this.isDiscountTypeIsPriceDiscount || !this.isRecursive, + recurseOver: () => this.isDiscountTypeIsPriceDiscount || !this.isRecursive, + }; + + static filters: FiltersMap = {}; +} diff --git a/models/baseModels/PricingRuleDetail/PricingRuleDetail.ts b/models/baseModels/PricingRuleDetail/PricingRuleDetail.ts new file mode 100644 index 00000000..5096b922 --- /dev/null +++ b/models/baseModels/PricingRuleDetail/PricingRuleDetail.ts @@ -0,0 +1,5 @@ +import { Doc } from 'fyo/model/doc'; + +export class PricingRuleDetail extends Doc { + referenceName?: string; +} diff --git a/models/baseModels/PricingRuleItem/PricingRuleItem.ts b/models/baseModels/PricingRuleItem/PricingRuleItem.ts new file mode 100644 index 00000000..ef9263c6 --- /dev/null +++ b/models/baseModels/PricingRuleItem/PricingRuleItem.ts @@ -0,0 +1,19 @@ +import { Doc } from 'fyo/model/doc'; +import { FormulaMap } from 'fyo/model/types'; +import { ModelNameEnum } from 'models/types'; + +export class PricingRuleItem extends Doc { + item?: string; + unit?: string; + + formulas: FormulaMap = { + unit: { + formula: () => { + if (!this.item) { + return; + } + return this.fyo.getValue(ModelNameEnum.Item, this.item, 'unit'); + }, + }, + }; +} From 5efdd9fbc6ed6246080e2e921688eb967389dd81 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 30 Jan 2024 18:25:50 +0530 Subject: [PATCH 04/19] feat: model changes for pricing rule --- .../AccountingSettings/AccountingSettings.ts | 3 + models/baseModels/Invoice/Invoice.ts | 200 +++++++++++++++++- models/baseModels/InvoiceItem/InvoiceItem.ts | 66 ++++++ models/baseModels/PriceList/PriceList.ts | 8 +- models/helpers.ts | 106 +++++++++- models/index.ts | 4 + models/types.ts | 2 + 7 files changed, 381 insertions(+), 8 deletions(-) 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', From 33f82f8eaebbbc8b3592dd3564981802df1685c0 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 30 Jan 2024 18:26:05 +0530 Subject: [PATCH 05/19] feat: update types for pricing rule --- models/baseModels/Invoice/types.ts | 6 ++++ models/baseModels/types.ts | 58 ++++++++++++++++++++++++++++++ models/types.ts | 1 + 3 files changed, 65 insertions(+) create mode 100644 models/baseModels/Invoice/types.ts create mode 100644 models/baseModels/types.ts diff --git a/models/baseModels/Invoice/types.ts b/models/baseModels/Invoice/types.ts new file mode 100644 index 00000000..50362b21 --- /dev/null +++ b/models/baseModels/Invoice/types.ts @@ -0,0 +1,6 @@ +import { PricingRule } from '../PricingRule/PricingRule'; + +export interface ApplicablePricingRules { + applyOnItem: string; + pricingRule: PricingRule; +} diff --git a/models/baseModels/types.ts b/models/baseModels/types.ts new file mode 100644 index 00000000..51807db0 --- /dev/null +++ b/models/baseModels/types.ts @@ -0,0 +1,58 @@ +export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid' | 'Return' | 'ReturnIssued'; +export enum ModelNameEnum { + Account = 'Account', + AccountingLedgerEntry = 'AccountingLedgerEntry', + AccountingSettings = 'AccountingSettings', + Address = 'Address', + Batch= 'Batch', + Color = 'Color', + Currency = 'Currency', + GetStarted = 'GetStarted', + Defaults = 'Defaults', + Item = 'Item', + ItemPrice = 'ItemPrice', + UOM = 'UOM', + UOMConversionItem = 'UOMConversionItem', + JournalEntry = 'JournalEntry', + JournalEntryAccount = 'JournalEntryAccount', + Misc = 'Misc', + NumberSeries = 'NumberSeries', + Party = 'Party', + Payment = 'Payment', + PaymentFor = 'PaymentFor', + PriceList = 'PriceList', + PricingRule = 'PricingRule', + PricingRuleItem = 'PricingRuleItem', + PricingRuleDetail = 'PricingRuleDetail', + PrintSettings = 'PrintSettings', + PrintTemplate = 'PrintTemplate', + PurchaseInvoice = 'PurchaseInvoice', + PurchaseInvoiceItem = 'PurchaseInvoiceItem', + SalesInvoice = 'SalesInvoice', + SalesInvoiceItem = 'SalesInvoiceItem', + SalesQuote = 'SalesQuote', + SalesQuoteItem = 'SalesQuoteItem', + SerialNumber = 'SerialNumber', + SetupWizard = 'SetupWizard', + Tax = 'Tax', + TaxDetail = 'TaxDetail', + TaxSummary = 'TaxSummary', + PatchRun = 'PatchRun', + SingleValue = 'SingleValue', + InventorySettings = 'InventorySettings', + SystemSettings = 'SystemSettings', + StockMovement = 'StockMovement', + StockMovementItem = 'StockMovementItem', + StockLedgerEntry = 'StockLedgerEntry', + Shipment = 'Shipment', + ShipmentItem = 'ShipmentItem', + PurchaseReceipt = 'PurchaseReceipt', + PurchaseReceiptItem = 'PurchaseReceiptItem', + Location = 'Location', + CustomForm = 'CustomForm', + CustomField = 'CustomField', + POSSettings = 'POSSettings', + POSShift = 'POSShift' +} + +export type ModelName = keyof typeof ModelNameEnum; diff --git a/models/types.ts b/models/types.ts index 0199be15..04ba0c73 100644 --- a/models/types.ts +++ b/models/types.ts @@ -24,6 +24,7 @@ export enum ModelNameEnum { PriceList = 'PriceList', PricingRule = 'PricingRule', PricingRuleItem = 'PricingRuleItem', + PricingRuleDetail = 'PricingRuleDetail', PrintSettings = 'PrintSettings', PrintTemplate = 'PrintTemplate', PurchaseInvoice = 'PurchaseInvoice', From 86f6571b9dc7566d1672a8aca970a92529f00d12 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 30 Jan 2024 18:26:19 +0530 Subject: [PATCH 06/19] feat: added pricig rule to sidebar --- src/utils/sidebarConfig.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/utils/sidebarConfig.ts b/src/utils/sidebarConfig.ts index dc4aa159..7bf43400 100644 --- a/src/utils/sidebarConfig.ts +++ b/src/utils/sidebarConfig.ts @@ -279,6 +279,13 @@ function getCompleteSidebar(): SidebarConfig { schemaName: 'PriceList', hidden: () => !fyo.singles.AccountingSettings?.enablePriceList, }, + { + label: t`Pricing Rule`, + name: 'pricing-rule', + route: '/list/PricingRule', + schemaName: 'PricingRule', + hidden: () => !fyo.singles.AccountingSettings?.enablePricingRule, + }, ] as SidebarItem[], }, getReportSidebar(), From 154c3b36f881c4462abff6ef7389bbd684c83caa Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Thu, 1 Feb 2024 17:03:08 +0530 Subject: [PATCH 07/19] chore: cleanup --- models/baseModels/types.ts | 58 ------------------------------- schemas/app/PricingRule.json | 7 ---- schemas/app/SalesInvoiceItem.json | 6 ---- 3 files changed, 71 deletions(-) delete mode 100644 models/baseModels/types.ts diff --git a/models/baseModels/types.ts b/models/baseModels/types.ts deleted file mode 100644 index 51807db0..00000000 --- a/models/baseModels/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid' | 'Return' | 'ReturnIssued'; -export enum ModelNameEnum { - Account = 'Account', - AccountingLedgerEntry = 'AccountingLedgerEntry', - AccountingSettings = 'AccountingSettings', - Address = 'Address', - Batch= 'Batch', - Color = 'Color', - Currency = 'Currency', - GetStarted = 'GetStarted', - Defaults = 'Defaults', - Item = 'Item', - ItemPrice = 'ItemPrice', - UOM = 'UOM', - UOMConversionItem = 'UOMConversionItem', - JournalEntry = 'JournalEntry', - JournalEntryAccount = 'JournalEntryAccount', - Misc = 'Misc', - NumberSeries = 'NumberSeries', - Party = 'Party', - Payment = 'Payment', - PaymentFor = 'PaymentFor', - PriceList = 'PriceList', - PricingRule = 'PricingRule', - PricingRuleItem = 'PricingRuleItem', - PricingRuleDetail = 'PricingRuleDetail', - PrintSettings = 'PrintSettings', - PrintTemplate = 'PrintTemplate', - PurchaseInvoice = 'PurchaseInvoice', - PurchaseInvoiceItem = 'PurchaseInvoiceItem', - SalesInvoice = 'SalesInvoice', - SalesInvoiceItem = 'SalesInvoiceItem', - SalesQuote = 'SalesQuote', - SalesQuoteItem = 'SalesQuoteItem', - SerialNumber = 'SerialNumber', - SetupWizard = 'SetupWizard', - Tax = 'Tax', - TaxDetail = 'TaxDetail', - TaxSummary = 'TaxSummary', - PatchRun = 'PatchRun', - SingleValue = 'SingleValue', - InventorySettings = 'InventorySettings', - SystemSettings = 'SystemSettings', - StockMovement = 'StockMovement', - StockMovementItem = 'StockMovementItem', - StockLedgerEntry = 'StockLedgerEntry', - Shipment = 'Shipment', - ShipmentItem = 'ShipmentItem', - PurchaseReceipt = 'PurchaseReceipt', - PurchaseReceiptItem = 'PurchaseReceiptItem', - Location = 'Location', - CustomForm = 'CustomForm', - CustomField = 'CustomField', - POSSettings = 'POSSettings', - POSShift = 'POSShift' -} - -export type ModelName = keyof typeof ModelNameEnum; diff --git a/schemas/app/PricingRule.json b/schemas/app/PricingRule.json index 552e7cbe..714f3ecb 100644 --- a/schemas/app/PricingRule.json +++ b/schemas/app/PricingRule.json @@ -92,13 +92,6 @@ "fieldtype": "Currency", "section": "Price Discount Scheme" }, - { - "fieldname": "forPriceList", - "label": "For Price List", - "fieldtype": "Link", - "target": "PriceList", - "section": "Price Discount Scheme" - }, { "fieldname": "freeItem", "label": "Free Item", diff --git a/schemas/app/SalesInvoiceItem.json b/schemas/app/SalesInvoiceItem.json index 6e8e26d1..682f6957 100644 --- a/schemas/app/SalesInvoiceItem.json +++ b/schemas/app/SalesInvoiceItem.json @@ -8,12 +8,6 @@ "fieldtype": "Check", "default": false, "hidden": true - }, - { - "fieldname": "isPricingRuleAppliedItem", - "fieldtype": "Check", - "default": false, - "hidden": true } ] } From beccfb6f22ac280aec8557d43709ddd0bb08cd6a Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Thu, 1 Feb 2024 17:03:59 +0530 Subject: [PATCH 08/19] feat: product discount --- models/baseModels/Invoice/Invoice.ts | 185 +++++++++++++-------------- 1 file changed, 88 insertions(+), 97 deletions(-) diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index adebfe69..0d0ff886 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -18,6 +18,7 @@ import { getExchangeRate, getNumberSeries, getPricingRulesConflicts, + roundFreeItemQty, } from 'models/helpers'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { validateBatch } from 'models/inventory/helpers'; @@ -172,6 +173,7 @@ export abstract class Invoice extends Transactional { throw new ValidationError(this.fyo.t`Discount Account is not set.`); } await validateBatch(this); + await this._validatePricingRule(); } async afterSubmit() { @@ -639,6 +641,10 @@ export abstract class Invoice extends Transactional { }, isPricingRuleApplied: { formula: async () => { + if (!this.fyo.singles.AccountingSettings?.enablePricingRule) { + return false; + } + const pricingRule = await this.getPricingRule(); if (pricingRule) { await this.appendPricingRuleDetail(pricingRule); @@ -1077,6 +1083,84 @@ export abstract class Invoice extends Transactional { return await addItem(name, this); } + 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 applyProductDiscount() { + if (!this.pricingRuleDetail || !this.items) { + return; + } + + this.items = this.items.filter((item) => !item.isFreeItem); + + for (const item of this.items) { + if (item.isFreeItem) { + continue; + } + + const pricingRuleDetailForItem = this.pricingRuleDetail.filter( + (doc) => doc.referenceItem === item.item + ); + + const pricingRuleDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRuleDetailForItem[0].referenceName + )) as PricingRule; + + if (pricingRuleDoc.discountType === 'Price Discount') { + continue; + } + + const appliedItems = pricingRuleDoc.appliedItems?.map((doc) => doc.item); + 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); + } + + if (pricingRuleDoc.roundFreeItemQty) { + freeItemQty = roundFreeItemQty( + freeItemQty, + pricingRuleDoc.roundingMethod as 'round' | 'floor' | 'ceil' + ); + } + + await this.append('items', { + item: pricingRuleDoc.freeItem as string, + quantity: freeItemQty, + isFreeItem: true, + rate: pricingRuleDoc.freeItemRate, + unit: pricingRuleDoc.freeItemUnit, + }); + } + } + async getPricingRule(): Promise { if (!this.isSales || !this.items) { return; @@ -1140,107 +1224,14 @@ export abstract class Invoice extends Transactional { 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) { + async _validatePricingRule() { + if (!this.fyo.singles.AccountingSettings?.enablePricingRule) { 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) { + if (!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, - }); - } - } + await this.getPricingRule(); } } From 9345ee5106d4adb8dce30c5ab0bf9480d0c559fb Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Thu, 1 Feb 2024 17:04:39 +0530 Subject: [PATCH 09/19] feat: price discount --- models/baseModels/InvoiceItem/InvoiceItem.ts | 169 ++++++++++++------ .../PricingRuleDetail/PricingRuleDetail.ts | 1 + 2 files changed, 114 insertions(+), 56 deletions(-) diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index cad6b396..e9b8ff73 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -19,9 +19,7 @@ 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; @@ -49,6 +47,8 @@ export abstract class InvoiceItem extends Doc { itemDiscountedTotal?: Money; itemTaxedTotal?: Money; + isFreeItem?: boolean; + get isSales() { return ( this.schemaName === 'SalesInvoiceItem' || @@ -96,6 +96,10 @@ export abstract class InvoiceItem extends Doc { return !!this.parentdoc?.isReturn; } + get pricingRuleDetail() { + return this.parentdoc?.pricingRuleDetail; + } + constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { super(schema, data, fyo); this._setGetCurrencies(); @@ -172,6 +176,7 @@ export abstract class InvoiceItem extends Doc { 'itemTaxedTotal', 'itemDiscountedTotal', 'setItemDiscountAmount', + 'pricingRuleDetail', ], }, unit: { @@ -407,41 +412,79 @@ export abstract class InvoiceItem extends Doc { }, dependsOn: ['item', 'quantity'], }, - isPricingRuleAppliedItem: { + setItemDiscountAmount: { formula: async () => { - if (!this.parentdoc?.pricingRuleDetail) { - return false; + if ( + !this.fyo.singles.AccountingSettings?.enablePricingRule || + !this.parentdoc?.pricingRuleDetail + ) { + return this.setItemDiscountAmount; } - for (const prleDoc of this.parentdoc.pricingRuleDetail) { - const pricingRuleDoc = (await this.fyo.doc.getDoc( - ModelNameEnum.PricingRule, - prleDoc.referenceName - )) as PricingRule; + const pricingRule = this.parentdoc?.pricingRuleDetail?.filter( + (prDetail) => prDetail.referenceItem === this.item + ); - if ( - !canApplyPricingRule( - pricingRuleDoc, - this.parentdoc.date as Date, - this.quantity as number, - this.amount as Money - ) - ) { - continue; - } + if (!pricingRule) { + return this.setItemDiscountAmount; + } - const appliedItems = pricingRuleDoc.appliedItems?.map( - (item) => item.item + const pricingRuleDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRule[0].referenceName + )) as PricingRule; + + if (pricingRuleDoc.discountType === 'Product Discount') { + return this.setItemDiscountAmount; + } + + if (pricingRuleDoc.priceDiscountType === 'amount') { + const discountAmount = pricingRuleDoc.discountAmount?.mul( + this.quantity as number ); - - if (!appliedItems?.includes(this.item)) { - continue; - } - - await applyPricingRuleOnItem(this, pricingRuleDoc); + await this.set('itemDiscountAmount', discountAmount); + return true; } + + return this.setItemDiscountAmount; }, - dependsOn: ['item', 'quantity'], + dependsOn: ['pricingRuleDetail'], + }, + itemDiscountPercent: { + formula: async () => { + if ( + !this.fyo.singles.AccountingSettings?.enablePricingRule || + !this.parentdoc?.pricingRuleDetail + ) { + return this.itemDiscountPercent; + } + + const pricingRule = this.parentdoc?.pricingRuleDetail?.filter( + (prDetail) => prDetail.referenceItem === this.item + ); + + if (!pricingRule) { + return this.itemDiscountPercent; + } + + const pricingRuleDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRule[0].referenceName + )) as PricingRule; + + if (pricingRuleDoc.discountType === 'Product Discount') { + return this.itemDiscountPercent; + } + + if (pricingRuleDoc.priceDiscountType === 'percentage') { + await this.set('setItemDiscountAmount', false); + + return pricingRuleDoc.discountPercentage; + } + + return this.setItemDiscountAmount; + }, + dependsOn: ['pricingRuleDetail'], }, }; @@ -570,7 +613,21 @@ export abstract class InvoiceItem extends Doc { } async function getItemRate(doc: InvoiceItem): Promise { + if (doc.isFreeItem) { + return doc.rate; + } + + let pricingRuleRate: Money | undefined; + if (doc.fyo.singles.AccountingSettings?.enablePricingRule) { + pricingRuleRate = await getItemRateFromPricingRule(doc); + } + + if (pricingRuleRate) { + return pricingRuleRate; + } + let priceListRate: Money | undefined; + if (doc.fyo.singles.AccountingSettings?.enablePriceList) { priceListRate = await getItemRateFromPriceList(doc); } @@ -591,6 +648,33 @@ async function getItemRate(doc: InvoiceItem): Promise { return; } +async function getItemRateFromPricingRule( + doc: InvoiceItem +): Promise { + const pricingRule = doc.parentdoc?.pricingRuleDetail?.filter( + (prDetail) => prDetail.referenceItem === doc.item + ); + + if (!pricingRule) { + return; + } + + const pricingRuleDoc = (await doc.fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRule[0].referenceName + )) as PricingRule; + + if (pricingRuleDoc.discountType !== 'Price Discount') { + return; + } + + if (pricingRuleDoc.priceDiscountType !== 'rate') { + return; + } + + return pricingRuleDoc.discountRate; +} + async function getItemRateFromPriceList( doc: InvoiceItem ): Promise { @@ -773,30 +857,3 @@ 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/PricingRuleDetail/PricingRuleDetail.ts b/models/baseModels/PricingRuleDetail/PricingRuleDetail.ts index 5096b922..99ac370b 100644 --- a/models/baseModels/PricingRuleDetail/PricingRuleDetail.ts +++ b/models/baseModels/PricingRuleDetail/PricingRuleDetail.ts @@ -2,4 +2,5 @@ import { Doc } from 'fyo/model/doc'; export class PricingRuleDetail extends Doc { referenceName?: string; + referenceItem?: string; } From c658ace3da636787d4fe8ade1e4b77835a22f942 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Thu, 1 Feb 2024 17:04:52 +0530 Subject: [PATCH 10/19] feat: pricing rule --- models/helpers.ts | 100 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/models/helpers.ts b/models/helpers.ts index 648c1a26..30caf5d8 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -23,7 +23,8 @@ 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'; +import { ValidationError } from 'fyo/utils/errors'; +import { ApplicablePricingRules } from './baseModels/Invoice/types'; export function getQuoteActions( fyo: Fyo, @@ -660,6 +661,76 @@ export async function addItem(name: string, doc: M) { await item.set('item', name); } +export async function getPricingRule( + doc: Invoice +): Promise { + if ( + !doc.fyo.singles.AccountingSettings?.enablePricingRule || + !doc.isSales || + !doc.items + ) { + return; + } + + const pricingRules: ApplicablePricingRules[] = []; + + for (const item of doc.items) { + if (item.isFreeItem) { + continue; + } + + const pricingRuleDocNames = ( + await doc.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 doc.fyo.db.getAll( + ModelNameEnum.PricingRule, + { + fields: ['*'], + filters: { + name: ['in', pricingRuleDocNames], + isEnabled: true, + }, + orderBy: 'priority', + order: 'desc', + } + )) as PricingRule[]; + + const filtered = filterPricingRules( + pricingRuleDocsForItem, + doc.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; +} + export function filterPricingRules( pricingRuleDocsForItem: PricingRule[], sinvDate: Date, @@ -732,7 +803,7 @@ export function canApplyPricingRule( export function getPricingRulesConflicts( pricingRules: PricingRule[], item: string -) { +): string[] | undefined { const pricingRuleDocs = Array.from(pricingRules); const firstPricingRule = pricingRuleDocs.shift(); @@ -742,22 +813,29 @@ export function getPricingRulesConflicts( const conflictingPricingRuleNames: string[] = []; for (const pricingRuleDoc of pricingRuleDocs.slice(0)) { - if (pricingRuleDoc.priority === firstPricingRule?.priority) { - conflictingPricingRuleNames.push(pricingRuleDoc.name as string); + if (pricingRuleDoc.priority !== firstPricingRule?.priority) { + continue; } + + conflictingPricingRuleNames.push(pricingRuleDoc.name as string); } if (!conflictingPricingRuleNames.length) { - return false; + return; } - showToast({ - type: 'error', - message: t`Pricing Rules ${ + throw new ValidationError( + t`Pricing Rules ${ firstPricingRule.name as string }, ${conflictingPricingRuleNames.join( ', ' - )} has the same Priority for the Item ${item}.`, - }); - return true; + )} has the same Priority for the Item ${item}.` + ); +} + +export function roundFreeItemQty( + quantity: number, + roundingMethod: 'round' | 'floor' | 'ceil' +): number { + return Math[roundingMethod](quantity); } From 0e4c3a18fc6439c6dd32cc19fa11753f30cb5f56 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Fri, 2 Feb 2024 11:51:46 +0530 Subject: [PATCH 11/19] feat: hide pricingRuleDetail when unapplied and not enabled --- models/baseModels/Invoice/Invoice.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index 0d0ff886..e03bb3e4 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -730,6 +730,9 @@ export abstract class Invoice extends Transactional { (!this.canEdit && !this.priceList), returnAgainst: () => (this.isSubmitted || this.isCancelled) && !this.returnAgainst, + pricingRuleDetail: () => + !this.fyo.singles.AccountingSettings?.enablePricingRule || + !this.pricingRuleDetail?.length, }; static defaults: DefaultMap = { From f90579dc877ba509e2326c7636f53a8442676185 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Fri, 2 Feb 2024 14:44:16 +0530 Subject: [PATCH 12/19] feat: added name to keywordFields --- schemas/app/PricingRule.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schemas/app/PricingRule.json b/schemas/app/PricingRule.json index 714f3ecb..3b0b162b 100644 --- a/schemas/app/PricingRule.json +++ b/schemas/app/PricingRule.json @@ -231,5 +231,6 @@ { "value": "20", "label": 20 } ] } - ] + ], + "keywordFields": ["name"] } From 615109e247e252e9dd3f6abfae7b5bcfcde8128a Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Fri, 2 Feb 2024 15:58:22 +0530 Subject: [PATCH 13/19] feat: pricing rule validations --- models/baseModels/PricingRule/PricingRule.ts | 78 +++++++++++++++++++- schemas/app/PricingRule.json | 4 +- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/models/baseModels/PricingRule/PricingRule.ts b/models/baseModels/PricingRule/PricingRule.ts index 4b8f15d4..f614f2ee 100644 --- a/models/baseModels/PricingRule/PricingRule.ts +++ b/models/baseModels/PricingRule/PricingRule.ts @@ -10,6 +10,9 @@ import { RequiredMap, ValidationMap, } from 'fyo/model/types'; +import { DocValue } from 'fyo/core/types'; +import { ValidationError } from 'fyo/utils/errors'; +import { t } from 'fyo'; export class PricingRule extends Doc { isEnabled?: boolean; @@ -53,7 +56,80 @@ export class PricingRule extends Doc { formulas: FormulaMap = {}; - validations: ValidationMap = {}; + validations: ValidationMap = { + minQuantity: (value: DocValue) => { + if (!value || !this.maxQuantity) { + return; + } + + if ((value as number) > this.maxQuantity) { + throw new ValidationError( + t`Minimum Quantity should be less than the Maximum Quantity.` + ); + } + }, + maxQuantity: (value: DocValue) => { + if (!this.minQuantity || !value) { + return; + } + + if ((value as number) < this.minQuantity) { + throw new ValidationError( + t`Maximum Quantity should be greater than the Minimum Quantity.` + ); + } + }, + minAmount: (value: DocValue) => { + if (!value || !this.maxAmount) { + return; + } + + if ((value as Money).isZero() && this.maxAmount.isZero()) { + return; + } + + if ((value as Money).gte(this.maxAmount)) { + throw new ValidationError( + t`Minimum Amount should be less than the Maximum Amount.` + ); + } + }, + maxAmount: (value: DocValue) => { + if (!this.minAmount || !value) { + return; + } + + if (this.minAmount.isZero() && (value as Money).isZero()) { + return; + } + + if ((value as Money).lte(this.minAmount)) { + throw new ValidationError( + t`Maximum Amount should be greater than the Minimum Amount.` + ); + } + }, + validFrom: (value: DocValue) => { + if (!value || !this.validTo) { + return; + } + if ((value as Date).toISOString() > this.validTo.toISOString()) { + throw new ValidationError( + t`Valid From Date should be less than Valid To Date.` + ); + } + }, + validTo: (value: DocValue) => { + if (!this.validFrom || !value) { + return; + } + if ((value as Date).toISOString() < this.validFrom.toISOString()) { + throw new ValidationError( + t`Valid To Date should be greater than Valid From Date.` + ); + } + }, + }; required: RequiredMap = { priceDiscountType: () => this.isDiscountTypeIsPriceDiscount, diff --git a/schemas/app/PricingRule.json b/schemas/app/PricingRule.json index 3b0b162b..060ed537 100644 --- a/schemas/app/PricingRule.json +++ b/schemas/app/PricingRule.json @@ -156,7 +156,7 @@ }, { "fieldname": "recurseEvery", - "label": "Recurse Every (As Per Transaction UOM)", + "label": "Recurse Every (As Per Transaction Unit)", "fieldtype": "Float", "section": "Product Discount Scheme" }, @@ -168,7 +168,7 @@ }, { "fieldname": "maxQuantity", - "label": "Max Qty (As Per Stock UOM)", + "label": "Max Qty (As Per Stock Unit)", "fieldtype": "Float", "section": "Quantity and Amount" }, From c0c61045b2273f0e41b2f4418d74476afb1f8553 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Wed, 6 Mar 2024 15:54:25 +0530 Subject: [PATCH 14/19] feat: pricing rule --- models/baseModels/InvoiceItem/InvoiceItem.ts | 2 +- models/helpers.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index e9b8ff73..1f0f4ed1 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -482,7 +482,7 @@ export abstract class InvoiceItem extends Doc { return pricingRuleDoc.discountPercentage; } - return this.setItemDiscountAmount; + return this.itemDiscountPercent; }, dependsOn: ['pricingRuleDetail'], }, diff --git a/models/helpers.ts b/models/helpers.ts index 30caf5d8..4e06acd1 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -796,7 +796,6 @@ export function canApplyPricingRule( ) { return false; } - return true; } From c2571230fde9926e845de72ccbc124cb0d81994a Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Wed, 6 Mar 2024 15:55:56 +0530 Subject: [PATCH 15/19] fix: removed unwanted fields --- schemas/app/PricingRule.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/schemas/app/PricingRule.json b/schemas/app/PricingRule.json index 060ed537..9d474c77 100644 --- a/schemas/app/PricingRule.json +++ b/schemas/app/PricingRule.json @@ -196,17 +196,11 @@ "fieldtype": "Date", "section": "Validity" }, - { - "fieldname": "thresholdForSuggestion", - "label": "Threshold for Suggestion", - "fieldtype": "Float", - "section": "Threshold and Priority" - }, { "fieldname": "priority", "label": "Priority", "fieldtype": "Select", - "section": "Threshold and Priority", + "section": "Priority", "required": true, "options": [ { "value": "1", "label": 1 }, From a455aeb3ce0a61a2579b0c263fab74f844f1d287 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Wed, 6 Mar 2024 15:56:04 +0530 Subject: [PATCH 16/19] test: pricing rule --- .../baseModels/tests/testPricingRule.spec.ts | 557 ++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 models/baseModels/tests/testPricingRule.spec.ts diff --git a/models/baseModels/tests/testPricingRule.spec.ts b/models/baseModels/tests/testPricingRule.spec.ts new file mode 100644 index 00000000..f17a1a28 --- /dev/null +++ b/models/baseModels/tests/testPricingRule.spec.ts @@ -0,0 +1,557 @@ +import test from 'tape'; +import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; +import { ModelNameEnum } from 'models/types'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { + assertDoesNotThrow, + assertThrows, +} from 'backend/database/tests/helpers'; +import { getItem } from 'models/inventory/tests/helpers'; +import { PricingRule } from '../PricingRule/PricingRule'; + +const fyo = getTestFyo(); +setupTestFyo(fyo, __filename); + +const itemMap = { + Jacket: { + name: 'Jacket', + rate: 1000, + unit: 'Unit', + }, + Cap: { + name: 'Cap', + rate: 100, + unit: 'Unit', + }, +}; + +const partyMap = { + partyOne: { + name: 'Daisy', + email: 'daisy@alien.com', + }, +}; + +const pricingRuleMap = [ + { + name: 'PRLE-1001', + isEnabled: false, + title: 'JKT PDR Offer', + appliedItems: [{ item: itemMap.Jacket.name }], + discountType: 'Price Discount', + priceDiscountType: 'rate', + discountRate: 800, + minQuantity: 4, + maxQuantity: 6, + minAmount: fyo.pesa(4000), + maxAmount: fyo.pesa(6000), + priority: '1', + }, + { + name: 'PRLE-1002', + title: 'CAP PDR Offer', + appliedItems: [{ item: itemMap.Cap.name }], + discountType: 'Product Discount', + freeItem: 'Cap', + freeItemQuantity: 1, + freeItemUnit: 'Unit', + freeItemRate: 0, + minQuantity: 4, + maxQuantity: 6, + minAmount: 200, + maxAmount: 1000, + validFrom: '2024-02-01', + validTo: '2024-02-29', + priority: '1', + }, +]; + +test('Pricing Rule: create dummy item, party, pricing rules', async (t) => { + // Create Items + for (const { name, rate } of Object.values(itemMap)) { + const item = getItem(name, rate, false); + await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync(); + t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `Item: ${name} exists`); + } + + // Create Party + await fyo.doc.getNewDoc(ModelNameEnum.Party, partyMap.partyOne).sync(); + t.ok( + await fyo.db.exists(ModelNameEnum.Party, partyMap.partyOne.name), + `Party: ${partyMap.partyOne.name} exists` + ); + + // Create Pricing Rules + for (const pricingRule of Object.values(pricingRuleMap)) { + await fyo.doc.getNewDoc(ModelNameEnum.PricingRule, pricingRule).sync(); + + t.ok( + await fyo.db.exists(ModelNameEnum.PricingRule, pricingRule.name), + `Price List: ${pricingRule.name} exists` + ); + } + + await fyo.singles.AccountingSettings?.set('enablePricingRule', true); + t.ok(fyo.singles.AccountingSettings?.enablePricingRule); +}); + +test('disabled pricing rule is not applied', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { item: itemMap.Jacket.name, quantity: 5 }); + await sinv.runFormulas(); + + t.equal(sinv.pricingRuleDetail?.length, undefined); +}); + +test('pricing rule is applied when filtered by min and max qty', async (t) => { + const pruleDoc = (await fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRuleMap[0].name + )) as PricingRule; + + await pruleDoc.set('isEnabled', true); + await pruleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail![0].referenceName, + pricingRuleMap[0].name, + 'Pricing Rule is added to Pricing Rule Detail' + ); + + t.equal( + sinv.items![0].rate!.float, + pricingRuleMap[0].discountRate, + 'item rate fetched from Pricing Rule' + ); +}); + +test('pricing rule is not applied when item qty is < min qty ', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { item: itemMap.Jacket.name, quantity: 3 }); + await sinv.runFormulas(); + + t.equal(sinv.pricingRuleDetail?.length, undefined); +}); + +test('pricing rule is not applied when item qty is > max qty', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { item: itemMap.Jacket.name, quantity: 10 }); + await sinv.runFormulas(); + + t.equal(sinv.pricingRuleDetail?.length, undefined); +}); + +test('pricing rule is applied when filtered by min and max amount', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail![0].referenceName, + pricingRuleMap[0].name, + 'Pricing Rule is added to Pricing Rule Detail' + ); + + t.equal( + sinv.items![0].rate!.float, + pricingRuleMap[0].discountRate, + 'item rate fetched from Pricing Rule' + ); +}); + +test('Pricing Rule is not applied when item amount is < min amount', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 2, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('Pricing Rule is not applied when item amount is > max amount', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: new Date(), + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 7, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('Pricing Rule is not applied when sinvDate < validFrom date', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-01-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('Pricing Rule is not applied when sinvDate > validFrom date', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-03-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('Pricing Rule is applied when filtered by qty, amount and dates', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail![0].referenceName, + pricingRuleMap[1].name, + 'Pricing Rule is applied' + ); +}); + +test('Pricing Rule is applied when filtered by qty, amount and dates', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail![0].referenceName, + pricingRuleMap[1].name, + 'Pricing Rule is applied' + ); +}); + +test('Pricing Rule is not applied when qty condition is false, rest is true', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 7, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('Pricing Rule is not applied when amount condition is false, rest is true', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: fyo.pesa(250), + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('Pricing Rule is not applied when validity condition is false, rest is true', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-03-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail?.length, + undefined, + 'Pricing Rule is not applied' + ); +}); + +test('create two pricing rules, Highest priority pricing rule is applied', async (t) => { + const newPricingRuleDoc = fyo.doc.getNewDoc(ModelNameEnum.PricingRule, { + ...pricingRuleMap[1], + priority: '2', + appliedItems: [{ item: itemMap.Cap.name }], + }); + + await newPricingRuleDoc.runFormulas(); + await newPricingRuleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal( + sinv.pricingRuleDetail![0].referenceName, + 'PRLE-1003', + 'Pricing Rule with highest priority is applied' + ); +}); + +test('Pricing Rule is not applied due to two docs having same priority', async (t) => { + const pricingRuleDoc = await fyo.doc.getDoc( + ModelNameEnum.PricingRule, + 'PRLE-1003' + ); + + await pricingRuleDoc.set('priority', '1'); + await pricingRuleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + + t.equal(!!sinv.pricingRuleDetail?.length, false); +}); + +test('create a price discount of type rate, discounted rate should apply', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal(sinv.items![0].rate?.float, pricingRuleMap[0].discountRate); +}); + +test('create a price discount of type percent, discount percent should apply', async (t) => { + const pricingRuleDoc = await fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRuleMap[0].name + ); + + await pricingRuleDoc.setMultiple({ + priceDiscountType: 'percentage', + discountPercentage: 69, + }); + + await pricingRuleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal(sinv.items![0].itemDiscountPercent, 69); +}); + +test('create a price discount of type amount, discount amount should apply', async (t) => { + const pricingRuleDoc = await fyo.doc.getDoc( + ModelNameEnum.PricingRule, + pricingRuleMap[0].name + ); + + await pricingRuleDoc.setMultiple({ + priceDiscountType: 'amount', + discountAmount: 500, + }); + + await pricingRuleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + await sinv.runFormulas(); + + t.equal(sinv.items![0].itemDiscountAmount!.float, 2500); +}); + +test('create a product discount giving 1 free item', async (t) => { + const pricingRuleDoc = await fyo.doc.getDoc( + ModelNameEnum.PricingRule, + 'PRLE-1003' + ); + + await pricingRuleDoc.set('isEnabled', false); + await pricingRuleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + account: 'Debtors', + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + await sinv.sync(); + + t.equal(!!sinv.items![1].isFreeItem, true); + t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate); + t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity); +}); + +test('create a product discount, recurse 2', async (t) => { + const pricingRuleDoc = await fyo.doc.getDoc( + ModelNameEnum.PricingRule, + 'PRLE-1003' + ); + + await pricingRuleDoc.set('isRecursive', true); + await pricingRuleDoc.set('recurseEvery', 2); + await pricingRuleDoc.sync(); + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + account: 'Debtors', + date: '2024-02-01', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + await sinv.runFormulas(); + await sinv.sync(); + + console.log('freeQty', sinv.items![1].quantity); + t.equal(!!sinv.items![1].isFreeItem, true); + t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate); + t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity); +}); + +closeTestFyo(fyo, __filename); From 427b8260e0e7b4357e0d2b4b12bda86e39cc5682 Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Wed, 6 Mar 2024 15:56:14 +0530 Subject: [PATCH 17/19] feat: pricing rule in pos --- src/pages/POS/POS.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index 47c00b4d..22e80d53 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -217,6 +217,7 @@ import { validateSinv, } from 'src/utils/pos'; import Barcode from 'src/components/Controls/Barcode.vue'; +import { getPricingRule } from 'models/helpers'; export default defineComponent({ name: 'POS', @@ -364,7 +365,7 @@ export default defineComponent({ async addItem(item: POSItem | Item | undefined) { // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sinvDoc.runFormulas(); + await this.sinvDoc.runFormulas(); if (!item) { return; @@ -415,6 +416,7 @@ export default defineComponent({ if (existingItems.length) { existingItems[0].quantity = (existingItems[0].quantity as number) + 1; + await this.applyPricingRule(); return; } @@ -562,6 +564,17 @@ export default defineComponent({ validateSinv(this.sinvDoc as SalesInvoice, this.itemQtyMap); await validateShipment(this.itemSerialNumbers); }, + async applyPricingRule() { + const hasPricingRules = await getPricingRule( + this.sinvDoc as SalesInvoice + ); + if (!hasPricingRules) { + return; + } + + await this.sinvDoc.appendPricingRuleDetail(hasPricingRules); + await this.sinvDoc.applyProductDiscount(); + }, getItem, }, From d4a08d8331e3c47d61272c24284b2e74ae81a62d Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Mon, 19 Aug 2024 13:00:25 +0530 Subject: [PATCH 18/19] fix: pricing rule not applied in pos --- models/baseModels/Invoice/Invoice.ts | 37 ++++++++++++++---- .../baseModels/tests/testPricingRule.spec.ts | 3 +- models/helpers.ts | 10 +++-- src/pages/POS/POS.vue | 39 ++++++++++++++++--- src/utils/pos.ts | 3 +- 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index e03bb3e4..b2abedae 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -646,11 +646,12 @@ export abstract class Invoice extends Transactional { } const pricingRule = await this.getPricingRule(); - if (pricingRule) { - await this.appendPricingRuleDetail(pricingRule); + if (!pricingRule) { + return false; } - return !!pricingRule?.length; + await this.appendPricingRuleDetail(pricingRule); + return !!pricingRule; }, dependsOn: ['items'], }, @@ -954,6 +955,8 @@ export abstract class Invoice extends Transactional { if (this.pricingRuleDetail?.length) { await this.applyProductDiscount(); + } else { + this.clearFreeItems(); } } @@ -1099,22 +1102,40 @@ export abstract class Invoice extends Transactional { } } + clearFreeItems() { + if (this.pricingRuleDetail?.length || !this.items) { + return; + } + + for (const item of this.items) { + if (item.isFreeItem) { + this.items = this.items?.filter( + (invoiceItem) => invoiceItem.name !== item.name + ); + } + } + } + async applyProductDiscount() { - if (!this.pricingRuleDetail || !this.items) { + if (!this.items) { + return; + } + + if (!this.pricingRuleDetail?.length || !this.pricingRuleDetail.length) { return; } this.items = this.items.filter((item) => !item.isFreeItem); for (const item of this.items) { - if (item.isFreeItem) { - continue; - } - const pricingRuleDetailForItem = this.pricingRuleDetail.filter( (doc) => doc.referenceItem === item.item ); + if (!pricingRuleDetailForItem.length) { + return; + } + const pricingRuleDoc = (await this.fyo.doc.getDoc( ModelNameEnum.PricingRule, pricingRuleDetailForItem[0].referenceName diff --git a/models/baseModels/tests/testPricingRule.spec.ts b/models/baseModels/tests/testPricingRule.spec.ts index f17a1a28..dfffab3c 100644 --- a/models/baseModels/tests/testPricingRule.spec.ts +++ b/models/baseModels/tests/testPricingRule.spec.ts @@ -141,7 +141,7 @@ test('pricing rule is applied when filtered by min and max qty', async (t) => { ); }); -test('pricing rule is not applied when item qty is < min qty ', async (t) => { +test('pricing rule is not applied when item qty is < min qty', async (t) => { const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { date: new Date(), party: partyMap.partyOne.name, @@ -548,7 +548,6 @@ test('create a product discount, recurse 2', async (t) => { await sinv.runFormulas(); await sinv.sync(); - console.log('freeQty', sinv.items![1].quantity); t.equal(!!sinv.items![1].isFreeItem, true); t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate); t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity); diff --git a/models/helpers.ts b/models/helpers.ts index 4e06acd1..b04b51ed 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -663,13 +663,13 @@ export async function addItem(name: string, doc: M) { export async function getPricingRule( doc: Invoice -): Promise { +): Promise { if ( !doc.fyo.singles.AccountingSettings?.enablePricingRule || !doc.isSales || !doc.items ) { - return; + return null; } const pricingRules: ApplicablePricingRules[] = []; @@ -786,13 +786,15 @@ export function canApplyPricingRule( // Filter by Validity if ( pricingRuleDoc.validFrom && - sinvDate.toISOString() < pricingRuleDoc.validFrom.toISOString() + new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() < + pricingRuleDoc.validFrom.toISOString() ) { return false; } if ( pricingRuleDoc.validTo && - sinvDate.toISOString() > pricingRuleDoc.validTo.toISOString() + new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() > + pricingRuleDoc.validTo.toISOString() ) { return false; } diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index 22e80d53..c9be4081 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -362,6 +362,23 @@ export default defineComponent({ setTransferRefNo(ref: string) { this.transferRefNo = ref; }, + removeFreeItems() { + if (!this.sinvDoc || !this.sinvDoc.items) { + return; + } + + if (!!this.sinvDoc.isPricingRuleApplied) { + return; + } + + for (const item of this.sinvDoc.items) { + if (item.isFreeItem) { + this.sinvDoc.items = this.sinvDoc.items?.filter( + (invoiceItem) => invoiceItem.name !== item.name + ); + } + } + }, async addItem(item: POSItem | Item | undefined) { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -385,17 +402,21 @@ export default defineComponent({ const existingItems = this.sinvDoc.items?.filter( - (invoiceItem) => invoiceItem.item === item.name + (invoiceItem) => + invoiceItem.item === item.name && !invoiceItem.isFreeItem ) ?? []; if (item.hasBatch) { - for (const item of existingItems) { - const itemQty = item.quantity ?? 0; + for (const invItem of existingItems) { + const itemQty = invItem.quantity ?? 0; const qtyInBatch = - this.itemQtyMap[item.item as string][item.batch as string] ?? 0; + this.itemQtyMap[invItem.item as string][invItem.batch as string] ?? + 0; if (itemQty < qtyInBatch) { - item.quantity = (item.quantity as number) + 1; + invItem.quantity = (invItem.quantity as number) + 1; + invItem.rate = item.rate as Money; + return; } } @@ -415,8 +436,10 @@ export default defineComponent({ } if (existingItems.length) { + existingItems[0].rate = item.rate as Money; existingItems[0].quantity = (existingItems[0].quantity as number) + 1; await this.applyPricingRule(); + await this.sinvDoc.runFormulas(); return; } @@ -568,7 +591,11 @@ export default defineComponent({ const hasPricingRules = await getPricingRule( this.sinvDoc as SalesInvoice ); - if (!hasPricingRules) { + + if (!hasPricingRules || !hasPricingRules.length) { + this.sinvDoc.pricingRuleDetail = undefined; + this.sinvDoc.isPricingRuleApplied = false; + this.removeFreeItems(); return; } diff --git a/src/utils/pos.ts b/src/utils/pos.ts index 25047527..7a4b4d3d 100644 --- a/src/utils/pos.ts +++ b/src/utils/pos.ts @@ -21,7 +21,8 @@ import { showToast } from './interactive'; export async function getItemQtyMap(): Promise { const itemQtyMap: ItemQtyMap = {}; const valuationMethod = - fyo.singles.InventorySettings?.valuationMethod ?? ValuationMethod.FIFO; + (fyo.singles.InventorySettings?.valuationMethod as ValuationMethod) ?? + ValuationMethod.FIFO; const rawSLEs = await getRawStockLedgerEntries(fyo); const rawData = getStockLedgerEntries(rawSLEs, valuationMethod); From 3da9f81b62b5e4b9e2e288da993554bfbd2e597c Mon Sep 17 00:00:00 2001 From: akshayitzme Date: Tue, 20 Aug 2024 09:32:00 +0530 Subject: [PATCH 19/19] chore: code cleanup --- models/baseModels/PricingRule/PricingRule.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/models/baseModels/PricingRule/PricingRule.ts b/models/baseModels/PricingRule/PricingRule.ts index f614f2ee..31888d6f 100644 --- a/models/baseModels/PricingRule/PricingRule.ts +++ b/models/baseModels/PricingRule/PricingRule.ts @@ -3,8 +3,6 @@ import { Money } from 'pesa'; import { PricingRuleItem } from '../PricingRuleItem/PricingRuleItem'; import { getIsDocEnabledColumn } from 'models/helpers'; import { - FiltersMap, - FormulaMap, HiddenMap, ListViewSettings, RequiredMap, @@ -54,8 +52,6 @@ export class PricingRule extends Doc { return this.discountType === 'Price Discount'; } - formulas: FormulaMap = {}; - validations: ValidationMap = { minQuantity: (value: DocValue) => { if (!value || !this.maxQuantity) { @@ -167,6 +163,4 @@ export class PricingRule extends Doc { recurseEvery: () => this.isDiscountTypeIsPriceDiscount || !this.isRecursive, recurseOver: () => this.isDiscountTypeIsPriceDiscount || !this.isRecursive, }; - - static filters: FiltersMap = {}; }