2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +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'
| '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,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';
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
@ -49,6 +57,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,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 { 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,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 {
return {
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<
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 +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(
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[],
},
{