mirror of
https://github.com/frappe/books.git
synced 2025-01-02 22:50:14 +00:00
Merge pull request #829 from akshayitzme/pricing-rule
feat: Pricing Rule
This commit is contained in:
commit
2119c301d7
@ -18,6 +18,7 @@ export class AccountingSettings extends Doc {
|
|||||||
enableLead?: boolean;
|
enableLead?: boolean;
|
||||||
enableFormCustomization?: boolean;
|
enableFormCustomization?: boolean;
|
||||||
enableInvoiceReturns?: boolean;
|
enableInvoiceReturns?: boolean;
|
||||||
|
enablePricingRule?: boolean;
|
||||||
|
|
||||||
static filters: FiltersMap = {
|
static filters: FiltersMap = {
|
||||||
writeOffAccount: () => ({
|
writeOffAccount: () => ({
|
||||||
@ -60,6 +61,8 @@ export class AccountingSettings extends Doc {
|
|||||||
override hidden: HiddenMap = {
|
override hidden: HiddenMap = {
|
||||||
discountAccount: () => !this.enableDiscounting,
|
discountAccount: () => !this.enableDiscounting,
|
||||||
gstin: () => this.fyo.singles.SystemSettings?.countryCode !== 'in',
|
gstin: () => this.fyo.singles.SystemSettings?.countryCode !== 'in',
|
||||||
|
enablePricingRule: () =>
|
||||||
|
!this.fyo.singles.AccountingSettings?.enableDiscounting,
|
||||||
};
|
};
|
||||||
|
|
||||||
async change(ch: ChangeArg) {
|
async change(ch: ChangeArg) {
|
||||||
|
@ -11,7 +11,15 @@ import {
|
|||||||
import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
|
import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
|
||||||
import { ValidationError } from 'fyo/utils/errors';
|
import { ValidationError } from 'fyo/utils/errors';
|
||||||
import { Transactional } from 'models/Transactional/Transactional';
|
import { Transactional } from 'models/Transactional/Transactional';
|
||||||
import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers';
|
import {
|
||||||
|
addItem,
|
||||||
|
canApplyPricingRule,
|
||||||
|
filterPricingRules,
|
||||||
|
getExchangeRate,
|
||||||
|
getNumberSeries,
|
||||||
|
getPricingRulesConflicts,
|
||||||
|
roundFreeItemQty,
|
||||||
|
} from 'models/helpers';
|
||||||
import { StockTransfer } from 'models/inventory/StockTransfer';
|
import { StockTransfer } from 'models/inventory/StockTransfer';
|
||||||
import { validateBatch } from 'models/inventory/helpers';
|
import { validateBatch } from 'models/inventory/helpers';
|
||||||
import { ModelNameEnum } from 'models/types';
|
import { ModelNameEnum } from 'models/types';
|
||||||
@ -27,6 +35,9 @@ import { Tax } from '../Tax/Tax';
|
|||||||
import { TaxSummary } from '../TaxSummary/TaxSummary';
|
import { TaxSummary } from '../TaxSummary/TaxSummary';
|
||||||
import { ReturnDocItem } from 'models/inventory/types';
|
import { ReturnDocItem } from 'models/inventory/types';
|
||||||
import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types';
|
import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types';
|
||||||
|
import { PricingRule } from '../PricingRule/PricingRule';
|
||||||
|
import { ApplicablePricingRules } from './types';
|
||||||
|
import { PricingRuleDetail } from '../PricingRuleDetail/PricingRuleDetail';
|
||||||
|
|
||||||
export type TaxDetail = {
|
export type TaxDetail = {
|
||||||
account: string;
|
account: string;
|
||||||
@ -70,6 +81,8 @@ export abstract class Invoice extends Transactional {
|
|||||||
isReturned?: boolean;
|
isReturned?: boolean;
|
||||||
returnAgainst?: string;
|
returnAgainst?: string;
|
||||||
|
|
||||||
|
pricingRuleDetail?: PricingRuleDetail[];
|
||||||
|
|
||||||
get isSales() {
|
get isSales() {
|
||||||
return (
|
return (
|
||||||
this.schemaName === 'SalesInvoice' || this.schemaName == 'SalesQuote'
|
this.schemaName === 'SalesInvoice' || this.schemaName == 'SalesQuote'
|
||||||
@ -160,6 +173,7 @@ export abstract class Invoice extends Transactional {
|
|||||||
throw new ValidationError(this.fyo.t`Discount Account is not set.`);
|
throw new ValidationError(this.fyo.t`Discount Account is not set.`);
|
||||||
}
|
}
|
||||||
await validateBatch(this);
|
await validateBatch(this);
|
||||||
|
await this._validatePricingRule();
|
||||||
}
|
}
|
||||||
|
|
||||||
async afterSubmit() {
|
async afterSubmit() {
|
||||||
@ -625,6 +639,22 @@ export abstract class Invoice extends Transactional {
|
|||||||
!!this.autoStockTransferLocation,
|
!!this.autoStockTransferLocation,
|
||||||
dependsOn: [],
|
dependsOn: [],
|
||||||
},
|
},
|
||||||
|
isPricingRuleApplied: {
|
||||||
|
formula: async () => {
|
||||||
|
if (!this.fyo.singles.AccountingSettings?.enablePricingRule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingRule = await this.getPricingRule();
|
||||||
|
if (!pricingRule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.appendPricingRuleDetail(pricingRule);
|
||||||
|
return !!pricingRule;
|
||||||
|
},
|
||||||
|
dependsOn: ['items'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
getStockTransferred() {
|
getStockTransferred() {
|
||||||
@ -701,6 +731,9 @@ export abstract class Invoice extends Transactional {
|
|||||||
(!this.canEdit && !this.priceList),
|
(!this.canEdit && !this.priceList),
|
||||||
returnAgainst: () =>
|
returnAgainst: () =>
|
||||||
(this.isSubmitted || this.isCancelled) && !this.returnAgainst,
|
(this.isSubmitted || this.isCancelled) && !this.returnAgainst,
|
||||||
|
pricingRuleDetail: () =>
|
||||||
|
!this.fyo.singles.AccountingSettings?.enablePricingRule ||
|
||||||
|
!this.pricingRuleDetail?.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaults: DefaultMap = {
|
static defaults: DefaultMap = {
|
||||||
@ -917,6 +950,16 @@ export abstract class Invoice extends Transactional {
|
|||||||
return transfer;
|
return transfer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async beforeSync(): Promise<void> {
|
||||||
|
await super.beforeSync();
|
||||||
|
|
||||||
|
if (this.pricingRuleDetail?.length) {
|
||||||
|
await this.applyProductDiscount();
|
||||||
|
} else {
|
||||||
|
this.clearFreeItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async beforeCancel(): Promise<void> {
|
async beforeCancel(): Promise<void> {
|
||||||
await super.beforeCancel();
|
await super.beforeCancel();
|
||||||
await this._validateStockTransferCancelled();
|
await this._validateStockTransferCancelled();
|
||||||
@ -1045,4 +1088,174 @@ export abstract class Invoice extends Transactional {
|
|||||||
async addItem(name: string) {
|
async addItem(name: string) {
|
||||||
return await addItem(name, this);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.pricingRuleDetail?.length || !this.pricingRuleDetail.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.items = this.items.filter((item) => !item.isFreeItem);
|
||||||
|
|
||||||
|
for (const item of this.items) {
|
||||||
|
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
|
||||||
|
)) 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<ApplicablePricingRules[] | undefined> {
|
||||||
|
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 _validatePricingRule() {
|
||||||
|
if (!this.fyo.singles.AccountingSettings?.enablePricingRule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.getPricingRule();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
6
models/baseModels/Invoice/types.ts
Normal file
6
models/baseModels/Invoice/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { PricingRule } from '../PricingRule/PricingRule';
|
||||||
|
|
||||||
|
export interface ApplicablePricingRules {
|
||||||
|
applyOnItem: string;
|
||||||
|
pricingRule: PricingRule;
|
||||||
|
}
|
@ -19,6 +19,7 @@ import { Item } from '../Item/Item';
|
|||||||
import { StockTransfer } from 'models/inventory/StockTransfer';
|
import { StockTransfer } from 'models/inventory/StockTransfer';
|
||||||
import { PriceList } from '../PriceList/PriceList';
|
import { PriceList } from '../PriceList/PriceList';
|
||||||
import { isPesa } from 'fyo/utils';
|
import { isPesa } from 'fyo/utils';
|
||||||
|
import { PricingRule } from '../PricingRule/PricingRule';
|
||||||
|
|
||||||
export abstract class InvoiceItem extends Doc {
|
export abstract class InvoiceItem extends Doc {
|
||||||
item?: string;
|
item?: string;
|
||||||
@ -46,6 +47,8 @@ export abstract class InvoiceItem extends Doc {
|
|||||||
itemDiscountedTotal?: Money;
|
itemDiscountedTotal?: Money;
|
||||||
itemTaxedTotal?: Money;
|
itemTaxedTotal?: Money;
|
||||||
|
|
||||||
|
isFreeItem?: boolean;
|
||||||
|
|
||||||
get isSales() {
|
get isSales() {
|
||||||
return (
|
return (
|
||||||
this.schemaName === 'SalesInvoiceItem' ||
|
this.schemaName === 'SalesInvoiceItem' ||
|
||||||
@ -93,6 +96,10 @@ export abstract class InvoiceItem extends Doc {
|
|||||||
return !!this.parentdoc?.isReturn;
|
return !!this.parentdoc?.isReturn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get pricingRuleDetail() {
|
||||||
|
return this.parentdoc?.pricingRuleDetail;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
|
constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
|
||||||
super(schema, data, fyo);
|
super(schema, data, fyo);
|
||||||
this._setGetCurrencies();
|
this._setGetCurrencies();
|
||||||
@ -169,6 +176,7 @@ export abstract class InvoiceItem extends Doc {
|
|||||||
'itemTaxedTotal',
|
'itemTaxedTotal',
|
||||||
'itemDiscountedTotal',
|
'itemDiscountedTotal',
|
||||||
'setItemDiscountAmount',
|
'setItemDiscountAmount',
|
||||||
|
'pricingRuleDetail',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unit: {
|
unit: {
|
||||||
@ -404,6 +412,80 @@ export abstract class InvoiceItem extends Doc {
|
|||||||
},
|
},
|
||||||
dependsOn: ['item', 'quantity'],
|
dependsOn: ['item', 'quantity'],
|
||||||
},
|
},
|
||||||
|
setItemDiscountAmount: {
|
||||||
|
formula: async () => {
|
||||||
|
if (
|
||||||
|
!this.fyo.singles.AccountingSettings?.enablePricingRule ||
|
||||||
|
!this.parentdoc?.pricingRuleDetail
|
||||||
|
) {
|
||||||
|
return this.setItemDiscountAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingRule = this.parentdoc?.pricingRuleDetail?.filter(
|
||||||
|
(prDetail) => prDetail.referenceItem === this.item
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pricingRule) {
|
||||||
|
return this.setItemDiscountAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
await this.set('itemDiscountAmount', discountAmount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.setItemDiscountAmount;
|
||||||
|
},
|
||||||
|
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.itemDiscountPercent;
|
||||||
|
},
|
||||||
|
dependsOn: ['pricingRuleDetail'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
validations: ValidationMap = {
|
validations: ValidationMap = {
|
||||||
@ -531,7 +613,21 @@ export abstract class InvoiceItem extends Doc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getItemRate(doc: InvoiceItem): Promise<Money | undefined> {
|
async function getItemRate(doc: InvoiceItem): Promise<Money | undefined> {
|
||||||
|
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;
|
let priceListRate: Money | undefined;
|
||||||
|
|
||||||
if (doc.fyo.singles.AccountingSettings?.enablePriceList) {
|
if (doc.fyo.singles.AccountingSettings?.enablePriceList) {
|
||||||
priceListRate = await getItemRateFromPriceList(doc);
|
priceListRate = await getItemRateFromPriceList(doc);
|
||||||
}
|
}
|
||||||
@ -552,6 +648,33 @@ async function getItemRate(doc: InvoiceItem): Promise<Money | undefined> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getItemRateFromPricingRule(
|
||||||
|
doc: InvoiceItem
|
||||||
|
): Promise<Money | undefined> {
|
||||||
|
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(
|
async function getItemRateFromPriceList(
|
||||||
doc: InvoiceItem
|
doc: InvoiceItem
|
||||||
): Promise<Money | undefined> {
|
): Promise<Money | undefined> {
|
||||||
|
@ -2,7 +2,7 @@ import { Doc } from 'fyo/model/doc';
|
|||||||
import { ListViewSettings } from 'fyo/model/types';
|
import { ListViewSettings } from 'fyo/model/types';
|
||||||
import { PriceListItem } from './PriceListItem';
|
import { PriceListItem } from './PriceListItem';
|
||||||
import {
|
import {
|
||||||
getPriceListEnabledColumn,
|
getIsDocEnabledColumn,
|
||||||
getPriceListStatusColumn,
|
getPriceListStatusColumn,
|
||||||
} from 'models/helpers';
|
} from 'models/helpers';
|
||||||
|
|
||||||
@ -14,11 +14,7 @@ export class PriceList extends Doc {
|
|||||||
|
|
||||||
static getListViewSettings(): ListViewSettings {
|
static getListViewSettings(): ListViewSettings {
|
||||||
return {
|
return {
|
||||||
columns: [
|
columns: ['name', getIsDocEnabledColumn(), getPriceListStatusColumn()],
|
||||||
'name',
|
|
||||||
getPriceListEnabledColumn(),
|
|
||||||
getPriceListStatusColumn(),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
166
models/baseModels/PricingRule/PricingRule.ts
Normal file
166
models/baseModels/PricingRule/PricingRule.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Doc } from 'fyo/model/doc';
|
||||||
|
import { Money } from 'pesa';
|
||||||
|
import { PricingRuleItem } from '../PricingRuleItem/PricingRuleItem';
|
||||||
|
import { getIsDocEnabledColumn } from 'models/helpers';
|
||||||
|
import {
|
||||||
|
HiddenMap,
|
||||||
|
ListViewSettings,
|
||||||
|
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;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
6
models/baseModels/PricingRuleDetail/PricingRuleDetail.ts
Normal file
6
models/baseModels/PricingRuleDetail/PricingRuleDetail.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Doc } from 'fyo/model/doc';
|
||||||
|
|
||||||
|
export class PricingRuleDetail extends Doc {
|
||||||
|
referenceName?: string;
|
||||||
|
referenceItem?: string;
|
||||||
|
}
|
19
models/baseModels/PricingRuleItem/PricingRuleItem.ts
Normal file
19
models/baseModels/PricingRuleItem/PricingRuleItem.ts
Normal file
@ -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');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
556
models/baseModels/tests/testPricingRule.spec.ts
Normal file
556
models/baseModels/tests/testPricingRule.spec.ts
Normal file
@ -0,0 +1,556 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
@ -22,6 +22,9 @@ import { StockMovement } from './inventory/StockMovement';
|
|||||||
import { StockTransfer } from './inventory/StockTransfer';
|
import { StockTransfer } from './inventory/StockTransfer';
|
||||||
import { InvoiceStatus, ModelNameEnum } from './types';
|
import { InvoiceStatus, ModelNameEnum } from './types';
|
||||||
import { Lead } from './baseModels/Lead/Lead';
|
import { Lead } from './baseModels/Lead/Lead';
|
||||||
|
import { PricingRule } from './baseModels/PricingRule/PricingRule';
|
||||||
|
import { ValidationError } from 'fyo/utils/errors';
|
||||||
|
import { ApplicablePricingRules } from './baseModels/Invoice/types';
|
||||||
|
|
||||||
export function getQuoteActions(
|
export function getQuoteActions(
|
||||||
fyo: Fyo,
|
fyo: Fyo,
|
||||||
@ -517,7 +520,7 @@ export function getPriceListStatusColumn(): ColumnConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPriceListEnabledColumn(): ColumnConfig {
|
export function getIsDocEnabledColumn(): ColumnConfig {
|
||||||
return {
|
return {
|
||||||
label: t`Enabled`,
|
label: t`Enabled`,
|
||||||
fieldname: 'enabled',
|
fieldname: 'enabled',
|
||||||
@ -657,3 +660,183 @@ export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
|
|||||||
|
|
||||||
await item.set('item', name);
|
await item.set('item', name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPricingRule(
|
||||||
|
doc: Invoice
|
||||||
|
): Promise<ApplicablePricingRules[] | null> {
|
||||||
|
if (
|
||||||
|
!doc.fyo.singles.AccountingSettings?.enablePricingRule ||
|
||||||
|
!doc.isSales ||
|
||||||
|
!doc.items
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 &&
|
||||||
|
new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() <
|
||||||
|
pricingRuleDoc.validFrom.toISOString()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pricingRuleDoc.validTo &&
|
||||||
|
new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() >
|
||||||
|
pricingRuleDoc.validTo.toISOString()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPricingRulesConflicts(
|
||||||
|
pricingRules: PricingRule[],
|
||||||
|
item: string
|
||||||
|
): string[] | undefined {
|
||||||
|
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) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
conflictingPricingRuleNames.push(pricingRuleDoc.name as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conflictingPricingRuleNames.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ValidationError(
|
||||||
|
t`Pricing Rules ${
|
||||||
|
firstPricingRule.name as string
|
||||||
|
}, ${conflictingPricingRuleNames.join(
|
||||||
|
', '
|
||||||
|
)} has the same Priority for the Item ${item}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundFreeItemQty(
|
||||||
|
quantity: number,
|
||||||
|
roundingMethod: 'round' | 'floor' | 'ceil'
|
||||||
|
): number {
|
||||||
|
return Math[roundingMethod](quantity);
|
||||||
|
}
|
||||||
|
@ -14,6 +14,8 @@ import { Payment } from './baseModels/Payment/Payment';
|
|||||||
import { PaymentFor } from './baseModels/PaymentFor/PaymentFor';
|
import { PaymentFor } from './baseModels/PaymentFor/PaymentFor';
|
||||||
import { PriceList } from './baseModels/PriceList/PriceList';
|
import { PriceList } from './baseModels/PriceList/PriceList';
|
||||||
import { PriceListItem } from './baseModels/PriceList/PriceListItem';
|
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 { PrintSettings } from './baseModels/PrintSettings/PrintSettings';
|
||||||
import { PrintTemplate } from './baseModels/PrintTemplate';
|
import { PrintTemplate } from './baseModels/PrintTemplate';
|
||||||
import { PurchaseInvoice } from './baseModels/PurchaseInvoice/PurchaseInvoice';
|
import { PurchaseInvoice } from './baseModels/PurchaseInvoice/PurchaseInvoice';
|
||||||
@ -61,6 +63,8 @@ export const models = {
|
|||||||
PrintSettings,
|
PrintSettings,
|
||||||
PriceList,
|
PriceList,
|
||||||
PriceListItem,
|
PriceListItem,
|
||||||
|
PricingRule,
|
||||||
|
PricingRuleItem,
|
||||||
PurchaseInvoice,
|
PurchaseInvoice,
|
||||||
PurchaseInvoiceItem,
|
PurchaseInvoiceItem,
|
||||||
SalesInvoice,
|
SalesInvoice,
|
||||||
|
@ -22,6 +22,9 @@ export enum ModelNameEnum {
|
|||||||
Payment = 'Payment',
|
Payment = 'Payment',
|
||||||
PaymentFor = 'PaymentFor',
|
PaymentFor = 'PaymentFor',
|
||||||
PriceList = 'PriceList',
|
PriceList = 'PriceList',
|
||||||
|
PricingRule = 'PricingRule',
|
||||||
|
PricingRuleItem = 'PricingRuleItem',
|
||||||
|
PricingRuleDetail = 'PricingRuleDetail',
|
||||||
PrintSettings = 'PrintSettings',
|
PrintSettings = 'PrintSettings',
|
||||||
PrintTemplate = 'PrintTemplate',
|
PrintTemplate = 'PrintTemplate',
|
||||||
PurchaseInvoice = 'PurchaseInvoice',
|
PurchaseInvoice = 'PurchaseInvoice',
|
||||||
|
@ -107,6 +107,13 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"section": "Features"
|
"section": "Features"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "enablePricingRule",
|
||||||
|
"label": "Enable Pricing Rule",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"default": false,
|
||||||
|
"section": "Features"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "fiscalYearStart",
|
"fieldname": "fiscalYearStart",
|
||||||
"label": "Fiscal Year Start Date",
|
"label": "Fiscal Year Start Date",
|
||||||
|
@ -62,6 +62,10 @@
|
|||||||
{
|
{
|
||||||
"value": "PurchaseReceipt",
|
"value": "PurchaseReceipt",
|
||||||
"label": "Purchase Receipt"
|
"label": "Purchase Receipt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "PricingRule",
|
||||||
|
"label": "Pricing Rule"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "-",
|
"default": "-",
|
||||||
|
230
schemas/app/PricingRule.json
Normal file
230
schemas/app/PricingRule.json
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
{
|
||||||
|
"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": "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 Unit)",
|
||||||
|
"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 Unit)",
|
||||||
|
"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": "priority",
|
||||||
|
"label": "Priority",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"section": "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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keywordFields": ["name"]
|
||||||
|
}
|
23
schemas/app/PricingRuleDetail.json
Normal file
23
schemas/app/PricingRuleDetail.json
Normal file
@ -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"]
|
||||||
|
}
|
25
schemas/app/PricingRuleItem.json
Normal file
25
schemas/app/PricingRuleItem.json
Normal file
@ -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"]
|
||||||
|
}
|
@ -75,6 +75,21 @@
|
|||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"default": false,
|
"default": false,
|
||||||
"hidden": true
|
"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"]
|
"keywordFields": ["name", "party"]
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "SalesInvoiceItem",
|
"name": "SalesInvoiceItem",
|
||||||
"label": "Sales Invoice Item",
|
"label": "Sales Invoice Item",
|
||||||
"extends": "InvoiceItem"
|
"extends": "InvoiceItem",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "isFreeItem",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"default": false,
|
||||||
|
"hidden": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,9 @@ import Payment from './app/Payment.json';
|
|||||||
import PaymentFor from './app/PaymentFor.json';
|
import PaymentFor from './app/PaymentFor.json';
|
||||||
import PriceList from './app/PriceList.json';
|
import PriceList from './app/PriceList.json';
|
||||||
import PriceListItem from './app/PriceListItem.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 PrintSettings from './app/PrintSettings.json';
|
||||||
import PrintTemplate from './app/PrintTemplate.json';
|
import PrintTemplate from './app/PrintTemplate.json';
|
||||||
import PurchaseInvoice from './app/PurchaseInvoice.json';
|
import PurchaseInvoice from './app/PurchaseInvoice.json';
|
||||||
@ -122,6 +125,10 @@ export const appSchemas: Schema[] | SchemaStub[] = [
|
|||||||
PriceList as Schema,
|
PriceList as Schema,
|
||||||
PriceListItem as SchemaStub,
|
PriceListItem as SchemaStub,
|
||||||
|
|
||||||
|
PricingRule as Schema,
|
||||||
|
PricingRuleItem as SchemaStub,
|
||||||
|
PricingRuleDetail as SchemaStub,
|
||||||
|
|
||||||
Tax as Schema,
|
Tax as Schema,
|
||||||
TaxDetail as Schema,
|
TaxDetail as Schema,
|
||||||
TaxSummary as Schema,
|
TaxSummary as Schema,
|
||||||
|
@ -217,6 +217,7 @@ import {
|
|||||||
validateSinv,
|
validateSinv,
|
||||||
} from 'src/utils/pos';
|
} from 'src/utils/pos';
|
||||||
import Barcode from 'src/components/Controls/Barcode.vue';
|
import Barcode from 'src/components/Controls/Barcode.vue';
|
||||||
|
import { getPricingRule } from 'models/helpers';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'POS',
|
name: 'POS',
|
||||||
@ -361,10 +362,27 @@ export default defineComponent({
|
|||||||
setTransferRefNo(ref: string) {
|
setTransferRefNo(ref: string) {
|
||||||
this.transferRefNo = ref;
|
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) {
|
async addItem(item: POSItem | Item | undefined) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.sinvDoc.runFormulas();
|
await this.sinvDoc.runFormulas();
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
@ -384,17 +402,21 @@ export default defineComponent({
|
|||||||
|
|
||||||
const existingItems =
|
const existingItems =
|
||||||
this.sinvDoc.items?.filter(
|
this.sinvDoc.items?.filter(
|
||||||
(invoiceItem) => invoiceItem.item === item.name
|
(invoiceItem) =>
|
||||||
|
invoiceItem.item === item.name && !invoiceItem.isFreeItem
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
if (item.hasBatch) {
|
if (item.hasBatch) {
|
||||||
for (const item of existingItems) {
|
for (const invItem of existingItems) {
|
||||||
const itemQty = item.quantity ?? 0;
|
const itemQty = invItem.quantity ?? 0;
|
||||||
const qtyInBatch =
|
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) {
|
if (itemQty < qtyInBatch) {
|
||||||
item.quantity = (item.quantity as number) + 1;
|
invItem.quantity = (invItem.quantity as number) + 1;
|
||||||
|
invItem.rate = item.rate as Money;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -414,7 +436,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingItems.length) {
|
if (existingItems.length) {
|
||||||
|
existingItems[0].rate = item.rate as Money;
|
||||||
existingItems[0].quantity = (existingItems[0].quantity as number) + 1;
|
existingItems[0].quantity = (existingItems[0].quantity as number) + 1;
|
||||||
|
await this.applyPricingRule();
|
||||||
|
await this.sinvDoc.runFormulas();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -562,6 +587,21 @@ export default defineComponent({
|
|||||||
validateSinv(this.sinvDoc as SalesInvoice, this.itemQtyMap);
|
validateSinv(this.sinvDoc as SalesInvoice, this.itemQtyMap);
|
||||||
await validateShipment(this.itemSerialNumbers);
|
await validateShipment(this.itemSerialNumbers);
|
||||||
},
|
},
|
||||||
|
async applyPricingRule() {
|
||||||
|
const hasPricingRules = await getPricingRule(
|
||||||
|
this.sinvDoc as SalesInvoice
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasPricingRules || !hasPricingRules.length) {
|
||||||
|
this.sinvDoc.pricingRuleDetail = undefined;
|
||||||
|
this.sinvDoc.isPricingRuleApplied = false;
|
||||||
|
this.removeFreeItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sinvDoc.appendPricingRuleDetail(hasPricingRules);
|
||||||
|
await this.sinvDoc.applyProductDiscount();
|
||||||
|
},
|
||||||
|
|
||||||
getItem,
|
getItem,
|
||||||
},
|
},
|
||||||
|
@ -21,7 +21,8 @@ import { showToast } from './interactive';
|
|||||||
export async function getItemQtyMap(): Promise<ItemQtyMap> {
|
export async function getItemQtyMap(): Promise<ItemQtyMap> {
|
||||||
const itemQtyMap: ItemQtyMap = {};
|
const itemQtyMap: ItemQtyMap = {};
|
||||||
const valuationMethod =
|
const valuationMethod =
|
||||||
fyo.singles.InventorySettings?.valuationMethod ?? ValuationMethod.FIFO;
|
(fyo.singles.InventorySettings?.valuationMethod as ValuationMethod) ??
|
||||||
|
ValuationMethod.FIFO;
|
||||||
|
|
||||||
const rawSLEs = await getRawStockLedgerEntries(fyo);
|
const rawSLEs = await getRawStockLedgerEntries(fyo);
|
||||||
const rawData = getStockLedgerEntries(rawSLEs, valuationMethod);
|
const rawData = getStockLedgerEntries(rawSLEs, valuationMethod);
|
||||||
|
@ -279,6 +279,13 @@ function getCompleteSidebar(): SidebarConfig {
|
|||||||
schemaName: 'PriceList',
|
schemaName: 'PriceList',
|
||||||
hidden: () => !fyo.singles.AccountingSettings?.enablePriceList,
|
hidden: () => !fyo.singles.AccountingSettings?.enablePriceList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t`Pricing Rule`,
|
||||||
|
name: 'pricing-rule',
|
||||||
|
route: '/list/PricingRule',
|
||||||
|
schemaName: 'PricingRule',
|
||||||
|
hidden: () => !fyo.singles.AccountingSettings?.enablePricingRule,
|
||||||
|
},
|
||||||
] as SidebarItem[],
|
] as SidebarItem[],
|
||||||
},
|
},
|
||||||
getReportSidebar(),
|
getReportSidebar(),
|
||||||
|
Loading…
Reference in New Issue
Block a user