2
0
mirror of https://github.com/frappe/books.git synced 2024-11-13 00:46:28 +00:00

Merge pull request #653 from akshayitzme/price-list-5

feat: price list
This commit is contained in:
Alan 2023-06-06 06:37:48 -07:00 committed by GitHub
commit 341148e326
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 621 additions and 1 deletions

View File

@ -14,6 +14,7 @@ import { getCountryInfo } from 'utils/misc';
export class AccountingSettings extends Doc { export class AccountingSettings extends Doc {
enableDiscounting?: boolean; enableDiscounting?: boolean;
enableInventory?: boolean; enableInventory?: boolean;
enablePriceList?: boolean;
static filters: FiltersMap = { static filters: FiltersMap = {
writeOffAccount: () => ({ writeOffAccount: () => ({

View File

@ -35,6 +35,7 @@ export abstract class Invoice extends Transactional {
party?: string; party?: string;
account?: string; account?: string;
currency?: string; currency?: string;
priceList?: string;
netTotal?: Money; netTotal?: Money;
grandTotal?: Money; grandTotal?: Money;
baseGrandTotal?: Money; baseGrandTotal?: Money;
@ -514,6 +515,7 @@ export abstract class Invoice extends Transactional {
attachment: () => attachment: () =>
!(this.attachment || !(this.isSubmitted || this.isCancelled)), !(this.attachment || !(this.isSubmitted || this.isCancelled)),
backReference: () => !this.backReference, backReference: () => !this.backReference,
priceList: () => !this.fyo.singles.AccountingSettings?.enablePriceList,
}; };
static defaults: DefaultMap = { static defaults: DefaultMap = {
@ -544,6 +546,10 @@ export abstract class Invoice extends Transactional {
accountType: doc.isSales ? 'Receivable' : 'Payable', accountType: doc.isSales ? 'Receivable' : 'Payable',
}), }),
numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }), numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }),
priceList: (doc: Doc) => ({
enabled: true,
...(doc.isSales ? { selling: true } : { buying: true }),
}),
}; };
static createFilters: FiltersMap = { static createFilters: FiltersMap = {

View File

@ -17,6 +17,7 @@ import { safeParseFloat } from 'utils/index';
import { Invoice } from '../Invoice/Invoice'; import { Invoice } from '../Invoice/Invoice';
import { Item } from '../Item/Item'; import { Item } from '../Item/Item';
import { StockTransfer } from 'models/inventory/StockTransfer'; import { StockTransfer } from 'models/inventory/StockTransfer';
import { getPriceListRate } from 'models/helpers';
export abstract class InvoiceItem extends Doc { export abstract class InvoiceItem extends Doc {
item?: string; item?: string;
@ -48,6 +49,18 @@ export abstract class InvoiceItem extends Doc {
return this.schemaName === 'SalesInvoiceItem'; return this.schemaName === 'SalesInvoiceItem';
} }
get date() {
return this.parentdoc?.date ?? undefined;
}
get party() {
return this.parentdoc?.party ?? undefined;
}
get priceList() {
return this.parentdoc?.priceList ?? undefined;
}
get discountAfterTax() { get discountAfterTax() {
return !!this?.parentdoc?.discountAfterTax; return !!this?.parentdoc?.discountAfterTax;
} }
@ -101,12 +114,15 @@ export abstract class InvoiceItem extends Doc {
}, },
rate: { rate: {
formula: async (fieldname) => { formula: async (fieldname) => {
const rate = (await this.fyo.getValue( const priceListRate = await getPriceListRate(this);
const itemRate = (await this.fyo.getValue(
'Item', 'Item',
this.item as string, this.item as string,
'rate' 'rate'
)) as undefined | Money; )) as undefined | Money;
const rate = priceListRate instanceof Money ? priceListRate : itemRate;
if (!rate?.float && this.rate?.float) { if (!rate?.float && this.rate?.float) {
return this.rate; return this.rate;
} }
@ -144,6 +160,9 @@ export abstract class InvoiceItem extends Doc {
return rateFromTotals ?? rate ?? this.fyo.pesa(0); return rateFromTotals ?? rate ?? this.fyo.pesa(0);
}, },
dependsOn: [ dependsOn: [
'date',
'priceList',
'batch',
'party', 'party',
'exchangeRate', 'exchangeRate',
'item', 'item',

View File

@ -0,0 +1,60 @@
import { t } from 'fyo';
import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { ValidationMap } from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import { getItemPrice } from 'models/helpers';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
export class ItemPrice extends Doc {
item?: string;
rate?: Money;
validFrom?: Date;
validUpto?: Date;
get isBuying() {
return !!this.parentdoc?.buying;
}
get isSelling() {
return !!this.parentdoc?.selling;
}
get priceList() {
return this.parentdoc?.name;
}
validations: ValidationMap = {
validUpto: async (value: DocValue) => {
if (!value || !this.validFrom) {
return;
}
if (value < this.validFrom) {
throw new ValidationError(
t`Valid From date can not be greater than Valid To date.`
);
}
const itemPrice = await getItemPrice(
this,
this.validFrom,
this.validUpto
);
if (!itemPrice) {
return;
}
const priceList = (await this.fyo.getValue(
ModelNameEnum.ItemPrice,
itemPrice,
'parent'
)) as string;
throw new ValidationError(
t`an Item Price already exists for the given date in Price List ${priceList}`
);
},
};
}

View File

@ -0,0 +1,18 @@
import { Doc } from 'fyo/model/doc';
import { ListViewSettings } from 'fyo/model/types';
import { ItemPrice } from '../ItemPrice/ItemPrice';
import { getPriceListStatusColumn } from 'models/helpers';
export class PriceList extends Doc {
enabled?: boolean;
buying?: boolean;
selling?: boolean;
isUomDependent?: boolean;
priceListItem?: ItemPrice[];
static getListViewSettings(): ListViewSettings {
return {
columns: ['name', getPriceListStatusColumn()],
};
}
}

View File

@ -0,0 +1,220 @@
import test from 'tape';
import { getDefaultMetaFieldValueMap } from 'backend/helpers';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { ModelNameEnum } from 'models/types';
import { getItem } from 'models/inventory/tests/helpers';
import { getItemPrice } from 'models/helpers';
import { SalesInvoiceItem } from '../SalesInvoiceItem/SalesInvoiceItem';
import { SalesInvoice } from '../SalesInvoice/SalesInvoice';
import { PurchaseInvoiceItem } from '../PurchaseInvoiceItem/PurchaseInvoiceItem';
const fyo = getTestFyo();
setupTestFyo(fyo, __filename);
const itemMap = {
Pen: {
name: 'Pen',
rate: 100,
hasBatch: true,
},
Ink: {
name: 'Ink',
rate: 50,
},
};
const partyMap = {
partyOne: {
name: 'John Whoe',
email: 'john@whoe.com',
},
};
const batchMap = {
batchOne: {
name: 'PL-AB001',
manufactureDate: '2022-11-03T09:57:04.528',
},
};
const priceListMap = {
PL_SELL: {
name: 'PL_SELL',
enabled: true,
party: 'Shaju',
buying: false,
selling: true,
isUomDependent: false,
itemPrice: [
{
enabled: true,
item: itemMap.Pen.name,
rate: 101,
buying: false,
selling: true,
party: partyMap.partyOne.name,
validFrom: '2023-02-28T18:30:00.678Z',
validUpto: '2023-03-30T18:30:00.678Z',
...getDefaultMetaFieldValueMap(),
},
],
},
PL_BUY: {
name: 'PL_BUY',
enabled: true,
buying: true,
selling: false,
isUomDependent: false,
itemPrice: [
{
enabled: true,
item: itemMap.Pen.name,
rate: 102,
buying: true,
selling: false,
party: partyMap.partyOne.name,
validFrom: '2023-02-28T18:30:00.678Z',
validUpto: '2023-03-30T18:30:00.678Z',
...getDefaultMetaFieldValueMap(),
},
],
},
PL_SB: {
name: 'PL_SB',
enabled: true,
selling: true,
buying: true,
isUomDependent: false,
itemPrice: [
{
enabled: true,
item: itemMap.Pen.name,
rate: 104,
batch: batchMap.batchOne.name,
buying: true,
selling: true,
party: partyMap.partyOne.name,
validFrom: '2023-05-05T18:30:00.000Z',
validUpto: '2023-06-05T18:30:00.000Z',
...getDefaultMetaFieldValueMap(),
},
],
},
};
test('Price List: create dummy items, parties and batches', 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 Parties
for (const { name, email } of Object.values(partyMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.Party, { name, email }).sync();
t.ok(
await fyo.db.exists(ModelNameEnum.Party, name),
`Party: ${name} exists`
);
}
// Create Batches
for (const batch of Object.values(batchMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.Batch, batch).sync();
t.ok(
await fyo.db.exists(ModelNameEnum.Batch, batch.name),
`Batch: ${batch.name} exists`
);
}
});
test('create Price Lists', async (t) => {
for (const priceListItem of Object.values(priceListMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.PriceList, priceListItem).sync();
t.ok(
await fyo.db.exists(ModelNameEnum.PriceList, priceListItem.name),
`Price List ${priceListItem.name} exists`
);
}
});
test('check item price', async (t) => {
// check selling enabled item price
const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
items: [{ item: itemMap.Pen.name, quantity: 1 }],
date: priceListMap.PL_SELL.itemPrice[0].validFrom,
priceList: priceListMap.PL_SELL.name,
party: partyMap.partyOne.name,
}) as SalesInvoice;
const sinvItem = Object.values(sinv.items ?? {})[0];
const sellEnabled = await getItemPrice(sinvItem as SalesInvoiceItem);
const sellEnabledPLName = await fyo.getValue(
ModelNameEnum.ItemPrice,
sellEnabled as string,
'parent'
);
t.equal(sellEnabledPLName, priceListMap.PL_SELL.name);
// check buying enabled item price
const pinv = fyo.doc.getNewDoc(ModelNameEnum.PurchaseInvoice, {
items: [{ item: itemMap.Pen.name, quantity: 1 }],
date: priceListMap.PL_BUY.itemPrice[0].validFrom,
priceList: priceListMap.PL_BUY.name,
party: partyMap.partyOne.name,
}) as SalesInvoice;
const pinvItem = Object.values(pinv.items ?? {})[0];
const buyEnabled = await getItemPrice(pinvItem as PurchaseInvoiceItem);
const buyEnabledPLName = await fyo.getValue(
ModelNameEnum.ItemPrice,
buyEnabled as string,
'parent'
);
t.equal(buyEnabledPLName, priceListMap.PL_BUY.name);
// check sell batch enabled
const sinv1 = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
items: [
{ item: itemMap.Pen.name, quantity: 1, batch: batchMap.batchOne.name },
],
date: priceListMap.PL_SB.itemPrice[0].validFrom,
priceList: priceListMap.PL_SB.name,
party: partyMap.partyOne.name,
}) as SalesInvoice;
const sinv1Item = Object.values(sinv1.items ?? {})[0];
const sellBatchEnabled = await getItemPrice(sinv1Item as SalesInvoiceItem);
const sellBatchEnabledPLName = await fyo.getValue(
ModelNameEnum.ItemPrice,
sellBatchEnabled as string,
'parent'
);
t.equal(sellBatchEnabledPLName, priceListMap.PL_SB.name);
// undefined returns
const sinv2 = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
items: [{ item: itemMap.Ink.name, quantity: 1 }],
date: priceListMap.PL_SELL.itemPrice[0].validFrom,
priceList: priceListMap.PL_SELL.name,
party: partyMap.partyOne.name,
}) as SalesInvoice;
const sinv2Item = Object.values(sinv2.items ?? {})[0];
const nonExistItem = await getItemPrice(sinv2Item as SalesInvoiceItem);
t.equal(
nonExistItem,
undefined,
'itemPrice of non-existing item in price list returns false'
);
});
closeTestFyo(fyo, __filename);

View File

@ -17,6 +17,8 @@ import { Invoice } from './baseModels/Invoice/Invoice';
import { StockMovement } from './inventory/StockMovement'; 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 { InvoiceItem } from './baseModels/InvoiceItem/InvoiceItem';
import { ItemPrice } from './baseModels/ItemPrice/ItemPrice';
export function getInvoiceActions( export function getInvoiceActions(
fyo: Fyo, fyo: Fyo,
@ -325,6 +327,122 @@ export function getSerialNumberStatusText(status: string): string {
} }
} }
export function getPriceListStatusColumn(): ColumnConfig {
return {
label: t`Enabled For`,
fieldname: 'enabledFor',
fieldtype: 'Select',
render(doc) {
let status = 'None';
if (doc.buying && !doc.selling) {
status = 'Buying';
}
if (doc.selling && !doc.buying) {
status = 'Selling';
}
if (doc.buying && doc.selling) {
status = 'Buying & Selling';
}
return {
template: `<Badge class="text-xs" color="gray">${status}</Badge>`,
};
},
};
}
export async function getItemPrice(
doc: InvoiceItem | ItemPrice,
validFrom?: Date,
validUpto?: Date
): Promise<string | undefined> {
if (!doc.item || !doc.priceList) {
return;
}
const isUomDependent = await doc.fyo.getValue(
ModelNameEnum.PriceList,
doc.priceList,
'isUomDependent'
);
const itemPriceQuery = Object.values(
await doc.fyo.db.getAll(ModelNameEnum.ItemPrice, {
filters: {
enabled: true,
item: doc.item,
...(doc.isSales ? { selling: true } : { buying: true }),
...(doc.batch ? { batch: doc.batch as string } : { batch: null }),
},
fields: ['name', 'unit', 'party', 'batch', 'validFrom', 'validUpto'],
})
)[0];
if (!itemPriceQuery) {
return;
}
const { name, unit, party } = itemPriceQuery;
const validFromDate = validFrom ?? itemPriceQuery.validFrom;
const validUptoDate = validFrom ?? itemPriceQuery.validUpto;
let date;
if (doc.date) {
date = new Date((doc.date as Date).setHours(0, 0, 0));
}
if (isUomDependent && unit !== doc.unit) {
return;
}
if (party && doc.party !== party) {
return;
}
if (date instanceof Date) {
if (validFromDate && date < validFromDate) {
return;
}
if (validUptoDate && date > validUptoDate) {
return;
}
}
if (validFrom && validUpto) {
if (validFromDate && validFrom < validFromDate) {
return;
}
if (validUptoDate && validFrom > validUptoDate) {
return;
}
}
return name as string;
}
export async function getPriceListRate(
doc: InvoiceItem
): Promise<Money | undefined> {
const itemPrice = await getItemPrice(doc);
if (!itemPrice) {
return;
}
const itemPriceRate = (await doc.fyo.getValue(
ModelNameEnum.ItemPrice,
itemPrice,
'rate'
)) as Money;
return itemPriceRate;
}
export async function getExchangeRate({ export async function getExchangeRate({
fromCurrency, fromCurrency,
toCurrency, toCurrency,

View File

@ -10,6 +10,8 @@ import { JournalEntryAccount } from './baseModels/JournalEntryAccount/JournalEnt
import { Party } from './baseModels/Party/Party'; import { Party } from './baseModels/Party/Party';
import { Payment } from './baseModels/Payment/Payment'; 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 { ItemPrice } from './baseModels/ItemPrice/ItemPrice';
import { PrintSettings } from './baseModels/PrintSettings/PrintSettings'; import { PrintSettings } from './baseModels/PrintSettings/PrintSettings';
import { PurchaseInvoice } from './baseModels/PurchaseInvoice/PurchaseInvoice'; import { PurchaseInvoice } from './baseModels/PurchaseInvoice/PurchaseInvoice';
import { PurchaseInvoiceItem } from './baseModels/PurchaseInvoiceItem/PurchaseInvoiceItem'; import { PurchaseInvoiceItem } from './baseModels/PurchaseInvoiceItem/PurchaseInvoiceItem';
@ -45,6 +47,8 @@ export const models = {
Payment, Payment,
PaymentFor, PaymentFor,
PrintSettings, PrintSettings,
PriceList,
ItemPrice,
PurchaseInvoice, PurchaseInvoice,
PurchaseInvoiceItem, PurchaseInvoiceItem,
SalesInvoice, SalesInvoice,

View File

@ -10,6 +10,7 @@ export enum ModelNameEnum {
GetStarted = 'GetStarted', GetStarted = 'GetStarted',
Defaults = 'Defaults', Defaults = 'Defaults',
Item = 'Item', Item = 'Item',
ItemPrice = 'ItemPrice',
UOM = 'UOM', UOM = 'UOM',
UOMConversionItem = 'UOMConversionItem', UOMConversionItem = 'UOMConversionItem',
JournalEntry = 'JournalEntry', JournalEntry = 'JournalEntry',
@ -19,6 +20,7 @@ export enum ModelNameEnum {
Party = 'Party', Party = 'Party',
Payment = 'Payment', Payment = 'Payment',
PaymentFor = 'PaymentFor', PaymentFor = 'PaymentFor',
PriceList = 'PriceList',
PrintSettings = 'PrintSettings', PrintSettings = 'PrintSettings',
PrintTemplate = 'PrintTemplate', PrintTemplate = 'PrintTemplate',
PurchaseInvoice = 'PurchaseInvoice', PurchaseInvoice = 'PurchaseInvoice',

View File

@ -79,6 +79,13 @@
"default": false, "default": false,
"section": "Features" "section": "Features"
}, },
{
"fieldname": "enablePriceList",
"label": "Enable Price List",
"fieldtype": "Check",
"default": false,
"section": "Features"
},
{ {
"fieldname": "fiscalYearStart", "fieldname": "fiscalYearStart",
"label": "Fiscal Year Start Date", "label": "Fiscal Year Start Date",

View File

@ -43,6 +43,13 @@
"required": true, "required": true,
"section": "Default" "section": "Default"
}, },
{
"fieldname": "priceList",
"label": "Price List",
"fieldtype": "Link",
"target": "PriceList",
"section": "Default"
},
{ {
"abstract": true, "abstract": true,
"fieldname": "items", "fieldname": "items",

View File

@ -0,0 +1,98 @@
{
"name": "ItemPrice",
"label": "Item Price",
"isChild": true,
"fields": [
{
"fieldname": "enabled",
"label": "Enabled",
"fieldtype": "Check",
"default": true,
"section": "Price List"
},
{
"fieldname": "buying",
"label": "Buying",
"fieldtype": "Check",
"placeholder": "Buying",
"default": false,
"section": "Price List"
},
{
"fieldname": "selling",
"label": "Selling",
"fieldtype": "Check",
"placeholder": "Selling",
"default": false,
"section": "Price List"
},
{
"fieldname": "party",
"label": "Party",
"placeholder": "Party",
"fieldtype": "Link",
"target": "Party",
"create": true,
"section": "Price List"
},
{
"fieldname": "item",
"label": "Item",
"fieldtype": "Link",
"target": "Item",
"required": true,
"create": true,
"section": "Item"
},
{
"fieldname": "unit",
"label": "Unit Type",
"fieldtype": "Link",
"target": "UOM",
"default": "Unit",
"section": "Item"
},
{
"fieldname": "rate",
"label": "Rate",
"fieldtype": "Currency",
"required": true,
"section": "Item"
},
{
"fieldname": "batch",
"label": "Batch",
"fieldtype": "Link",
"target": "Batch",
"create": true,
"section": "Item"
},
{
"fieldname": "validFrom",
"label": "Valid From",
"fieldtype": "Date",
"placeholder": "Valid From",
"section": "Validity"
},
{
"fieldname": "validUpto",
"label": "Valid Upto",
"fieldtype": "Date",
"placeholder": "Valid Upto",
"section": "Validity"
}
],
"tableFields": ["item", "rate", "enabled"],
"quickEditFields": [
"enabled",
"buying",
"selling",
"party",
"item",
"unit",
"rate",
"batch",
"validFrom",
"validUpto"
]
}

View File

@ -0,0 +1,48 @@
{
"name": "PriceList",
"label": "Price List",
"naming": "manual",
"fields": [
{
"fieldname": "name",
"label": "Name",
"fieldtype": "Data",
"required": true
},
{
"fieldname": "enabled",
"label": "Enabled",
"fieldtype": "Check",
"default": true
},
{
"fieldname": "buying",
"label": "Buying",
"fieldtype": "Check",
"default": false,
"required": true
},
{
"fieldname": "selling",
"label": "Selling",
"fieldtype": "Check",
"default": false,
"required": true
},
{
"fieldname": "isUomDependent",
"label": "Is Price UOM Dependent",
"fieldtype": "Check",
"default": false
},
{
"fieldname": "itemPrice",
"label": "Item Prices",
"fieldtype": "Table",
"target": "ItemPrice",
"edit": true,
"required": true,
"section": "Item Prices"
}
]
}

View File

@ -9,6 +9,8 @@ import Defaults from './app/Defaults.json';
import GetStarted from './app/GetStarted.json'; import GetStarted from './app/GetStarted.json';
import InventorySettings from './app/inventory/InventorySettings.json'; import InventorySettings from './app/inventory/InventorySettings.json';
import Location from './app/inventory/Location.json'; import Location from './app/inventory/Location.json';
import PriceList from './app/PriceList.json';
import ItemPrice from './app/ItemPrice.json';
import PurchaseReceipt from './app/inventory/PurchaseReceipt.json'; import PurchaseReceipt from './app/inventory/PurchaseReceipt.json';
import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json'; import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json';
import SerialNumber from './app/inventory/SerialNumber.json'; import SerialNumber from './app/inventory/SerialNumber.json';
@ -100,6 +102,9 @@ export const appSchemas: Schema[] | SchemaStub[] = [
SalesInvoiceItem as SchemaStub, SalesInvoiceItem as SchemaStub,
PurchaseInvoiceItem as SchemaStub, PurchaseInvoiceItem as SchemaStub,
PriceList as Schema,
ItemPrice as SchemaStub,
Tax as Schema, Tax as Schema,
TaxDetail as Schema, TaxDetail as Schema,
TaxSummary as Schema, TaxSummary as Schema,

View File

@ -245,6 +245,13 @@ async function getCompleteSidebar(): Promise<SidebarConfig> {
schemaName: 'Item', schemaName: 'Item',
filters: { for: 'Both' }, filters: { for: 'Both' },
}, },
{
label: t`Price List`,
name: 'price-list',
route: '/list/PriceList',
schemaName: 'PriceList',
hidden: () => !fyo.singles.AccountingSettings?.enablePriceList,
},
] as SidebarItem[], ] as SidebarItem[],
}, },
await getReportSidebar(), await getReportSidebar(),