2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 02:49:03 +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.
<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"/>

View File

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

View File

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

View File

@ -165,6 +165,10 @@ export abstract class Invoice extends Transactional {
async afterSubmit() {
await super.afterSubmit();
if (this.schemaName === ModelNameEnum.SalesQuote) {
return;
}
// update outstanding amounts
await this.fyo.db.update(this.schemaName, {
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';
import { Money } from 'pesa';
import { PartyRole } from './types';
import { ModelNameEnum } from 'models/types';
export class Party extends Doc {
role?: PartyRole;
party?: string;
fromLead?: string;
defaultAccount?: string;
outstandingAmount?: Money;
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[] {
return [
{

View File

@ -1,14 +1,22 @@
import { Fyo } from 'fyo';
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 { getQuoteActions, getTransactionStatusColumn } from '../../helpers';
import { Invoice } from '../Invoice/Invoice';
import { SalesQuoteItem } from '../SalesQuoteItem/SalesQuoteItem';
import { Defaults } from '../Defaults/Defaults';
import { Doc } from 'fyo/model/doc';
import { Party } from '../Party/Party';
export class SalesQuote extends Invoice {
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
// class
@ -48,6 +56,20 @@ export class SalesQuote extends 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 {
return {
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 { 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 { Money } from 'pesa';
import { safeParseFloat } from 'utils/index';
@ -15,6 +21,7 @@ import { SalesQuote } from './baseModels/SalesQuote/SalesQuote';
import { StockMovement } from './inventory/StockMovement';
import { StockTransfer } from './inventory/StockTransfer';
import { InvoiceStatus, ModelNameEnum } from './types';
import { Lead } from './baseModels/Lead/Lead';
export function getQuoteActions(
fyo: Fyo,
@ -23,6 +30,10 @@ export function getQuoteActions(
return [getMakeInvoiceAction(fyo, schemaName)];
}
export function getLeadActions(fyo: Fyo): Action[] {
return [getCreateCustomerAction(fyo), getSalesQuoteAction(fyo)];
}
export function getInvoiceActions(
fyo: Fyo,
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 {
return {
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<
DocStatus | InvoiceStatus,
DocStatus | InvoiceStatus | LeadStatus,
string | undefined
> = {
'': 'gray',
Draft: 'gray',
Open: 'gray',
Replied: 'yellow',
Opportunity: 'yellow',
Unpaid: 'orange',
Paid: 'green',
Interested: 'yellow',
Converted: 'green',
Quotation: 'green',
Saved: 'gray',
NotSaved: 'gray',
Submitted: 'green',
Cancelled: 'red',
DonotContact: 'red',
Return: '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(
doc?: RenderData | Doc
): DocStatus | InvoiceStatus {

View File

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

View File

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

View File

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

View File

@ -100,6 +100,13 @@
"default": false,
"section": "Features"
},
{
"fieldname": "enableLead",
"label": "Enable Lead",
"fieldtype": "Check",
"default": false,
"section": "Features"
},
{
"fieldname": "fiscalYearStart",
"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,
"section": "Billing"
},
{
"fieldname": "fromLead",
"label": "From Lead",
"fieldtype": "Link",
"target": "Lead",
"readOnly": true,
"section": "References"
},
{
"fieldname": "taxId",
"label": "Tax ID",

View File

@ -15,11 +15,29 @@
"default": "SQUOT-",
"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",
"label": "Customer",
"fieldtype": "Link",
"target": "Party",
"fieldtype": "DynamicLink",
"references": "referenceType",
"create": true,
"required": true,
"section": "Default"

View File

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

View File

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