2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 19:09:01 +00:00

Merge pull request #929 from AbleKSaju/feat-lead

feat: lead
This commit is contained in:
Akshay 2024-08-19 13:15:24 +05:30 committed by GitHub
commit 7205f5aa42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 457 additions and 5 deletions

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,51 @@
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';
import { ModelNameEnum } from 'models/types';
export class Lead extends Doc {
status?: LeadStatus;
validations: ValidationMap = {
email: validateEmail,
mobile: validatePhoneNumber,
};
createCustomer() {
return this.fyo.doc.getNewDoc(ModelNameEnum.Party, {
...this.getValidDict(),
fromLead: this.name,
phone: this.mobile as string,
role: 'Customer',
});
}
createSalesQuote() {
const data: { party: string | undefined; referenceType: string } = {
party: this.name,
referenceType: ModelNameEnum.Lead,
};
return this.fyo.doc.getNewDoc(ModelNameEnum.SalesQuote, data);
}
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
@ -49,6 +57,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,121 @@
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: '1234567890',
};
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 newCustomer = leadDoc.createCustomer();
t.equals(
leadDoc.status,
'Open',
'status must be Open before Customer is created'
);
await newCustomer.sync();
t.equals(
leadDoc.status,
'Converted',
'status should change to Converted after Customer is created'
);
t.ok(
await fyo.db.exists(ModelNameEnum.Party, newCustomer.name),
'Customer created from Lead'
);
});
test('create SalesQuote', async (t) => {
const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
const newSalesQuote = leadDoc.createSalesQuote();
newSalesQuote.items = [];
newSalesQuote.append('items', {
item: itemData.name,
quantity: 1,
rate: itemData.rate,
});
t.equals(
leadDoc.status,
'Converted',
'status must be Open before SQUOT is created'
);
await newSalesQuote.sync();
await newSalesQuote.submit();
t.equals(
leadDoc.status,
'Quotation',
'status should change to Quotation after SQUOT submission'
);
t.ok(
await fyo.db.exists(ModelNameEnum.SalesQuote, newSalesQuote.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',
'status should change to Interested after Customer is deleted'
);
});
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,35 @@ export function getMakeInvoiceAction(
}; };
} }
export function getCreateCustomerAction(fyo: Fyo): Action {
return {
group: fyo.t`Create`,
label: fyo.t`Customer`,
action: async (doc: Doc, router) => {
const customerData = (doc as Lead).createCustomer();
if (!customerData.name) {
return;
}
await router.push(`/edit/Party/${customerData.name}`);
},
};
}
export function getSalesQuoteAction(fyo: Fyo): Action {
return {
group: fyo.t`Create`,
label: fyo.t`Sales Quote`,
action: async (doc, router) => {
const salesQuoteData = (doc as Lead).createSalesQuote();
if (!salesQuoteData.name) {
return;
}
await router.push(`/edit/SalesQuote/${salesQuoteData.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 +270,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 +335,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[],
}, },
{ {