diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index d345a577..5d774e72 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -24,6 +24,7 @@ import { getPricingRulesConflicts, removeLoyaltyPoint, roundFreeItemQty, + getItemQtyMap, } from 'models/helpers'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { validateBatch } from 'models/inventory/helpers'; @@ -1264,34 +1265,42 @@ export abstract class Invoice extends Transactional { continue; } + let freeItemQty: number | undefined; + + if (pricingRuleDoc?.freeItem) { + const itemQtyMap = await getItemQtyMap(this as SalesInvoice); + freeItemQty = itemQtyMap[pricingRuleDoc.freeItem]?.availableQty; + } + const canApplyPRLOnItem = canApplyPricingRule( pricingRuleDoc, this.date as Date, item.quantity as number, - item.amount as Money + item.amount as Money, + freeItemQty as number ); if (!canApplyPRLOnItem) { continue; } - let freeItemQty = pricingRuleDoc.freeItemQuantity as number; + let roundFreeItemQuantity = pricingRuleDoc.freeItemQuantity as number; if (pricingRuleDoc.isRecursive) { - freeItemQty = + roundFreeItemQuantity = (item.quantity as number) / (pricingRuleDoc.recurseEvery as number); } if (pricingRuleDoc.roundFreeItemQty) { freeItemQty = roundFreeItemQty( - freeItemQty, + roundFreeItemQuantity, pricingRuleDoc.roundingMethod as 'round' | 'floor' | 'ceil' ); } await this.append('items', { item: pricingRuleDoc.freeItem as string, - quantity: freeItemQty, + quantity: roundFreeItemQuantity, isFreeItem: true, pricingRule: pricingRuleDoc.title, rate: pricingRuleDoc.freeItemRate, @@ -1437,14 +1446,14 @@ export abstract class Invoice extends Transactional { } } - const filtered = filterPricingRules( + const filtered = await filterPricingRules( + this as SalesInvoice, pricingRuleDocsForItem, - this.date as Date, item.quantity as number, item.amount as Money ); - if (!filtered.length) { + if (!filtered || !filtered.length) { continue; } diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index 39f5a124..181eb04d 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -658,7 +658,7 @@ async function getItemRateFromPricingRule( (prDetail) => prDetail.referenceItem === doc.item ); - if (!pricingRule) { + if (!pricingRule || !pricingRule.length) { return; } diff --git a/models/baseModels/tests/testCouponCodes.spec.ts b/models/baseModels/tests/testCouponCodes.spec.ts index 0d229a65..a185c7d4 100644 --- a/models/baseModels/tests/testCouponCodes.spec.ts +++ b/models/baseModels/tests/testCouponCodes.spec.ts @@ -1,10 +1,11 @@ import test from 'tape'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; 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 { PricingRule } from '../PricingRule/PricingRule'; import { assertThrows } from 'backend/database/tests/helpers'; +import { MovementTypeEnum } from 'models/inventory/types'; const fyo = getTestFyo(); setupTestFyo(fyo, __filename); @@ -20,6 +21,11 @@ const itemMap = { rate: 100, unit: 'Unit', }, + Pen: { + name: 'Pen', + rate: 700, + unit: 'Unit', + }, }; const partyMap = { @@ -52,7 +58,7 @@ const pricingRuleMap = [ title: 'CAP PDR Offer', appliedItems: [{ item: itemMap.Cap.name }], discountType: 'Product Discount', - freeItem: 'Cap', + freeItem: 'Pen', freeItemQuantity: 1, freeItemUnit: 'Unit', 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) => { // Create Items 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); + // 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 for (const couponCodes of Object.values(couponCodesMap)) { 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.sync(); - t.equal(sinv.coupons?.length, 0, 'coupon code is not applied'); }); diff --git a/models/baseModels/tests/testPricingRule.spec.ts b/models/baseModels/tests/testPricingRule.spec.ts index a95172ed..acad1144 100644 --- a/models/baseModels/tests/testPricingRule.spec.ts +++ b/models/baseModels/tests/testPricingRule.spec.ts @@ -2,8 +2,9 @@ import test from 'tape'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { ModelNameEnum } from 'models/types'; 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 { MovementTypeEnum } from 'models/inventory/types'; const fyo = getTestFyo(); setupTestFyo(fyo, __filename); @@ -19,6 +20,11 @@ const itemMap = { rate: 100, unit: 'Unit', }, + Pen: { + name: 'Pen', + rate: 700, + unit: 'Unit', + }, }; const partyMap = { @@ -48,7 +54,7 @@ const pricingRuleMap = [ title: 'CAP PDR Offer', appliedItems: [{ item: itemMap.Cap.name }], discountType: 'Product Discount', - freeItem: 'Cap', + freeItem: 'Pen', freeItemQuantity: 1, freeItemUnit: 'Unit', 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 for (const { name, rate } of Object.values(itemMap)) { 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); 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.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].quantity, pricingRuleMap[1].freeItemQuantity); }); @@ -516,7 +558,7 @@ test('create a product discount, recurse 2', async (t) => { await sinv.runFormulas(); 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].quantity, pricingRuleMap[1].freeItemQuantity); }); diff --git a/models/helpers.ts b/models/helpers.ts index f6ee08e7..e7e71bef 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -39,6 +39,13 @@ import { safeParseFloat } from 'utils/index'; import { PriceList } from './baseModels/PriceList/PriceList'; import { InvoiceItem } from './baseModels/InvoiceItem/InvoiceItem'; 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( fyo: Fyo, @@ -63,6 +70,34 @@ export function getInvoiceActions( ]; } +export async function getItemQtyMap(doc: SalesInvoice): Promise { + 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( fyo: Fyo, schemaName: ModelNameEnum.Shipment | ModelNameEnum.PurchaseReceipt @@ -914,9 +949,9 @@ export async function getPricingRule( continue; } - const filtered = filterPricingRules( + const filtered = await filterPricingRules( + doc as SalesInvoice, pricingRuleDocsForItem, - doc.date as Date, item.quantity as number, item.amount as Money ); @@ -977,16 +1012,31 @@ export async function getItemRateFromPriceList( return plItem?.rate; } -export function filterPricingRules( +export async function filterPricingRules( + doc: SalesInvoice, pricingRuleDocsForItem: PricingRule[], - sinvDate: Date, quantity: number, amount: Money -): PricingRule[] | [] { - const filteredPricingRules: PricingRule[] | undefined = []; +): Promise { + const filteredPricingRules: PricingRule[] = []; 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); } } @@ -997,9 +1047,22 @@ export function canApplyPricingRule( pricingRuleDoc: PricingRule, sinvDate: Date, quantity: number, - amount: Money + amount: Money, + freeItemQty: number ): boolean { + const freeItemQuantity = pricingRuleDoc.freeItemQuantity; + + if (pricingRuleDoc.isRecursive) { + freeItemQty = quantity / (pricingRuleDoc.recurseEvery as number); + } + // Filter by Quantity + if (pricingRuleDoc.freeItem && freeItemQuantity! >= freeItemQty) { + throw new ValidationError( + t`Free item '${pricingRuleDoc.freeItem}' does not have a specified quantity` + ); + } + if ( (pricingRuleDoc.minQuantity as number) > 0 && quantity < (pricingRuleDoc.minQuantity as number) diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index 609cfaab..5dc2e706 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -117,7 +117,6 @@ import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoic import { AppliedCouponCodes } from 'models/baseModels/AppliedCouponCodes/AppliedCouponCodes'; import { validateSinv, - getItemQtyMap, getItemDiscounts, validateShipment, getTotalQuantity, @@ -129,6 +128,7 @@ import { removeFreeItems, getAddedLPWithGrandTotal, getItemRateFromPriceList, + getItemQtyMap, } from 'models/helpers'; import { POSItem, @@ -328,7 +328,7 @@ export default defineComponent({ ); }, async setItemQtyMap() { - this.itemQtyMap = await getItemQtyMap(); + this.itemQtyMap = await getItemQtyMap(this.sinvDoc as SalesInvoice); }, setSinvDoc() { this.sinvDoc = this.fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { diff --git a/src/utils/pos.ts b/src/utils/pos.ts index 7a4b4d3d..80e1e50a 100644 --- a/src/utils/pos.ts +++ b/src/utils/pos.ts @@ -5,47 +5,13 @@ import { Item } from 'models/baseModels/Item/Item'; import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem'; import { POSShift } from 'models/inventory/Point of Sale/POSShift'; -import { ValuationMethod } from 'models/inventory/types'; import { ModelNameEnum } from 'models/types'; import { Money } from 'pesa'; -import { - getRawStockLedgerEntries, - getStockBalanceEntries, - getStockLedgerEntries, -} from 'reports/inventory/helpers'; import { ItemQtyMap, ItemSerialNumbers } from 'src/components/POS/types'; import { fyo } from 'src/initFyo'; import { safeParseFloat } from 'utils/index'; import { showToast } from './interactive'; -export async function getItemQtyMap(): Promise { - 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 { let totalQuantity = safeParseFloat(0);