2
0
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:
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; 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) {

View File

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

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

View File

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

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 { 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);
}

View File

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

View File

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

View File

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

View File

@ -62,6 +62,10 @@
{ {
"value": "PurchaseReceipt", "value": "PurchaseReceipt",
"label": "Purchase Receipt" "label": "Purchase Receipt"
},
{
"value": "PricingRule",
"label": "Pricing Rule"
} }
], ],
"default": "-", "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", "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"]

View File

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

View File

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

View File

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

View File

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

View File

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