2
0
mirror of https://github.com/frappe/books.git synced 2025-01-02 22:50:14 +00:00

Merge pull request #1039 from AbleKSaju/fix-apply-freeitem

fix: prevent pricing rule for free items with insufficient stock
This commit is contained in:
Akshay 2024-12-06 15:01:58 +05:30 committed by GitHub
commit 0056233b72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 182 additions and 61 deletions

View File

@ -24,6 +24,7 @@ import {
getPricingRulesConflicts, getPricingRulesConflicts,
removeLoyaltyPoint, removeLoyaltyPoint,
roundFreeItemQty, roundFreeItemQty,
getItemQtyMap,
} from 'models/helpers'; } 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';
@ -1264,34 +1265,42 @@ export abstract class Invoice extends Transactional {
continue; continue;
} }
let freeItemQty: number | undefined;
if (pricingRuleDoc?.freeItem) {
const itemQtyMap = await getItemQtyMap(this as SalesInvoice);
freeItemQty = itemQtyMap[pricingRuleDoc.freeItem]?.availableQty;
}
const canApplyPRLOnItem = canApplyPricingRule( const canApplyPRLOnItem = canApplyPricingRule(
pricingRuleDoc, pricingRuleDoc,
this.date as Date, this.date as Date,
item.quantity as number, item.quantity as number,
item.amount as Money item.amount as Money,
freeItemQty as number
); );
if (!canApplyPRLOnItem) { if (!canApplyPRLOnItem) {
continue; continue;
} }
let freeItemQty = pricingRuleDoc.freeItemQuantity as number; let roundFreeItemQuantity = pricingRuleDoc.freeItemQuantity as number;
if (pricingRuleDoc.isRecursive) { if (pricingRuleDoc.isRecursive) {
freeItemQty = roundFreeItemQuantity =
(item.quantity as number) / (pricingRuleDoc.recurseEvery as number); (item.quantity as number) / (pricingRuleDoc.recurseEvery as number);
} }
if (pricingRuleDoc.roundFreeItemQty) { if (pricingRuleDoc.roundFreeItemQty) {
freeItemQty = roundFreeItemQty( freeItemQty = roundFreeItemQty(
freeItemQty, roundFreeItemQuantity,
pricingRuleDoc.roundingMethod as 'round' | 'floor' | 'ceil' pricingRuleDoc.roundingMethod as 'round' | 'floor' | 'ceil'
); );
} }
await this.append('items', { await this.append('items', {
item: pricingRuleDoc.freeItem as string, item: pricingRuleDoc.freeItem as string,
quantity: freeItemQty, quantity: roundFreeItemQuantity,
isFreeItem: true, isFreeItem: true,
pricingRule: pricingRuleDoc.title, pricingRule: pricingRuleDoc.title,
rate: pricingRuleDoc.freeItemRate, rate: pricingRuleDoc.freeItemRate,
@ -1437,14 +1446,14 @@ export abstract class Invoice extends Transactional {
} }
} }
const filtered = filterPricingRules( const filtered = await filterPricingRules(
this as SalesInvoice,
pricingRuleDocsForItem, pricingRuleDocsForItem,
this.date as Date,
item.quantity as number, item.quantity as number,
item.amount as Money item.amount as Money
); );
if (!filtered.length) { if (!filtered || !filtered.length) {
continue; continue;
} }

View File

@ -658,7 +658,7 @@ async function getItemRateFromPricingRule(
(prDetail) => prDetail.referenceItem === doc.item (prDetail) => prDetail.referenceItem === doc.item
); );
if (!pricingRule) { if (!pricingRule || !pricingRule.length) {
return; return;
} }

View File

