2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +00:00

incr: type Party, JournalEntry

- add removeFields
- fix Party schema
This commit is contained in:
18alantom 2022-04-14 10:54:11 +05:30
parent 71800dffd6
commit b08a3b9d52
25 changed files with 349 additions and 491 deletions

View File

@ -33,6 +33,7 @@ import {
EmptyMessageMap,
FiltersMap,
FormulaMap,
HiddenMap,
ListsMap,
ListViewSettings,
RequiredMap,
@ -702,6 +703,7 @@ export default class Doc extends Observable<DocValue | Doc[]> {
defaults: DefaultMap = {};
validations: ValidationMap = {};
required: RequiredMap = {};
hidden: HiddenMap = {};
dependsOn: DependsOnMap = {};
static lists: ListsMap = {};

View File

@ -20,12 +20,13 @@ export type Formula = () => Promise<DocValue | undefined>;
export type Default = () => DocValue;
export type Validation = (value: DocValue) => Promise<void>;
export type Required = () => boolean;
export type Hidden = () => boolean;
export type FormulaMap = Record<string, Formula | undefined>;
export type DefaultMap = Record<string, Default | undefined>;
export type ValidationMap = Record<string, Validation | undefined>;
export type RequiredMap = Record<string, Required | undefined>;
export type HiddenMap = Record<string, Hidden>;
export type DependsOnMap = Record<string, string[]>;
/**
@ -55,6 +56,7 @@ export interface Action {
export interface ColumnConfig {
label: string;
fieldtype: FieldType;
fieldname?: string;
size?: string;
render?: (doc: Doc) => { template: string };
getValue?: (doc: Doc) => string;

View File

@ -63,7 +63,7 @@ export class Item extends Doc {
},
};
actions: Action[] = [
static actions: Action[] = [
{
label: frappe.t`New Invoice`,
condition: (doc) => !doc.isNew,

View File

@ -1,92 +0,0 @@
import { t } from 'frappe';
import { DateTime } from 'luxon';
import { ledgerLink } from '../../../accounting/utils';
import { DEFAULT_NUMBER_SERIES } from '../../../frappe/utils/consts';
export const journalEntryTypeMap = {
'Journal Entry': t`Journal Entry`,
'Bank Entry': t`Bank Entry`,
'Cash Entry': t`Cash Entry`,
'Credit Card Entry': t`Credit Card Entry`,
'Debit Note': t`Debit Note`,
'Credit Note': t`Credit Note`,
'Contra Entry': t`Contra Entry`,
'Excise Entry': t`Excise Entry`,
'Write Off Entry': t`Write Off Entry`,
'Opening Entry': t`Opening Entry`,
'Depreciation Entry': t`Depreciation Entry`,
};
export default {
label: t`Journal Entry`,
name: 'JournalEntry',
doctype: 'DocType',
isSubmittable: 1,
settings: 'JournalEntrySettings',
fields: [
{
fieldname: 'entryType',
label: t`Entry Type`,
fieldtype: 'Select',
placeholder: t`Entry Type`,
options: Object.keys(journalEntryTypeMap),
map: journalEntryTypeMap,
required: 1,
},
{
label: t`Entry No`,
fieldname: 'name',
fieldtype: 'Data',
required: 1,
readOnly: 1,
},
{
fieldname: 'date',
label: t`Date`,
fieldtype: 'Date',
default: () => DateTime.local().toISODate(),
},
{
fieldname: 'accounts',
label: t`Account Entries`,
fieldtype: 'Table',
childtype: 'JournalEntryAccount',
required: true,
},
{
fieldname: 'referenceNumber',
label: t`Reference Number`,
fieldtype: 'Data',
},
{
fieldname: 'referenceDate',
label: t`Reference Date`,
fieldtype: 'Date',
},
{
fieldname: 'userRemark',
label: t`User Remark`,
fieldtype: 'Text',
placeholder: t`User Remark`,
},
{
fieldname: 'cancelled',
label: t`Cancelled`,
fieldtype: 'Check',
default: 0,
readOnly: 1,
},
{
fieldname: 'numberSeries',
label: t`Number Series`,
fieldtype: 'Link',
target: 'NumberSeries',
required: 1,
getFilters: () => {
return { referenceType: 'JournalEntry' };
},
default: DEFAULT_NUMBER_SERIES['JournalEntry'],
},
],
actions: [ledgerLink],
};

View File

@ -0,0 +1,99 @@
import frappe from 'frappe';
import Doc from 'frappe/model/doc';
import {
Action,
DefaultMap,
FiltersMap,
ListViewSettings,
} from 'frappe/model/types';
import { DateTime } from 'luxon';
import { getLedgerLinkAction } from 'models/helpers';
import Money from 'pesa/dist/types/src/money';
import { LedgerPosting } from '../../../accounting/ledgerPosting';
export class JournalEntry extends Doc {
accounts: Doc[] = [];
getPosting() {
const entries = new LedgerPosting({ reference: this });
for (const row of this.accounts) {
const debit = row.debit as Money;
const credit = row.credit as Money;
const account = row.account as string;
if (!debit.isZero()) {
entries.debit(account, debit);
} else if (!credit.isZero()) {
entries.credit(account, credit);
}
}
return entries;
}
beforeUpdate() {
this.getPosting().validateEntries();
}
beforeInsert() {
this.getPosting().validateEntries();
}
async beforeSubmit() {
await this.getPosting().post();
}
async afterRevert() {
await this.getPosting().postReverse();
}
defaults: DefaultMap = {
date: () => DateTime.local().toISODate(),
};
static filters: FiltersMap = {
numberSeries: () => ({ referenceType: 'JournalEntry' }),
};
static actions: Action[] = [getLedgerLinkAction()];
static listSettings: ListViewSettings = {
formRoute: (name) => `/edit/JournalEntry/${name}`,
columns: [
'date',
{
label: frappe.t`Status`,
fieldtype: 'Select',
size: 'small',
render(doc) {
let status = 'Draft';
let color = 'gray';
if (doc.submitted) {
color = 'green';
status = 'Submitted';
}
if (doc.cancelled) {
color = 'red';
status = 'Cancelled';
}
return {
template: `<Badge class="text-xs" color="${color}">${status}</Badge>`,
};
},
},
{
label: frappe.t`Entry ID`,
fieldtype: 'Data',
fieldname: 'name',
getValue(doc) {
return doc.name as string;
},
},
'entryType',
'referenceNumber',
],
};
}

View File

@ -1,44 +0,0 @@
import Badge from '@/components/Badge';
import { t } from 'frappe';
export default {
doctype: 'JournalEntry',
title: t`Journal Entry`,
formRoute: (name) => `/edit/JournalEntry/${name}`,
columns: [
'date',
{
label: t`Status`,
fieldtype: 'Select',
size: 'small',
render(doc) {
let status = 'Draft';
let color = 'gray';
if (doc.submitted === 1) {
color = 'green';
status = 'Submitted';
}
if (doc.cancelled === 1) {
color = 'red';
status = 'Cancelled';
}
return {
template: `<Badge class="text-xs" color="${color}">${status}</Badge>`,
components: { Badge },
};
},
},
{
label: t`Entry ID`,
fieldname: 'name',
fieldtype: 'Data',
getValue(doc) {
return doc.name;
},
},
'entryType',
'referenceNumber',
],
};

View File

@ -1,34 +0,0 @@
import Document from 'frappe/model/document';
import LedgerPosting from '../../../accounting/ledgerPosting';
export default class JournalEntryServer extends Document {
getPosting() {
let entries = new LedgerPosting({ reference: this });
for (let row of this.accounts) {
if (!row.debit.isZero()) {
entries.debit(row.account, row.debit);
} else if (!row.credit.isZero()) {
entries.credit(row.account, row.credit);
}
}
return entries;
}
beforeUpdate() {
this.getPosting().validateEntries();
}
beforeInsert() {
this.getPosting().validateEntries();
}
async beforeSubmit() {
await this.getPosting().post();
}
async afterRevert() {
await this.getPosting().postReverse();
}
}

View File

@ -1,53 +0,0 @@
import router from '@/router';
import frappe, { t } from 'frappe';
import { h } from 'vue';
import PartyWidget from './PartyWidget.vue';
export default {
name: 'Customer',
label: t`Customer`,
basedOn: 'Party',
filters: {
customer: 1,
},
actions: [
{
label: t`Create Invoice`,
condition: (doc) => !doc.isNew(),
action: async (customer) => {
let doc = await frappe.getEmptyDoc('SalesInvoice');
router.push({
path: `/edit/SalesInvoice/${doc.name}`,
query: {
doctype: 'SalesInvoice',
values: {
customer: customer.name,
},
},
});
},
},
{
label: t`View Invoices`,
condition: (doc) => !doc.isNew(),
action: (customer) => {
router.push({
name: 'ListView',
params: {
doctype: 'SalesInvoice',
filters: {
customer: customer.name,
},
},
});
},
},
],
quickEditWidget: (doc) => ({
render() {
return h(PartyWidget, {
doc,
});
},
}),
};

View File

@ -1,7 +0,0 @@
import { t } from 'frappe';
export default {
doctype: 'Customer',
title: t`Customers`,
columns: ['name', 'phone', 'outstandingAmount'],
};

View File

@ -1,105 +0,0 @@
import frappe, { t } from 'frappe';
export default {
name: 'Party',
label: t`Party`,
regional: 1,
keywordFields: ['name'],
fields: [
{
fieldname: 'name',
label: t`Name`,
fieldtype: 'Data',
required: 1,
placeholder: t`Full Name`,
},
{
fieldname: 'image',
label: t`Image`,
fieldtype: 'AttachImage',
},
{
fieldname: 'customer',
label: t`Is Customer`,
fieldtype: 'Check',
},
{
fieldname: 'supplier',
label: t`Is Supplier`,
fieldtype: 'Check',
},
{
fieldname: 'defaultAccount',
label: t`Default Account`,
fieldtype: 'Link',
target: 'Account',
getFilters: (query, doc) => {
return {
isGroup: 0,
accountType: doc.customer ? 'Receivable' : 'Payable',
};
},
async formula(doc) {
let accountName = 'Debtors'; // if Party is a Customer
if (doc.supplier) {
accountName = 'Creditors';
}
const accountExists = await frappe.db.exists('Account', accountName);
return accountExists ? accountName : '';
},
},
{
fieldname: 'outstandingAmount',
label: t`Outstanding Amount`,
fieldtype: 'Currency',
},
{
fieldname: 'currency',
label: t`Currency`,
fieldtype: 'Link',
target: 'Currency',
placeholder: t`INR`,
formula: () => frappe.AccountingSettings.currency,
},
{
fieldname: 'email',
label: t`Email`,
fieldtype: 'Data',
placeholder: t`john@doe.com`,
validate: {
type: 'email',
},
},
{
fieldname: 'phone',
label: t`Phone`,
fieldtype: 'Data',
placeholder: t`Phone`,
validate: {
type: 'phone',
},
},
{
fieldname: 'address',
label: t`Address`,
fieldtype: 'Link',
target: 'Address',
placeholder: t`Click to create`,
inline: true,
},
{
fieldname: 'addressDisplay',
label: t`Address Display`,
fieldtype: 'Text',
readOnly: true,
formula: (doc) => {
if (doc.address) {
return doc.getFrom('Address', doc.address, 'addressDisplay');
}
},
},
],
quickEditFields: ['email', 'phone', 'address', 'defaultAccount', 'currency'],
};

View File

@ -0,0 +1,161 @@
import frappe from 'frappe';
import Doc from 'frappe/model/doc';
import {
Action,
FiltersMap,
FormulaMap,
ListViewSettings,
} from 'frappe/model/types';
import { PartyRole } from './types';
export class Party extends Doc {
async updateOutstandingAmounts() {
const role = this.role as PartyRole;
switch (role) {
case 'Customer':
await this._updateOutstandingAmount('SalesInvoice');
break;
case 'Supplier':
await this._updateOutstandingAmount('PurchaseInvoice');
break;
case 'Both':
await this._updateOutstandingAmount('SalesInvoice');
await this._updateOutstandingAmount('PurchaseInvoice');
break;
}
}
async _updateOutstandingAmount(
schemaName: 'SalesInvoice' | 'PurchaseInvoice'
) {
const outstandingAmounts = (
await frappe.db.getAllRaw(schemaName, {
fields: ['outstandingAmount', 'party'],
filters: { submitted: true },
})
).filter(({ party }) => party === this.name);
const totalOutstanding = outstandingAmounts
.map(({ outstandingAmount }) => frappe.pesa(outstandingAmount as number))
.reduce((a, b) => a.add(b), frappe.pesa(0));
await this.set('outstandingAmount', totalOutstanding);
await this.update();
}
formulas: FormulaMap = {
defaultAccount: async () => {
const role = this.role as PartyRole;
if (role === 'Both') {
return '';
}
let accountName = 'Debtors';
if (role === 'Supplier') {
accountName = 'Creditors';
}
const accountExists = await frappe.db.exists('Account', accountName);
return accountExists ? accountName : '';
},
currency: async () => frappe.singles.AccountingSettings!.currency as string,
addressDisplay: async () => {
const address = this.address as string | undefined;
if (address) {
return this.getFrom('Address', address, 'addressDisplay') as string;
}
return '';
},
};
static filters: FiltersMap = {
defaultAccount: (doc: Doc) => {
const role = doc.role as PartyRole;
if (role === 'Both') {
return { isGroup: false, accountType: ['Payable', 'Receivable'] };
}
return {
isGroup: false,
accountType: role === 'Customer' ? 'Receivable' : 'Payable',
};
},
};
static listSettings: ListViewSettings = {
columns: ['name', 'phone', 'outstandingAmount'],
};
static actions: Action[] = [
{
label: frappe.t`Create Bill`,
condition: (doc: Doc) =>
!doc.isNew && (doc.role as PartyRole) !== 'Customer',
action: async (partyDoc, router) => {
const doc = await frappe.doc.getEmptyDoc('PurchaseInvoice');
router.push({
path: `/edit/PurchaseInvoice/${doc.name}`,
query: {
doctype: 'PurchaseInvoice',
values: {
// @ts-ignore
party: partyDoc.name!,
},
},
});
},
},
{
label: frappe.t`View Bills`,
condition: (doc: Doc) =>
!doc.isNew && (doc.role as PartyRole) !== 'Customer',
action: async (partyDoc, router) => {
router.push({
name: 'ListView',
params: {
doctype: 'PurchaseInvoice',
filters: {
// @ts-ignore
party: partyDoc.name!,
},
},
});
},
},
{
label: frappe.t`Create Invoice`,
condition: (doc: Doc) =>
!doc.isNew && (doc.role as PartyRole) !== 'Supplier',
action: async (partyDoc, router) => {
const doc = await frappe.doc.getEmptyDoc('SalesInvoice');
router.push({
path: `/edit/SalesInvoice/${doc.name}`,
query: {
doctype: 'SalesInvoice',
values: {
// @ts-ignore
party: partyDoc.name!,
},
},
});
},
},
{
label: frappe.t`View Invoices`,
condition: (doc: Doc) =>
!doc.isNew && (doc.role as PartyRole) !== 'Supplier',
action: async (partyDoc, router) => {
router.push({
name: 'ListView',
params: {
doctype: 'SalesInvoice',
filters: {
// @ts-ignore
party: partyDoc.name!,
},
},
});
},
},
];
}

View File

@ -1,4 +0,0 @@
export default {
doctype: 'Party',
columns: ['name', 'phone', 'outstandingAmount'],
};

View File

@ -1,37 +0,0 @@
import frappe from 'frappe';
import Document from 'frappe/model/document';
export default class PartyServer extends Document {
beforeInsert() {
if (this.customer && this.supplier) {
throw new Error('Select a single party type.');
}
if (!this.customer && !this.supplier) {
this.supplier = 1;
}
if (this.gstin && ['Unregistered', 'Consumer'].includes(this.gstType)) {
this.gstin = '';
}
}
async updateOutstandingAmount() {
let isCustomer = this.customer;
let doctype = isCustomer ? 'SalesInvoice' : 'PurchaseInvoice';
const outstandingAmounts = (
await frappe.db.getAllRaw(doctype, {
fields: ['outstandingAmount', 'party'],
filters: { submitted: true },
})
).filter(({ party }) => party === this.name);
const totalOutstanding = outstandingAmounts
.map(({ outstandingAmount }) => frappe.pesa(outstandingAmount))
.reduce((a, b) => a.add(b), frappe.pesa(0));
await this.set('outstandingAmount', totalOutstanding);
await this.update();
}
}

View File

@ -1,43 +0,0 @@
import { t } from 'frappe';
import { cloneDeep } from 'lodash';
import PartyOriginal from './Party';
const gstTypes = ['Unregistered', 'Registered Regular', 'Consumer'];
export default function getAugmentedParty({ country }) {
const Party = cloneDeep(PartyOriginal);
if (!country) {
return Party;
}
if (country === 'India') {
Party.fields.splice(
3,
0,
{
fieldname: 'gstin',
label: t`GSTIN No.`,
fieldtype: 'Data',
hidden: (doc) => (doc.gstType === 'Registered Regular' ? 0 : 1),
},
{
fieldname: 'gstType',
label: t`GST Registration`,
placeholder: t`GST Registration`,
fieldtype: 'Select',
default: gstTypes[0],
options: gstTypes,
}
);
Party.quickEditFields.push('gstType');
Party.quickEditFields.push('gstin');
} else {
Party.fields.splice(3, 0, {
fieldname: 'taxId',
label: t`Tax ID`,
fieldtype: 'Data',
});
Party.quickEditFields.push('taxId');
}
return Party;
}

View File

@ -1,53 +0,0 @@
import router from '@/router';
import frappe, { t } from 'frappe';
import { h } from 'vue';
import PartyWidget from './PartyWidget.vue';
export default {
name: 'Supplier',
label: t`Supplier`,
basedOn: 'Party',
filters: {
supplier: 1,
},
actions: [
{
label: t`Create Bill`,
condition: (doc) => !doc.isNew(),
action: async (supplier) => {
let doc = await frappe.getEmptyDoc('PurchaseInvoice');
router.push({
path: `/edit/PurchaseInvoice/${doc.name}`,
query: {
doctype: 'PurchaseInvoice',
values: {
supplier: supplier.name,
},
},
});
},
},
{
label: t`View Bills`,
condition: (doc) => !doc.isNew(),
action: (supplier) => {
router.push({
name: 'ListView',
params: {
doctype: 'PurchaseInvoice',
filters: {
supplier: supplier.name,
},
},
});
},
},
],
quickEditWidget: (doc) => ({
render() {
return h(PartyWidget, {
doc,
});
},
}),
};

View File

@ -1,7 +0,0 @@
import { t } from 'frappe';
export default {
doctype: 'Supplier',
title: t`Supplier`,
columns: ['name', 'phone', 'outstandingAmount'],
};

View File

@ -0,0 +1 @@
export type PartyRole = 'Both' | 'Supplier' | 'Customer';

24
models/helpers.ts Normal file
View File

@ -0,0 +1,24 @@
import frappe from 'frappe';
import Doc from 'frappe/model/doc';
import { Action } from 'frappe/model/types';
import { Router } from 'vue-router';
export function getLedgerLinkAction(): Action {
return {
label: frappe.t`Ledger Entries`,
condition: (doc: Doc) => !!doc.submitted,
action: async (doc: Doc, router: Router) => {
router.push({
name: 'Report',
params: {
reportName: 'general-ledger',
defaultFilters: {
// @ts-ignore
referenceType: doc.schemaName,
referenceName: doc.name,
},
},
});
},
};
}

View File

@ -0,0 +1,18 @@
import { HiddenMap } from 'frappe/model/types';
import { Party as BaseParty } from 'models/baseModels/Party/Party';
import { GSTType } from './types';
export class Party extends BaseParty {
beforeInsert() {
const gstin = this.get('gstin') as string | undefined;
const gstType = this.get('gstType') as GSTType;
if (gstin && gstType !== 'Registered Regular') {
this.gstin = '';
}
}
hidden: HiddenMap = {
gstin: () => (this.gstType as GSTType) !== 'Registered Regular',
};
}

View File

@ -0,0 +1 @@
export type GSTType = 'Unregistered' | 'Registered Regular' | 'Consumer';

View File

@ -72,15 +72,13 @@
"target": "Address",
"placeholder": "Click to create",
"inline": true
},
{
"fieldname": "taxId",
"label": "Tax ID",
"fieldtype": "Data"
}
],
"quickEditFields": [
"email",
"role",
"phone",
"address",
"defaultAccount",
"currency"
],
"quickEditFields": ["email", "role", "phone", "address", "defaultAccount", "currency", "taxId"],
"keywordFields": ["name"]
}

View File

@ -18,10 +18,39 @@ export function getSchemas(countryCode: string = '-'): Readonly<SchemaMap> {
let schemaMap = Object.assign({}, builtAppSchemas, builtCoreSchemas);
schemaMap = addMetaFields(schemaMap);
schemaMap = removeFields(schemaMap);
deepFreeze(schemaMap);
return schemaMap;
}
function removeFields(schemaMap: SchemaMap): SchemaMap {
for (const schemaName in schemaMap) {
const schema = schemaMap[schemaName]!;
if (schema.removeFields === undefined) {
continue;
}
for (const fieldname of schema.removeFields) {
schema.fields = schema.fields.filter((f) => f.fieldname !== fieldname);
schema.tableFields = schema.tableFields?.filter((fn) => fn !== fieldname);
schema.quickEditFields = schema.quickEditFields?.filter(
(fn) => fn !== fieldname
);
schema.keywordFields = schema.keywordFields?.filter(
(fn) => fn !== fieldname
);
if (schema.inlineEditDisplayField === fieldname) {
delete schema.inlineEditDisplayField;
}
}
delete schema.removeFields;
}
return schemaMap;
}
function deepFreeze(schemaMap: SchemaMap) {
Object.freeze(schemaMap);
for (const schemaName in schemaMap) {

View File

@ -36,5 +36,6 @@
"currency",
"gstType",
"gstin"
]
],
"removeFields": ["taxId"]
}

View File

@ -124,8 +124,9 @@ export interface Schema {
keywordFields?: string[]; // Used to get fields that are to be used for search.
quickEditFields?: string[]; // Used to get fields for the quickEditForm
treeSettings?: TreeSettings; // Used to determine root nodes
inlineEditDisplayField?:string,// Display field if inline editable
inlineEditDisplayField?:string;// Display field if inline editable
naming?: Naming; // Used for assigning name, default is 'random' else 'numberSeries' if present
removeFields?: string[]; // Used by the builder to remove fields.
}
export interface SchemaStub extends Partial<Schema> {