mirror of
https://github.com/frappe/books.git
synced 2024-11-14 01:14:03 +00:00
591e7b3163
- fix model exports - add a README for models base
717 lines
17 KiB
TypeScript
717 lines
17 KiB
TypeScript
import telemetry from '@/telemetry/telemetry';
|
|
import { Verb } from '@/telemetry/types';
|
|
import { DocValue, DocValueMap } from 'frappe/core/types';
|
|
import {
|
|
Conflict,
|
|
MandatoryError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
} from 'frappe/utils/errors';
|
|
import Observable from 'frappe/utils/observable';
|
|
import Money from 'pesa/dist/types/src/money';
|
|
import {
|
|
Field,
|
|
FieldTypeEnum,
|
|
OptionField,
|
|
Schema,
|
|
TargetField,
|
|
} from 'schemas/types';
|
|
import { getIsNullOrUndef, getMapFromList } from 'utils';
|
|
import frappe from '..';
|
|
import { getRandomString, isPesa } from '../utils/index';
|
|
import {
|
|
areDocValuesEqual,
|
|
getMissingMandatoryMessage,
|
|
getPreDefaultValues,
|
|
shouldApplyFormula,
|
|
} from './helpers';
|
|
import { setName } from './naming';
|
|
import {
|
|
Action,
|
|
DefaultMap,
|
|
DependsOnMap,
|
|
EmptyMessageMap,
|
|
FiltersMap,
|
|
FormulaMap,
|
|
FormulaReturn,
|
|
HiddenMap,
|
|
ListsMap,
|
|
ListViewSettings,
|
|
RequiredMap,
|
|
TreeViewSettings,
|
|
ValidationMap,
|
|
} from './types';
|
|
import { validateSelect } from './validationFunction';
|
|
|
|
export default class Doc extends Observable<DocValue | Doc[]> {
|
|
name?: string;
|
|
schema: Readonly<Schema>;
|
|
fieldMap: Record<string, Field>;
|
|
|
|
/**
|
|
* Fields below are used by child docs to maintain
|
|
* reference w.r.t their parent doc.
|
|
*/
|
|
idx?: number;
|
|
parentdoc?: Doc;
|
|
parentfield?: string;
|
|
|
|
_links?: Record<string, Doc>;
|
|
_dirty: boolean = true;
|
|
_notInserted: boolean = true;
|
|
|
|
flags = {
|
|
submitAction: false,
|
|
revertAction: false,
|
|
};
|
|
|
|
constructor(schema: Schema, data: DocValueMap) {
|
|
super();
|
|
this.schema = schema;
|
|
this._setInitialValues(data);
|
|
this.fieldMap = getMapFromList(schema.fields, 'fieldname');
|
|
}
|
|
|
|
get schemaName(): string {
|
|
return this.schema.name;
|
|
}
|
|
|
|
get isNew(): boolean {
|
|
return this._notInserted;
|
|
}
|
|
|
|
get tableFields(): TargetField[] {
|
|
return this.schema.fields.filter(
|
|
(f) => f.fieldtype === FieldTypeEnum.Table
|
|
) as TargetField[];
|
|
}
|
|
|
|
get dirty() {
|
|
return this._dirty;
|
|
}
|
|
|
|
_setInitialValues(data: DocValueMap) {
|
|
for (const fieldname in data) {
|
|
const value = data[fieldname];
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const row of value) {
|
|
this.push(fieldname, row);
|
|
}
|
|
} else {
|
|
this[fieldname] = value;
|
|
}
|
|
}
|
|
|
|
// set unset fields as null
|
|
for (const field of this.schema.fields) {
|
|
if (this[field.fieldname] === undefined) {
|
|
this[field.fieldname] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
setDirty(value: boolean) {
|
|
this._dirty = value;
|
|
if (this.schema.isChild && this.parentdoc) {
|
|
this.parentdoc._dirty = value;
|
|
}
|
|
}
|
|
|
|
// set value and trigger change
|
|
async set(fieldname: string | DocValueMap, value?: DocValue | Doc[]) {
|
|
if (typeof fieldname === 'object') {
|
|
this.setMultiple(fieldname as DocValueMap);
|
|
return;
|
|
}
|
|
|
|
if (fieldname === 'numberSeries' && !this._notInserted) {
|
|
return;
|
|
}
|
|
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.fieldMap[fieldname] === undefined ||
|
|
(this[fieldname] !== undefined &&
|
|
areDocValuesEqual(this[fieldname] as DocValue, value as DocValue))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.setDirty(true);
|
|
if (Array.isArray(value)) {
|
|
this[fieldname] = value.map((row, i) => {
|
|
row.idx = i;
|
|
return row;
|
|
});
|
|
} else {
|
|
const field = this.fieldMap[fieldname];
|
|
await this.validateField(field, value);
|
|
this[fieldname] = value;
|
|
}
|
|
|
|
// always run applyChange from the parentdoc
|
|
if (this.schema.isChild && this.parentdoc) {
|
|
await this.applyChange(fieldname);
|
|
await this.parentdoc.applyChange(this.parentfield as string);
|
|
} else {
|
|
await this.applyChange(fieldname);
|
|
}
|
|
}
|
|
|
|
async setMultiple(docValueMap: DocValueMap) {
|
|
for (const fieldname in docValueMap) {
|
|
await this.set(fieldname, docValueMap[fieldname] as DocValue | Doc[]);
|
|
}
|
|
}
|
|
|
|
async applyChange(fieldname: string) {
|
|
await this.applyFormula(fieldname);
|
|
await this.trigger('change', {
|
|
doc: this,
|
|
changed: fieldname,
|
|
});
|
|
}
|
|
|
|
setDefaults() {
|
|
for (const field of this.schema.fields) {
|
|
if (!getIsNullOrUndef(this[field.fieldname])) {
|
|
continue;
|
|
}
|
|
|
|
let defaultValue: DocValue | Doc[] = getPreDefaultValues(field.fieldtype);
|
|
const defaultFunction = this.defaults[field.fieldname];
|
|
|
|
if (defaultFunction !== undefined) {
|
|
defaultValue = defaultFunction();
|
|
} else if (field.default !== undefined) {
|
|
defaultValue = field.default;
|
|
}
|
|
|
|
if (field.fieldtype === 'Currency' && !isPesa(defaultValue)) {
|
|
defaultValue = frappe.pesa!(defaultValue as string | number);
|
|
}
|
|
|
|
this[field.fieldname] = defaultValue;
|
|
}
|
|
}
|
|
|
|
append(fieldname: string, docValueMap: Doc | DocValueMap = {}) {
|
|
// push child row and trigger change
|
|
this.push(fieldname, docValueMap);
|
|
this._dirty = true;
|
|
this.applyChange(fieldname);
|
|
}
|
|
|
|
push(fieldname: string, docValueMap: Doc | DocValueMap = {}) {
|
|
// push child row without triggering change
|
|
this[fieldname] ??= [];
|
|
const childDoc = this._initChild(docValueMap, fieldname);
|
|
(this[fieldname] as Doc[]).push(childDoc);
|
|
}
|
|
|
|
_initChild(docValueMap: Doc | DocValueMap, fieldname: string): Doc {
|
|
if (docValueMap instanceof Doc) {
|
|
return docValueMap;
|
|
}
|
|
|
|
const data: Record<string, unknown> = Object.assign({}, docValueMap);
|
|
|
|
data.parent = this.name;
|
|
data.parenttype = this.schemaName;
|
|
data.parentfield = fieldname;
|
|
data.parentdoc = this;
|
|
|
|
if (!data.idx) {
|
|
data.idx = ((this[fieldname] as Doc[]) || []).length;
|
|
}
|
|
|
|
if (!data.name) {
|
|
data.name = getRandomString();
|
|
}
|
|
|
|
const childSchemaName = this.fieldMap[fieldname] as TargetField;
|
|
const schema = frappe.db.schemaMap[childSchemaName.target] as Schema;
|
|
const childDoc = new Doc(schema, data as DocValueMap);
|
|
childDoc.setDefaults();
|
|
return childDoc;
|
|
}
|
|
|
|
async validateInsert() {
|
|
this.validateMandatory();
|
|
await this.validateFields();
|
|
}
|
|
|
|
validateMandatory() {
|
|
const checkForMandatory: Doc[] = [this];
|
|
const tableFields = this.schema.fields.filter(
|
|
(f) => f.fieldtype === FieldTypeEnum.Table
|
|
) as TargetField[];
|
|
|
|
for (const field of tableFields) {
|
|
const childDocs = this.get(field.fieldname) as Doc[];
|
|
checkForMandatory.push(...childDocs);
|
|
}
|
|
|
|
const missingMandatoryMessage = checkForMandatory
|
|
.map((doc) => getMissingMandatoryMessage(doc))
|
|
.filter(Boolean);
|
|
|
|
if (missingMandatoryMessage.length > 0) {
|
|
const fields = missingMandatoryMessage.join('\n');
|
|
const message = frappe.t`Value missing for ${fields}`;
|
|
throw new MandatoryError(message);
|
|
}
|
|
}
|
|
|
|
async validateFields() {
|
|
const fields = this.schema.fields;
|
|
for (const field of fields) {
|
|
if (field.fieldtype === FieldTypeEnum.Table) {
|
|
continue;
|
|
}
|
|
|
|
const value = this.get(field.fieldname) as DocValue;
|
|
await this.validateField(field, value);
|
|
}
|
|
}
|
|
|
|
async validateField(field: Field, value: DocValue) {
|
|
if (field.fieldtype == 'Select') {
|
|
validateSelect(field as OptionField, value as string);
|
|
}
|
|
|
|
if (value === null || value === undefined) {
|
|
return;
|
|
}
|
|
|
|
const validator = this.validations[field.fieldname];
|
|
if (validator === undefined) {
|
|
return;
|
|
}
|
|
|
|
await validator(value);
|
|
}
|
|
|
|
getValidDict(): DocValueMap {
|
|
const data: DocValueMap = {};
|
|
for (const field of this.schema.fields) {
|
|
let value = this[field.fieldname] as DocValue | DocValueMap[];
|
|
|
|
if (Array.isArray(value)) {
|
|
value = value.map((doc) => (doc as Doc).getValidDict());
|
|
}
|
|
|
|
if (isPesa(value)) {
|
|
value = (value as Money).copy();
|
|
}
|
|
|
|
data[field.fieldname] = value;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
setBaseMetaValues() {
|
|
if (this.schema.isSubmittable && typeof this.submitted !== 'boolean') {
|
|
this.submitted = false;
|
|
this.cancelled = false;
|
|
}
|
|
|
|
if (!this.createdBy) {
|
|
this.createdBy = frappe.auth.session.user;
|
|
}
|
|
|
|
if (!this.created) {
|
|
this.created = new Date();
|
|
}
|
|
|
|
this.updateModified();
|
|
}
|
|
|
|
updateModified() {
|
|
this.modifiedBy = frappe.auth.session.user;
|
|
this.modified = new Date();
|
|
}
|
|
|
|
async load() {
|
|
if (this.name === undefined) {
|
|
return;
|
|
}
|
|
|
|
const data = await frappe.db.get(this.schemaName, this.name);
|
|
if (data && data.name) {
|
|
this.syncValues(data);
|
|
if (this.schema.isSingle) {
|
|
this.setDefaults();
|
|
}
|
|
|
|
await this.loadLinks();
|
|
} else {
|
|
throw new NotFoundError(`Not Found: ${this.schemaName} ${this.name}`);
|
|
}
|
|
}
|
|
|
|
async loadLinks() {
|
|
this._links = {};
|
|
const inlineLinks = this.schema.fields.filter((f) => f.inline);
|
|
for (const f of inlineLinks) {
|
|
await this.loadLink(f.fieldname);
|
|
}
|
|
}
|
|
|
|
async loadLink(fieldname: string) {
|
|
this._links ??= {};
|
|
const field = this.fieldMap[fieldname] as TargetField;
|
|
if (field === undefined) {
|
|
return;
|
|
}
|
|
|
|
const value = this.get(fieldname);
|
|
if (getIsNullOrUndef(value) || field.target === undefined) {
|
|
return;
|
|
}
|
|
|
|
this._links[fieldname] = await frappe.doc.getDoc(
|
|
field.target,
|
|
value as string
|
|
);
|
|
}
|
|
|
|
getLink(fieldname: string) {
|
|
return this._links ? this._links[fieldname] : null;
|
|
}
|
|
|
|
syncValues(data: DocValueMap) {
|
|
this.clearValues();
|
|
this._setInitialValues(data);
|
|
this._dirty = false;
|
|
this.trigger('change', {
|
|
doc: this,
|
|
});
|
|
}
|
|
|
|
clearValues() {
|
|
for (const { fieldname } of this.schema.fields) {
|
|
this[fieldname] = null;
|
|
}
|
|
|
|
this._dirty = true;
|
|
this._notInserted = true;
|
|
}
|
|
|
|
setChildIdx() {
|
|
const childFields = this.schema.fields.filter(
|
|
(f) => f.fieldtype === FieldTypeEnum.Table
|
|
) as TargetField[];
|
|
|
|
for (const field of childFields) {
|
|
const childDocs = (this.get(field.fieldname) as Doc[]) ?? [];
|
|
|
|
for (let i = 0; i < childDocs.length; i++) {
|
|
childDocs[i].idx = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
async compareWithCurrentDoc() {
|
|
if (this.isNew || !this.name) {
|
|
return;
|
|
}
|
|
|
|
const currentDoc = await frappe.db.get(this.schemaName, this.name);
|
|
|
|
// check for conflict
|
|
if (
|
|
currentDoc &&
|
|
(this.modified as Date) !== (currentDoc.modified as Date)
|
|
) {
|
|
throw new Conflict(
|
|
frappe.t`Document ${this.schemaName} ${this.name} has been modified after loading`
|
|
);
|
|
}
|
|
|
|
if (this.submitted && !this.schema.isSubmittable) {
|
|
throw new ValidationError(
|
|
frappe.t`Document type ${this.schemaName} is not submittable`
|
|
);
|
|
}
|
|
|
|
// set submit action flag
|
|
if (this.submitted && !currentDoc.submitted) {
|
|
this.flags.submitAction = true;
|
|
}
|
|
|
|
if (currentDoc.submitted && !this.submitted) {
|
|
this.flags.revertAction = true;
|
|
}
|
|
}
|
|
|
|
async applyFormula(fieldname?: string) {
|
|
if (fieldname && this.formulas[fieldname] === undefined) {
|
|
return false;
|
|
}
|
|
|
|
const doc = this;
|
|
let changed = false;
|
|
|
|
const childDocs = this.tableFields
|
|
.map((f) => (this.get(f.fieldname) as Doc[]) ?? [])
|
|
.flat();
|
|
|
|
// children
|
|
for (const row of childDocs) {
|
|
const formulaFields = Object.keys(this.formulas).map(
|
|
(fn) => this.fieldMap[fn]
|
|
);
|
|
|
|
changed ||= await this.applyFormulaForFields(
|
|
formulaFields,
|
|
row,
|
|
fieldname
|
|
);
|
|
}
|
|
|
|
// parent or child row
|
|
const formulaFields = Object.keys(this.formulas).map(
|
|
(fn) => this.fieldMap[fn]
|
|
);
|
|
changed ||= await this.applyFormulaForFields(formulaFields, doc, fieldname);
|
|
return changed;
|
|
}
|
|
|
|
async applyFormulaForFields(
|
|
formulaFields: Field[],
|
|
doc: Doc,
|
|
fieldname?: string
|
|
) {
|
|
let changed = false;
|
|
for (const field of formulaFields) {
|
|
if (!shouldApplyFormula(field, doc, fieldname)) {
|
|
continue;
|
|
}
|
|
|
|
const newVal = await this.getValueFromFormula(field, doc);
|
|
const previousVal = doc.get(field.fieldname);
|
|
const isSame = areDocValuesEqual(newVal as DocValue, previousVal);
|
|
if (newVal === undefined || isSame) {
|
|
continue;
|
|
}
|
|
|
|
doc[field.fieldname] = newVal;
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
async getValueFromFormula(field: Field, doc: Doc) {
|
|
let value: FormulaReturn;
|
|
|
|
const formula = doc.formulas[field.fieldtype];
|
|
if (formula === undefined) {
|
|
return;
|
|
}
|
|
|
|
value = await formula();
|
|
if (Array.isArray(value) && field.fieldtype === FieldTypeEnum.Table) {
|
|
value = value.map((row) => this._initChild(row, field.fieldname));
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
async commit() {
|
|
// re-run triggers
|
|
this.setChildIdx();
|
|
await this.applyFormula();
|
|
await this.trigger('validate', null);
|
|
}
|
|
|
|
async insert() {
|
|
await setName(this);
|
|
this.setBaseMetaValues();
|
|
await this.commit();
|
|
await this.validateInsert();
|
|
await this.trigger('beforeInsert', null);
|
|
|
|
const oldName = this.name!;
|
|
const data = await frappe.db.insert(this.schemaName, this.getValidDict());
|
|
this.syncValues(data);
|
|
|
|
if (oldName !== this.name) {
|
|
frappe.doc.removeFromCache(this.schemaName, oldName);
|
|
}
|
|
|
|
await this.trigger('afterInsert', null);
|
|
await this.trigger('afterSave', null);
|
|
|
|
telemetry.log(Verb.Created, this.schemaName);
|
|
return this;
|
|
}
|
|
|
|
async update() {
|
|
await this.compareWithCurrentDoc();
|
|
await this.commit();
|
|
await this.trigger('beforeUpdate');
|
|
|
|
// before submit
|
|
if (this.flags.submitAction) await this.trigger('beforeSubmit');
|
|
if (this.flags.revertAction) await this.trigger('beforeRevert');
|
|
|
|
// update modifiedBy and modified
|
|
this.updateModified();
|
|
|
|
const data = this.getValidDict();
|
|
await frappe.db.update(this.schemaName, data);
|
|
this.syncValues(data);
|
|
|
|
await this.trigger('afterUpdate');
|
|
await this.trigger('afterSave');
|
|
|
|
// after submit
|
|
if (this.flags.submitAction) await this.trigger('afterSubmit');
|
|
if (this.flags.revertAction) await this.trigger('afterRevert');
|
|
|
|
return this;
|
|
}
|
|
|
|
async insertOrUpdate() {
|
|
if (this._notInserted) {
|
|
return await this.insert();
|
|
} else {
|
|
return await this.update();
|
|
}
|
|
}
|
|
|
|
async delete() {
|
|
await this.trigger('beforeDelete');
|
|
await frappe.db.delete(this.schemaName, this.name!);
|
|
await this.trigger('afterDelete');
|
|
|
|
telemetry.log(Verb.Deleted, this.schemaName);
|
|
}
|
|
|
|
async submitOrRevert(isSubmit: boolean) {
|
|
const wasSubmitted = this.submitted;
|
|
this.submitted = isSubmit;
|
|
try {
|
|
await this.update();
|
|
} catch (e) {
|
|
this.submitted = wasSubmitted;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async submit() {
|
|
this.cancelled = false;
|
|
await this.submitOrRevert(true);
|
|
}
|
|
|
|
async revert() {
|
|
await this.submitOrRevert(false);
|
|
}
|
|
|
|
async rename(newName: string) {
|
|
await this.trigger('beforeRename');
|
|
await frappe.db.rename(this.schemaName, this.name!, newName);
|
|
this.name = newName;
|
|
await this.trigger('afterRename');
|
|
}
|
|
|
|
async trigger(event: string, params?: unknown) {
|
|
if (this[event]) {
|
|
await (this[event] as Function)(params);
|
|
}
|
|
|
|
await super.trigger(event, params);
|
|
}
|
|
|
|
getSum(tablefield: string, childfield: string, convertToFloat = true) {
|
|
const childDocs = (this.get(tablefield) as Doc[]) ?? [];
|
|
const sum = childDocs
|
|
.map((d) => {
|
|
const value = d.get(childfield) ?? 0;
|
|
if (!isPesa(value)) {
|
|
try {
|
|
return frappe.pesa(value as string | number);
|
|
} catch (err) {
|
|
(
|
|
err as Error
|
|
).message += ` value: '${value}' of type: ${typeof value}, fieldname: '${tablefield}', childfield: '${childfield}'`;
|
|
throw err;
|
|
}
|
|
}
|
|
return value as Money;
|
|
})
|
|
.reduce((a, b) => a.add(b), frappe.pesa(0));
|
|
|
|
if (convertToFloat) {
|
|
return sum.float;
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
getFrom(schemaName: string, name: string, fieldname: string) {
|
|
if (name === undefined || fieldname === undefined) {
|
|
return '';
|
|
}
|
|
|
|
return frappe.doc.getCachedValue(schemaName, name, fieldname);
|
|
}
|
|
|
|
async duplicate(shouldInsert: boolean = true): Promise<Doc> {
|
|
const updateMap: DocValueMap = {};
|
|
const docValueMap = this.getValidDict();
|
|
const fieldnames = this.schema.fields.map((f) => f.fieldname);
|
|
|
|
for (const fn of fieldnames) {
|
|
const value = docValueMap[fn];
|
|
if (getIsNullOrUndef(value)) {
|
|
continue;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((row) => {
|
|
delete row.name;
|
|
delete row.parent;
|
|
});
|
|
}
|
|
|
|
updateMap[fn] = value;
|
|
}
|
|
|
|
if (this.numberSeries) {
|
|
delete updateMap.name;
|
|
} else {
|
|
updateMap.name = updateMap.name + ' CPY';
|
|
}
|
|
|
|
const doc = frappe.doc.getEmptyDoc(this.schemaName, false);
|
|
await doc.setMultiple(updateMap);
|
|
|
|
if (shouldInsert) {
|
|
await doc.insert();
|
|
}
|
|
|
|
return doc;
|
|
}
|
|
|
|
formulas: FormulaMap = {};
|
|
defaults: DefaultMap = {};
|
|
validations: ValidationMap = {};
|
|
required: RequiredMap = {};
|
|
hidden: HiddenMap = {};
|
|
dependsOn: DependsOnMap = {};
|
|
|
|
static lists: ListsMap = {};
|
|
static filters: FiltersMap = {};
|
|
static emptyMessages: EmptyMessageMap = {};
|
|
static listSettings: ListViewSettings = {};
|
|
static treeSettings?: TreeViewSettings;
|
|
|
|
static actions: Action[] = [];
|
|
}
|