@ -1,10 +1,11 @@
import test from 'tape'; import test from 'tape';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { getItem } from 'models/inventory/tests/helpers'; import { getItem, getStockMovement } from 'models/inventory/tests/helpers';
import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; import { SalesInvoice } from '../SalesInvoice/SalesInvoice';
import { PricingRule } from '../PricingRule/PricingRule'; import { PricingRule } from '../PricingRule/PricingRule';
import { assertThrows } from 'backend/database/tests/helpers'; import { assertThrows } from 'backend/database/tests/helpers';
import { MovementTypeEnum } from 'models/inventory/types';
const fyo = getTestFyo(); const fyo = getTestFyo();
setupTestFyo(fyo, __filename); setupTestFyo(fyo, __filename);
@ -20,6 +21,11 @@ const itemMap = {
rate: 100, rate: 100,
unit: 'Unit', unit: 'Unit',
}, },
Pen: {
name: 'Pen',
rate: 700,
unit: 'Unit',
},
}; };
const partyMap = { const partyMap = {
@ -52,7 +58,7 @@ const pricingRuleMap = [
title: 'CAP PDR Offer', title: 'CAP PDR Offer',
appliedItems: [{ item: itemMap.Cap.name }], appliedItems: [{ item: itemMap.Cap.name }],
discountType: 'Product Discount', discountType: 'Product Discount',
freeItem: 'Cap', freeItem: 'Pen',
freeItemQuantity: 1, freeItemQuantity: 1,
freeItemUnit: 'Unit', freeItemUnit: 'Unit',
freeItemRate: 0, freeItemRate: 0,
@ -91,6 +97,10 @@ const couponCodesMap = [
}, },
]; ];
const locationMap = {
LocationOne: 'LocationOne',
};
test(' Coupon Codes: create dummy item, party, pricing rules, coupon codes', async (t) => { test(' Coupon Codes: create dummy item, party, pricing rules, coupon codes', async (t) => {
// Create Items // Create Items
for (const { name, rate } of Object.values(itemMap)) { for (const { name, rate } of Object.values(itemMap)) {
@ -123,6 +133,38 @@ test(' Coupon Codes: create dummy item, party, pricing rules, coupon codes', asy
t.ok(fyo.singles.AccountingSettings?.enablePricingRule); t.ok(fyo.singles.AccountingSettings?.enablePricingRule);
// Create Locations
for (const name of Object.values(locationMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.Location, { name }).sync();
t.ok(await fyo.db.exists(ModelNameEnum.Location, name), `${name} exists`);
}
// create MaterialReceipt
const stockMovement = await getStockMovement(
MovementTypeEnum.MaterialReceipt,
new Date('2022-11-03T09:57:04.528'),
[
{
item: itemMap.Pen.name,
to: locationMap.LocationOne,
quantity: 25,
rate: 500,
},
],
fyo
);
await (await stockMovement.sync()).submit();
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined
),
25,
'Pen has quantity twenty five'
);
// Create Coupon Codes // Create Coupon Codes
for (const couponCodes of Object.values(couponCodesMap)) { for (const couponCodes of Object.values(couponCodesMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.CouponCode, couponCodes).sync(); await fyo.doc.getNewDoc(ModelNameEnum.CouponCode, couponCodes).sync();
@ -291,7 +333,6 @@ test('Coupon not applied: incorrect items added.', async (t) => {
await sinv.runFormulas(); await sinv.runFormulas();
await sinv.sync(); await sinv.sync();
t.equal(sinv.coupons?.length, 0, 'coupon code is not applied'); t.equal(sinv.coupons?.length, 0, 'coupon code is not applied');
}); });

View File

@ -2,8 +2,9 @@ import test from 'tape';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; import { SalesInvoice } from '../SalesInvoice/SalesInvoice';
import { getItem } from 'models/inventory/tests/helpers'; import { getItem, getStockMovement } from 'models/inventory/tests/helpers';
import { PricingRule } from '../PricingRule/PricingRule'; import { PricingRule } from '../PricingRule/PricingRule';
import { MovementTypeEnum } from 'models/inventory/types';
const fyo = getTestFyo(); const fyo = getTestFyo();
setupTestFyo(fyo, __filename); setupTestFyo(fyo, __filename);
@ -19,6 +20,11 @@ const itemMap = {
rate: 100, rate: 100,
unit: 'Unit', unit: 'Unit',
}, },
Pen: {
name: 'Pen',
rate: 700,
unit: 'Unit',
},
}; };
const partyMap = { const partyMap = {
@ -48,7 +54,7 @@ const pricingRuleMap = [
title: 'CAP PDR Offer', title: 'CAP PDR Offer',
appliedItems: [{ item: itemMap.Cap.name }], appliedItems: [{ item: itemMap.Cap.name }],
discountType: 'Product Discount', discountType: 'Product Discount',
freeItem: 'Cap', freeItem: 'Pen',
freeItemQuantity: 1, freeItemQuantity: 1,
freeItemUnit: 'Unit', freeItemUnit: 'Unit',
freeItemRate: 0, freeItemRate: 0,
@ -62,7 +68,11 @@ const pricingRuleMap = [
}, },
]; ];
test('Pricing Rule: create dummy item, party, pricing rules', async (t) => { const locationMap = {
LocationOne: 'LocationOne',
};
test('Pricing Rule: create dummy item, party, pricing rules, free items, locations', async (t) => {
// Create Items // Create Items
for (const { name, rate } of Object.values(itemMap)) { for (const { name, rate } of Object.values(itemMap)) {
const item = getItem(name, rate, false); const item = getItem(name, rate, false);
@ -87,6 +97,38 @@ test('Pricing Rule: create dummy item, party, pricing rules', async (t) => {
); );
} }
// Create Locations
for (const name of Object.values(locationMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.Location, { name }).sync();
t.ok(await fyo.db.exists(ModelNameEnum.Location, name), `${name} exists`);
}
// create MaterialReceipt
const stockMovement = await getStockMovement(
MovementTypeEnum.MaterialReceipt,
new Date('2022-11-03T09:57:04.528'),
[
{
item: itemMap.Pen.name,
to: locationMap.LocationOne,
quantity: 25,
rate: 500,
},
],
fyo
);
await (await stockMovement.sync()).submit();
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined
),
25,
'Pen has quantity twenty five'
);
await fyo.singles.AccountingSettings?.set('enablePricingRule', true); await fyo.singles.AccountingSettings?.set('enablePricingRule', true);
t.ok(fyo.singles.AccountingSettings?.enablePricingRule); t.ok(fyo.singles.AccountingSettings?.enablePricingRule);
}); });
@ -487,7 +529,7 @@ test('create a product discount giving 1 free item', async (t) => {
await sinv.runFormulas(); await sinv.runFormulas();
await sinv.sync(); await sinv.sync();
t.equal(!!sinv.items![1].isFreeItem, true); t.equal(sinv.items![1].isFreeItem, true);
t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate); t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate);
t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity); t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity);
}); });
@ -516,7 +558,7 @@ test('create a product discount, recurse 2', async (t) => {
await sinv.runFormulas(); await sinv.runFormulas();
await sinv.sync(); await sinv.sync();
t.equal(!!sinv.items![1].isFreeItem, true); t.equal(sinv.items![1].isFreeItem, true);
t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate); t.equal(sinv.items![1].rate!.float, pricingRuleMap[1].freeItemRate);
t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity); t.equal(sinv.items![1].quantity, pricingRuleMap[1].freeItemQuantity);
}); });

