2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 23:30:56 +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 {
enableDiscounting?: boolean;
enableInventory?: boolean;
enablePriceList?: boolean;
static filters: FiltersMap = {
writeOffAccount: () => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,13 @@
"required": true,
"section": "Default"
},
{
"fieldname": "priceList",
"label": "Price List",
"fieldtype": "Link",
"target": "PriceList",
"section": "Default"
},
{
"abstract": true,
"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 InventorySettings from './app/inventory/InventorySettings.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 PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json';
import SerialNumber from './app/inventory/SerialNumber.json';
@ -100,6 +102,9 @@ export const appSchemas: Schema[] | SchemaStub[] = [
SalesInvoiceItem as SchemaStub,
PurchaseInvoiceItem as SchemaStub,
PriceList as Schema,
ItemPrice as SchemaStub,
Tax as Schema,
TaxDetail as Schema,
TaxSummary as Schema,

View File

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