2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 15:20:56 +00:00

Merge pull request #829 from akshayitzme/pricing-rule

feat: Pricing Rule
This commit is contained in:
Akshay 2024-08-20 09:58:25 +05:30 committed by GitHub
commit 2119c301d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1661 additions and 16 deletions

View File

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

View File

@ -11,7 +11,15 @@ 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,
roundFreeItemQty,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
import { ModelNameEnum } from 'models/types';
@ -27,6 +35,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 +81,8 @@ export abstract class Invoice extends Transactional {
isReturned?: boolean;
returnAgainst?: string;
pricingRuleDetail?: PricingRuleDetail[];
get isSales() {
return (
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.`);
}
await validateBatch(this);
await this._validatePricingRule();
}
async afterSubmit() {
@ -625,6 +639,22 @@ export abstract class Invoice extends Transactional {
!!this.autoStockTransferLocation,
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() {
@ -701,6 +731,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 = {
@ -917,6 +950,16 @@ export abstract class Invoice extends Transactional {
return transfer;
}
async beforeSync(): Promise<void> {
await super.beforeSync();
if (this.pricingRuleDetail?.length) {
await this.applyProductDiscount();
} else {
this.clearFreeItems();
}
}
async beforeCancel(): Promise<void> {
await super.beforeCancel();
await this._validateStockTransferCancelled();
@ -1045,4 +1088,174 @@ export abstract class Invoice extends Transactional {
async addItem(name: string) {
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();
}
}

View File

@ -0,0 +1,6 @@
import { PricingRule } from '../PricingRule/PricingRule';
export interface ApplicablePricingRules {
applyOnItem: string;
pricingRule: PricingRule;
}

View File

@ -19,6 +19,7 @@ import { Item } from '../Item/Item';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { PriceList } from '../PriceList/PriceList';
import { isPesa } from 'fyo/utils';
import { PricingRule } from '../PricingRule/PricingRule';
export abstract class InvoiceItem extends Doc {
item?: string;
@ -46,6 +47,8 @@ export abstract class InvoiceItem extends Doc {
itemDiscountedTotal?: Money;
itemTaxedTotal?: Money;
isFreeItem?: boolean;
get isSales() {
return (
this.schemaName === 'SalesInvoiceItem' ||
@ -93,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();
@ -169,6 +176,7 @@ export abstract class InvoiceItem extends Doc {
'itemTaxedTotal',
'itemDiscountedTotal',
'setItemDiscountAmount',
'pricingRuleDetail',
],
},
unit: {
@ -404,6 +412,80 @@ export abstract class InvoiceItem extends Doc {
},
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 = {
@ -531,7 +613,21 @@ export abstract class InvoiceItem extends Doc {
}
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;
if (doc.fyo.singles.AccountingSettings?.enablePriceList) {
priceListRate = await getItemRateFromPriceList(doc);
}
@ -552,6 +648,33 @@ async function getItemRate(doc: InvoiceItem): Promise<Money | undefined> {
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(
doc: InvoiceItem
): Promise<Money | undefined> {

View File

@ -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()],
};
}
}

View 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,
};
}

View File

@ -0,0 +1,6 @@
import { Doc } from 'fyo/model/doc';
export class PricingRuleDetail extends Doc {
referenceName?: string;
referenceItem?: string;
}

View 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');
},
},
};
}

View 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);

View File

@ -22,6 +22,9 @@ 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 { ValidationError } from 'fyo/utils/errors';
import { ApplicablePricingRules } from './baseModels/Invoice/types';
export function getQuoteActions(
fyo: Fyo,
@ -517,7 +520,7 @@ export function getPriceListStatusColumn(): ColumnConfig {
};
}
export function getPriceListEnabledColumn(): ColumnConfig {
export function getIsDocEnabledColumn(): ColumnConfig {
return {
label: t`Enabled`,
fieldname: 'enabled',
@ -657,3 +660,183 @@ export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
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);
}

View File

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

View File

@ -22,6 +22,9 @@ export enum ModelNameEnum {
Payment = 'Payment',
PaymentFor = 'PaymentFor',
PriceList = 'PriceList',
PricingRule = 'PricingRule',
PricingRuleItem = 'PricingRuleItem',
PricingRuleDetail = 'PricingRuleDetail',
PrintSettings = 'PrintSettings',
PrintTemplate = 'PrintTemplate',
PurchaseInvoice = 'PurchaseInvoice',

View File

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

View File

@ -62,6 +62,10 @@
{
"value": "PurchaseReceipt",
"label": "Purchase Receipt"
},
{
"value": "PricingRule",
"label": "Pricing Rule"
}
],
"default": "-",

View 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"]
}

View 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"]
}

View 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"]
}

View File

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

View File

@ -1,5 +1,13 @@
{
"name": "SalesInvoiceItem",
"label": "Sales Invoice Item",
"extends": "InvoiceItem"
"extends": "InvoiceItem",
"fields": [
{
"fieldname": "isFreeItem",
"fieldtype": "Check",
"default": false,
"hidden": true
}
]
}

View File

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

View File

@ -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',
@ -361,10 +362,27 @@ 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
this.sinvDoc.runFormulas();
await this.sinvDoc.runFormulas();
if (!item) {
return;
@ -384,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;
}
}
@ -414,7 +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;
}
@ -562,6 +587,21 @@ 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 || !hasPricingRules.length) {
this.sinvDoc.pricingRuleDetail = undefined;
this.sinvDoc.isPricingRuleApplied = false;
this.removeFreeItems();
return;
}
await this.sinvDoc.appendPricingRuleDetail(hasPricingRules);
await this.sinvDoc.applyProductDiscount();
},
getItem,
},

View File

@ -21,7 +21,8 @@ import { showToast } from './interactive';
export async function getItemQtyMap(): Promise<ItemQtyMap> {
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);

View File

@ -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(),