View File

@ -39,6 +39,13 @@ import { safeParseFloat } from 'utils/index';
import { PriceList } from './baseModels/PriceList/PriceList'; import { PriceList } from './baseModels/PriceList/PriceList';
import { InvoiceItem } from './baseModels/InvoiceItem/InvoiceItem'; import { InvoiceItem } from './baseModels/InvoiceItem/InvoiceItem';
import { SalesInvoiceItem } from './baseModels/SalesInvoiceItem/SalesInvoiceItem'; import { SalesInvoiceItem } from './baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { ItemQtyMap } from 'src/components/POS/types';
import { ValuationMethod } from './inventory/types';
import {
getRawStockLedgerEntries,
getStockBalanceEntries,
getStockLedgerEntries,
} from 'reports/inventory/helpers';
export function getQuoteActions( export function getQuoteActions(
fyo: Fyo, fyo: Fyo,
@ -63,6 +70,34 @@ export function getInvoiceActions(
]; ];
} }
export async function getItemQtyMap(doc: SalesInvoice): Promise<ItemQtyMap> {
const itemQtyMap: ItemQtyMap = {};
const valuationMethod =
(doc.fyo.singles.InventorySettings?.valuationMethod as ValuationMethod) ??
ValuationMethod.FIFO;
const rawSLEs = await getRawStockLedgerEntries(doc.fyo);
const rawData = getStockLedgerEntries(rawSLEs, valuationMethod);
const posInventory = doc.fyo.singles.POSSettings?.inventory;
const stockBalance = getStockBalanceEntries(rawData, {
location: posInventory,
});
for (const row of stockBalance) {
if (!itemQtyMap[row.item]) {
itemQtyMap[row.item] = { availableQty: 0 };
}
if (row.batch) {
itemQtyMap[row.item][row.batch] = row.balanceQuantity;
}
itemQtyMap[row.item]!.availableQty += row.balanceQuantity;
}
return itemQtyMap;
}
export function getStockTransferActions( export function getStockTransferActions(
fyo: Fyo, fyo: Fyo,
schemaName: ModelNameEnum.Shipment | ModelNameEnum.PurchaseReceipt schemaName: ModelNameEnum.Shipment | ModelNameEnum.PurchaseReceipt
@ -914,9 +949,9 @@ export async function getPricingRule(
continue; continue;
} }
const filtered = filterPricingRules( const filtered = await filterPricingRules(
doc as SalesInvoice,
pricingRuleDocsForItem, pricingRuleDocsForItem,
doc.date as Date,
item.quantity as number, item.quantity as number,
item.amount as Money item.amount as Money
); );
@ -977,16 +1012,31 @@ export async function getItemRateFromPriceList(
return plItem?.rate; return plItem?.rate;
} }
export function filterPricingRules( export async function filterPricingRules(
doc: SalesInvoice,
pricingRuleDocsForItem: PricingRule[], pricingRuleDocsForItem: PricingRule[],
sinvDate: Date,
quantity: number, quantity: number,
amount: Money amount: Money
): PricingRule[] | [] { ): Promise<PricingRule[] | []> {
const filteredPricingRules: PricingRule[] | undefined = []; const filteredPricingRules: PricingRule[] = [];
for (const pricingRuleDoc of pricingRuleDocsForItem) { for (const pricingRuleDoc of pricingRuleDocsForItem) {
if (canApplyPricingRule(pricingRuleDoc, sinvDate, quantity, amount)) { let freeItemQty: number | undefined;
if (pricingRuleDoc?.freeItem) {
const itemQtyMap = await getItemQtyMap(doc);
freeItemQty = itemQtyMap[pricingRuleDoc.freeItem]?.availableQty;
}
if (
canApplyPricingRule(
pricingRuleDoc,
doc.date as Date,
quantity,
amount,
freeItemQty ?? 0
)
) {
filteredPricingRules.push(pricingRuleDoc); filteredPricingRules.push(pricingRuleDoc);
} }
} }
@ -997,9 +1047,22 @@ export function canApplyPricingRule(
pricingRuleDoc: PricingRule, pricingRuleDoc: PricingRule,
sinvDate: Date, sinvDate: Date,
quantity: number, quantity: number,
amount: Money amount: Money,
freeItemQty: number
): boolean { ): boolean {
const freeItemQuantity = pricingRuleDoc.freeItemQuantity;
if (pricingRuleDoc.isRecursive) {
freeItemQty = quantity / (pricingRuleDoc.recurseEvery as number);
}
// Filter by Quantity // Filter by Quantity
if (pricingRuleDoc.freeItem && freeItemQuantity! >= freeItemQty) {
throw new ValidationError(
t`Free item '${pricingRuleDoc.freeItem}' does not have a specified quantity`
);
}
if ( if (
(pricingRuleDoc.minQuantity as number) > 0 && (pricingRuleDoc.minQuantity as number) > 0 &&
quantity < (pricingRuleDoc.minQuantity as number) quantity < (pricingRuleDoc.minQuantity as number)

View File

@ -117,7 +117,6 @@ import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoic
import { AppliedCouponCodes } from 'models/baseModels/AppliedCouponCodes/AppliedCouponCodes'; import { AppliedCouponCodes } from 'models/baseModels/AppliedCouponCodes/AppliedCouponCodes';
import { import {
validateSinv, validateSinv,
getItemQtyMap,
getItemDiscounts, getItemDiscounts,
validateShipment, validateShipment,
getTotalQuantity, getTotalQuantity,
@ -129,6 +128,7 @@ import {
removeFreeItems, removeFreeItems,
getAddedLPWithGrandTotal, getAddedLPWithGrandTotal,
getItemRateFromPriceList, getItemRateFromPriceList,
getItemQtyMap,
} from 'models/helpers'; } from 'models/helpers';
import { import {
POSItem, POSItem,
@ -328,7 +328,7 @@ export default defineComponent({
); );
}, },
async setItemQtyMap() { async setItemQtyMap() {
this.itemQtyMap = await getItemQtyMap(); this.itemQtyMap = await getItemQtyMap(this.sinvDoc as SalesInvoice);
}, },
setSinvDoc() { setSinvDoc() {
this.sinvDoc = this.fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { this.sinvDoc = this.fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {

View File

@ -5,47 +5,13 @@ import { Item } from 'models/baseModels/Item/Item';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem'; import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { POSShift } from 'models/inventory/Point of Sale/POSShift'; import { POSShift } from 'models/inventory/Point of Sale/POSShift';
import { ValuationMethod } from 'models/inventory/types';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import {
getRawStockLedgerEntries,
getStockBalanceEntries,
getStockLedgerEntries,
} from 'reports/inventory/helpers';
import { ItemQtyMap, ItemSerialNumbers } from 'src/components/POS/types'; import { ItemQtyMap, ItemSerialNumbers } from 'src/components/POS/types';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { safeParseFloat } from 'utils/index'; import { safeParseFloat } from 'utils/index';
import { showToast } from './interactive'; import { showToast } from './interactive';
export async function getItemQtyMap(): Promise<ItemQtyMap> {
const itemQtyMap: ItemQtyMap = {};
const valuationMethod =
(fyo.singles.InventorySettings?.valuationMethod as ValuationMethod) ??
ValuationMethod.FIFO;
const rawSLEs = await getRawStockLedgerEntries(fyo);
const rawData = getStockLedgerEntries(rawSLEs, valuationMethod);
const posInventory = fyo.singles.POSSettings?.inventory;
const stockBalance = getStockBalanceEntries(rawData, {
location: posInventory,
});
for (const row of stockBalance) {
if (!itemQtyMap[row.item]) {
itemQtyMap[row.item] = { availableQty: 0 };
}
if (row.batch) {
itemQtyMap[row.item][row.batch] = row.balanceQuantity;
}
itemQtyMap[row.item].availableQty += row.balanceQuantity;
}
return itemQtyMap;
}
export function getTotalQuantity(items: SalesInvoiceItem[]): number { export function getTotalQuantity(items: SalesInvoiceItem[]): number {
let totalQuantity = safeParseFloat(0); let totalQuantity = safeParseFloat(0);