2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 10:58:59 +00:00

feat: Lead

This commit is contained in:
AbleKSaju 2024-08-09 10:41:54 +05:30
parent 04202280cd
commit b443631644
18 changed files with 452 additions and 6 deletions

View File

@ -2,7 +2,6 @@
> >
> Frappe Books is looking for a maintainer, please view [#775](https://github.com/frappe/books/issues/775) for more info. > Frappe Books is looking for a maintainer, please view [#775](https://github.com/frappe/books/issues/775) for more info.
<div align="center" markdown="1"> <div align="center" markdown="1">
<img src="https://user-images.githubusercontent.com/29507195/207267672-d422db6c-d89a-4bbe-9822-468a55c15053.png" alt="Frappe Books logo" width="384"/> <img src="https://user-images.githubusercontent.com/29507195/207267672-d422db6c-d89a-4bbe-9822-468a55c15053.png" alt="Frappe Books logo" width="384"/>

View File

@ -117,3 +117,13 @@ export type DocStatus =
| 'NotSaved' | 'NotSaved'
| 'Submitted' | 'Submitted'
| 'Cancelled'; | 'Cancelled';
export type LeadStatus =
| ''
| 'Open'
| 'Replied'
| 'Interested'
| 'Opportunity'
| 'Converted'
| 'Quotation'
| 'DonotContact'

View File

@ -15,6 +15,7 @@ export class AccountingSettings extends Doc {
enableDiscounting?: boolean; enableDiscounting?: boolean;
enableInventory?: boolean; enableInventory?: boolean;
enablePriceList?: boolean; enablePriceList?: boolean;
enableLead?: boolean;
enableFormCustomization?: boolean; enableFormCustomization?: boolean;
enableInvoiceReturns?: boolean; enableInvoiceReturns?: boolean;
@ -48,6 +49,9 @@ export class AccountingSettings extends Doc {
enableInventory: () => { enableInventory: () => {
return !!this.enableInventory; return !!this.enableInventory;
}, },
enableLead: () => {
return !!this.enableLead;
},
enableInvoiceReturns: () => { enableInvoiceReturns: () => {
return !!this.enableInvoiceReturns; return !!this.enableInvoiceReturns;
}, },

View File

@ -165,6 +165,10 @@ export abstract class Invoice extends Transactional {
async afterSubmit() { async afterSubmit() {
await super.afterSubmit(); await super.afterSubmit();
if (this.schemaName === ModelNameEnum.SalesQuote) {
return;
}
// update outstanding amounts // update outstanding amounts
await this.fyo.db.update(this.schemaName, { await this.fyo.db.update(this.schemaName, {
name: this.name as string, name: this.name as string,

View File

@ -0,0 +1,32 @@
import { Fyo } from 'fyo';
import { Doc } from 'fyo/model/doc';
import {
Action,
LeadStatus,
ListViewSettings,
ValidationMap,
} from 'fyo/model/types';
import { getLeadActions, getLeadStatusColumn } from 'models/helpers';
import {
validateEmail,
validatePhoneNumber,
} from 'fyo/model/validationFunction';
export class Lead extends Doc {
status?: LeadStatus;
validations: ValidationMap = {
email: validateEmail,
mobile: validatePhoneNumber,
};
static getActions(fyo: Fyo): Action[] {
return getLeadActions(fyo);
}
static getListViewSettings(): ListViewSettings {
return {
columns: ['name', getLeadStatusColumn(), 'email', 'mobile'],
};
}
}

View File

@ -13,9 +13,12 @@ import {
} from 'fyo/model/validationFunction'; } from 'fyo/model/validationFunction';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { PartyRole } from './types'; import { PartyRole } from './types';
import { ModelNameEnum } from 'models/types';
export class Party extends Doc { export class Party extends Doc {
role?: PartyRole; role?: PartyRole;
party?: string;
fromLead?: string;
defaultAccount?: string; defaultAccount?: string;
outstandingAmount?: Money; outstandingAmount?: Money;
async updateOutstandingAmount() { async updateOutstandingAmount() {
@ -125,6 +128,25 @@ export class Party extends Doc {
}; };
} }
async afterDelete() {
await super.afterDelete();
if (!this.fromLead) {
return;
}
const leadData = await this.fyo.doc.getDoc(ModelNameEnum.Lead, this.name);
await leadData.setAndSync('status', 'Interested');
}
async afterSync() {
await super.afterSync();
if (!this.fromLead) {
return;
}
const leadData = await this.fyo.doc.getDoc(ModelNameEnum.Lead, this.name);
await leadData.setAndSync('status', 'Converted');
}
static getActions(fyo: Fyo): Action[] { static getActions(fyo: Fyo): Action[] {
return [ return [
{ {

View File

@ -1,14 +1,22 @@
import { Fyo } from 'fyo'; import { Fyo } from 'fyo';
import { DocValueMap } from 'fyo/core/types'; import { DocValueMap } from 'fyo/core/types';
import { Action, ListViewSettings } from 'fyo/model/types'; import { Action, FiltersMap, ListViewSettings } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { getQuoteActions, getTransactionStatusColumn } from '../../helpers'; import { getQuoteActions, getTransactionStatusColumn } from '../../helpers';
import { Invoice } from '../Invoice/Invoice'; import { Invoice } from '../Invoice/Invoice';
import { SalesQuoteItem } from '../SalesQuoteItem/SalesQuoteItem'; import { SalesQuoteItem } from '../SalesQuoteItem/SalesQuoteItem';
import { Defaults } from '../Defaults/Defaults'; import { Defaults } from '../Defaults/Defaults';
import { Doc } from 'fyo/model/doc';
import { Party } from '../Party/Party';
export class SalesQuote extends Invoice { export class SalesQuote extends Invoice {
items?: SalesQuoteItem[]; items?: SalesQuoteItem[];
party?: string;
name?: string;
referenceType?:
| ModelNameEnum.SalesInvoice
| ModelNameEnum.PurchaseInvoice
| ModelNameEnum.Lead;
// This is an inherited method and it must keep the async from the parent // This is an inherited method and it must keep the async from the parent
// class // class
@ -48,6 +56,20 @@ export class SalesQuote extends Invoice {
return invoice; return invoice;
} }
static filters: FiltersMap = {
numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }),
};
async afterSubmit(): Promise<void> {
await super.afterSubmit();
if (this.referenceType == ModelNameEnum.Lead) {
const partyDoc = (await this.loadAndGetLink('party')) as Party;
await partyDoc.setAndSync('status', 'Quotation');
}
}
static getListViewSettings(): ListViewSettings { static getListViewSettings(): ListViewSettings {
return { return {
columns: [ columns: [

View File

@ -0,0 +1,127 @@
import test from 'tape';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { ModelNameEnum } from 'models/types';
import { Lead } from '../Lead/Lead';
import { Party } from '../Party/Party';
const fyo = getTestFyo();
setupTestFyo(fyo, __filename);
const leadData = {
name: 'name2',
status: 'Open',
email: 'sample@gmail.com',
mobile: '1231233545',
};
const itemData: { name: string; rate: number } = {
name: 'Pen',
rate: 100,
};
test('create test docs for Lead', async (t) => {
await fyo.doc.getNewDoc(ModelNameEnum.Item, itemData).sync();
t.ok(
fyo.db.exists(ModelNameEnum.Item, itemData.name),
`dummy item ${itemData.name} exists`
);
});
test('create a Lead doc', async (t) => {
await fyo.doc.getNewDoc(ModelNameEnum.Lead, leadData).sync();
t.ok(
fyo.db.exists(ModelNameEnum.Lead, leadData.name),
`${leadData.name} exists`
);
});
test('create Customer from Lead', async (t) => {
const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
const newPartyDoc = fyo.doc.getNewDoc(ModelNameEnum.Party, {
...leadDoc.getValidDict(),
fromLead: leadData.name,
role: 'Customer',
phone: leadData.mobile as string,
});
t.equals(
leadDoc.status,
'Open',
'Before Customer created the status must be Open'
);
await newPartyDoc.sync();
t.equals(
leadDoc.status,
'Converted',
'After Customer created the status change to Converted'
);
t.ok(
await fyo.db.exists(ModelNameEnum.Party, newPartyDoc.name),
'Customer created from Lead'
);
});
test('create SalesQuote', async (t) => {
const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
const docData = leadDoc.getValidDict(true, true);
const newSalesQuoteDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesQuote, {
...docData,
party: docData.name,
referenceType: ModelNameEnum.Lead,
items: [
{
item: itemData.name,
rate: itemData.rate,
},
],
}) as Lead;
t.equals(
leadDoc.status,
'Converted',
'status must be Open before SQUOT is created'
);
await newSalesQuoteDoc.sync();
await newSalesQuoteDoc.submit();
t.equals(
leadDoc.status,
'Quotation',
'status should change to Quotation after SQUOT submission'
);
t.ok(
await fyo.db.exists(ModelNameEnum.SalesQuote, newSalesQuoteDoc.name),
'SalesQuote Created from Lead'
);
});
test('delete Customer then lead status changes to Interested', async (t) => {
const partyDoc = (await fyo.doc.getDoc(
ModelNameEnum.Party,
'name2'
)) as Party;
await partyDoc.delete();
t.equals(
await fyo.db.exists(ModelNameEnum.Party, 'name2'),
false,
'Customer deleted'
);
const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
t.equals(
leadDoc.status,
'Interested',
'After Customer deleted the status changed to Interested'
);
});
closeTestFyo(fyo, __filename);

View File

@ -1,6 +1,12 @@
import { Fyo, t } from 'fyo'; import { Fyo, t } from 'fyo';
import { Doc } from 'fyo/model/doc'; import { Doc } from 'fyo/model/doc';
import { Action, ColumnConfig, DocStatus, RenderData } from 'fyo/model/types'; import {
Action,
ColumnConfig,
DocStatus,
LeadStatus,
RenderData,
} from 'fyo/model/types';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { safeParseFloat } from 'utils/index'; import { safeParseFloat } from 'utils/index';
@ -15,6 +21,7 @@ import { SalesQuote } from './baseModels/SalesQuote/SalesQuote';
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 { Lead } from './baseModels/Lead/Lead';
export function getQuoteActions( export function getQuoteActions(
fyo: Fyo, fyo: Fyo,
@ -23,6 +30,10 @@ export function getQuoteActions(
return [getMakeInvoiceAction(fyo, schemaName)]; return [getMakeInvoiceAction(fyo, schemaName)];
} }
export function getLeadActions(fyo: Fyo): Action[] {
return [getCreateCustomerAction(fyo), getSalesQuoteAction(fyo)];
}
export function getInvoiceActions( export function getInvoiceActions(
fyo: Fyo, fyo: Fyo,
schemaName: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice schemaName: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice
@ -108,6 +119,43 @@ export function getMakeInvoiceAction(
}; };
} }
export function getCreateCustomerAction(fyo: Fyo): Action {
return {
group: fyo.t`Create`,
label: fyo.t`Customer`,
action: async (doc: Doc, router) => {
const partyDoc = fyo.doc.getNewDoc(ModelNameEnum.Party, {
...doc.getValidDict(),
fromLead: doc.name,
phone: doc.mobile as string,
role: 'Customer',
});
if (!partyDoc.name) {
return;
}
await router.push(`/edit/Party/${partyDoc.name}`);
},
};
}
export function getSalesQuoteAction(fyo: Fyo): Action {
return {
group: fyo.t`Create`,
label: fyo.t`Sales Quote`,
action: async (doc, router) => {
const data: { party: string | undefined; referenceType: string } = {
party: doc.name,
referenceType: ModelNameEnum.Lead,
};
const salesQuoteDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesQuote, data);
if (!salesQuoteDoc.name) {
return;
}
await router.push(`/edit/SalesQuote/${salesQuoteDoc.name}`);
},
};
}
export function getMakePaymentAction(fyo: Fyo): Action { export function getMakePaymentAction(fyo: Fyo): Action {
return { return {
label: fyo.t`Payment`, label: fyo.t`Payment`,
@ -230,18 +278,42 @@ export function getTransactionStatusColumn(): ColumnConfig {
}; };
} }
export function getLeadStatusColumn(): ColumnConfig {
return {
label: t`Status`,
fieldname: 'status',
fieldtype: 'Select',
render(doc) {
const status = getLeadStatus(doc) as LeadStatus;
const color = statusColor[status] ?? 'gray';
const label = getStatusTextOfLead(status);
return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
};
},
};
}
export const statusColor: Record< export const statusColor: Record<
DocStatus | InvoiceStatus, DocStatus | InvoiceStatus | LeadStatus,
string | undefined string | undefined
> = { > = {
'': 'gray', '': 'gray',
Draft: 'gray', Draft: 'gray',
Open: 'gray',
Replied: 'yellow',
Opportunity: 'yellow',
Unpaid: 'orange', Unpaid: 'orange',
Paid: 'green', Paid: 'green',
Interested: 'yellow',
Converted: 'green',
Quotation: 'green',
Saved: 'gray', Saved: 'gray',
NotSaved: 'gray', NotSaved: 'gray',
Submitted: 'green', Submitted: 'green',
Cancelled: 'red', Cancelled: 'red',
DonotContact: 'red',
Return: 'green', Return: 'green',
ReturnIssued: 'green', ReturnIssued: 'green',
}; };
@ -271,6 +343,37 @@ export function getStatusText(status: DocStatus | InvoiceStatus): string {
} }
} }
export function getStatusTextOfLead(status: LeadStatus): string {
switch (status) {
case 'Open':
return t`Open`;
case 'Replied':
return t`Replied`;
case 'Opportunity':
return t`Opportunity`;
case 'Interested':
return t`Interested`;
case 'Converted':
return t`Converted`;
case 'Quotation':
return t`Quotation`;
case 'DonotContact':
return t`Do not Contact`;
default:
return '';
}
}
export function getLeadStatus(
doc?: Lead | Doc | RenderData
): LeadStatus | DocStatus {
if (!doc) {
return '';
}
return doc.status as LeadStatus;
}
export function getDocStatus( export function getDocStatus(
doc?: RenderData | Doc doc?: RenderData | Doc
): DocStatus | InvoiceStatus { ): DocStatus | InvoiceStatus {

View File

@ -9,6 +9,7 @@ import { JournalEntry } from './baseModels/JournalEntry/JournalEntry';
import { JournalEntryAccount } from './baseModels/JournalEntryAccount/JournalEntryAccount'; import { JournalEntryAccount } from './baseModels/JournalEntryAccount/JournalEntryAccount';
import { Misc } from './baseModels/Misc'; import { Misc } from './baseModels/Misc';
import { Party } from './baseModels/Party/Party'; import { Party } from './baseModels/Party/Party';
import { Lead } from './baseModels/Lead/Lead';
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 { PriceList } from './baseModels/PriceList/PriceList';
@ -53,6 +54,7 @@ export const models = {
JournalEntry, JournalEntry,
JournalEntryAccount, JournalEntryAccount,
Misc, Misc,
Lead,
Party, Party,
Payment, Payment,
PaymentFor, PaymentFor,

View File

@ -4,6 +4,7 @@ import { GSTType } from './types';
export class Party extends BaseParty { export class Party extends BaseParty {
gstin?: string; gstin?: string;
fromLead?: string;
gstType?: GSTType; gstType?: GSTType;
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
@ -18,5 +19,6 @@ export class Party extends BaseParty {
hidden: HiddenMap = { hidden: HiddenMap = {
gstin: () => (this.gstType as GSTType) !== 'Registered Regular', gstin: () => (this.gstType as GSTType) !== 'Registered Regular',
fromLead: () => !this.fromLead,
}; };
} }

View File

@ -17,6 +17,7 @@ export enum ModelNameEnum {
JournalEntryAccount = 'JournalEntryAccount', JournalEntryAccount = 'JournalEntryAccount',
Misc = 'Misc', Misc = 'Misc',
NumberSeries = 'NumberSeries', NumberSeries = 'NumberSeries',
Lead = 'Lead',
Party = 'Party', Party = 'Party',
Payment = 'Payment', Payment = 'Payment',
PaymentFor = 'PaymentFor', PaymentFor = 'PaymentFor',

View File

@ -100,6 +100,13 @@
"default": false, "default": false,
"section": "Features" "section": "Features"
}, },
{
"fieldname": "enableLead",
"label": "Enable Lead",
"fieldtype": "Check",
"default": false,
"section": "Features"
},
{ {
"fieldname": "fiscalYearStart", "fieldname": "fiscalYearStart",
"label": "Fiscal Year Start Date", "label": "Fiscal Year Start Date",

76
schemas/app/Lead.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "Lead",
"label": "Lead",
"naming": "manual",
"fields": [
{
"fieldname": "name",
"label": "Name",
"fieldtype": "Data",
"required": true,
"placeholder": "Full Name",
"section": "Default"
},
{
"fieldname": "status",
"label": "Status",
"fieldtype": "Select",
"default": "Open",
"options": [
{
"value": "Open",
"label": "Open"
},
{
"value": "Replied",
"label": "Replied"
},
{
"value": "Interested",
"label": "Interested"
},
{
"value": "Opportunity",
"label": "Opportunity"
},
{
"value": "Converted",
"label": "Converted"
},
{
"value": "Quotation",
"label": "Quotation"
},
{
"value": "DonotContact",
"label": "Do not Contact"
}
],
"required": true,
"section": "Default"
},
{
"fieldname": "email",
"label": "Email",
"fieldtype": "Data",
"placeholder": "john@doe.com",
"section": "Contacts"
},
{
"fieldname": "mobile",
"label": "Mobile",
"fieldtype": "Data",
"placeholder": "Mobile",
"section": "Contacts"
},
{
"fieldname": "address",
"label": "Address",
"fieldtype": "Link",
"target": "Address",
"create": true,
"section": "Contacts"
}
],
"keywordFields": ["name", "email", "mobile"]
}

View File

@ -78,6 +78,14 @@
"create": true, "create": true,
"section": "Billing" "section": "Billing"
}, },
{
"fieldname": "fromLead",
"label": "From Lead",
"fieldtype": "Link",
"target": "Lead",
"readOnly": true,
"section": "References"
},
{ {
"fieldname": "taxId", "fieldname": "taxId",
"label": "Tax ID", "label": "Tax ID",

View File

@ -15,11 +15,29 @@
"default": "SQUOT-", "default": "SQUOT-",
"section": "Default" "section": "Default"
}, },
{
"fieldname": "referenceType",
"label": "Type",
"placeholder": "Type",
"fieldtype": "Select",
"default": "Party",
"options": [
{
"value": "Party",
"label": "Party"
},
{
"value": "Lead",
"label": "Lead"
}
],
"required": true
},
{ {
"fieldname": "party", "fieldname": "party",
"label": "Customer", "label": "Customer",
"fieldtype": "Link", "fieldtype": "DynamicLink",
"target": "Party", "references": "referenceType",
"create": true, "create": true,
"required": true, "required": true,
"section": "Default" "section": "Default"

View File

@ -15,6 +15,7 @@ import JournalEntryAccount from './app/JournalEntryAccount.json';
import Misc from './app/Misc.json'; import Misc from './app/Misc.json';
import NumberSeries from './app/NumberSeries.json'; import NumberSeries from './app/NumberSeries.json';
import Party from './app/Party.json'; import Party from './app/Party.json';
import Lead from './app/Lead.json';
import Payment from './app/Payment.json'; 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';
@ -96,6 +97,7 @@ export const appSchemas: Schema[] | SchemaStub[] = [
AccountingLedgerEntry as Schema, AccountingLedgerEntry as Schema,
Party as Schema, Party as Schema,
Lead as Schema,
Address as Schema, Address as Schema,
Item as Schema, Item as Schema,
UOM as Schema, UOM as Schema,

View File

@ -202,6 +202,13 @@ function getCompleteSidebar(): SidebarConfig {
schemaName: 'Item', schemaName: 'Item',
filters: routeFilters.SalesItems, filters: routeFilters.SalesItems,
}, },
{
label: t`Lead`,
name: 'lead',
route: '/list/Lead',
schemaName: 'Lead',
hidden: () => !fyo.singles.AccountingSettings?.enableLead,
},
] as SidebarItem[], ] as SidebarItem[],
}, },
{ {