mirror of
https://github.com/frappe/books.git
synced 2024-11-14 01:14:03 +00:00
82709165a4
- delete irrelevant tests
763 lines
18 KiB
JavaScript
763 lines
18 KiB
JavaScript
import telemetry from '@/telemetry/telemetry';
|
|
import { Verb } from '@/telemetry/types';
|
|
import frappe from 'frappe';
|
|
import Observable from 'frappe/utils/observable';
|
|
import { DEFAULT_INTERNAL_PRECISION } from '../utils/consts';
|
|
import { getRandomString, isPesa } from '../utils/index';
|
|
import { setName } from './naming';
|
|
|
|
export default class Document extends Observable {
|
|
constructor(data) {
|
|
super();
|
|
this.fetchValuesCache = {};
|
|
this.flags = {};
|
|
this.setup();
|
|
this.setValues(data);
|
|
}
|
|
|
|
setup() {
|
|
// add listeners
|
|
}
|
|
|
|
setValues(data) {
|
|
for (let fieldname in data) {
|
|
let value = data[fieldname];
|
|
if (fieldname.startsWith('_')) {
|
|
// private property
|
|
this[fieldname] = value;
|
|
} else if (Array.isArray(value)) {
|
|
for (let row of value) {
|
|
this.push(fieldname, row);
|
|
}
|
|
} else {
|
|
this[fieldname] = value;
|
|
}
|
|
}
|
|
// set unset fields as null
|
|
for (let field of this.meta.getValidFields()) {
|
|
// check for null or undefined
|
|
if (this[field.fieldname] == null) {
|
|
this[field.fieldname] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
get meta() {
|
|
if (this.isCustom) {
|
|
this._meta = frappe.createMeta(this.fields);
|
|
}
|
|
if (!this._meta) {
|
|
this._meta = frappe.getMeta(this.doctype);
|
|
}
|
|
return this._meta;
|
|
}
|
|
|
|
async getSettings() {
|
|
if (!this._settings) {
|
|
this._settings = await frappe.getSingle(this.meta.settings);
|
|
}
|
|
return this._settings;
|
|
}
|
|
|
|
// set value and trigger change
|
|
async set(fieldname, value) {
|
|
if (typeof fieldname === 'object') {
|
|
const valueDict = fieldname;
|
|
for (let fieldname in valueDict) {
|
|
await this.set(fieldname, valueDict[fieldname]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fieldname === 'numberSeries' && !this._notInserted) {
|
|
return;
|
|
}
|
|
|
|
if (this[fieldname] !== value) {
|
|
this._dirty = true;
|
|
// if child is dirty, parent is dirty too
|
|
if (this.meta.isChild && this.parentdoc) {
|
|
this.parentdoc._dirty = true;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
this[fieldname] = [];
|
|
value.forEach((row, i) => {
|
|
this.append(fieldname, row);
|
|
row.idx = i;
|
|
});
|
|
} else {
|
|
await this.validateField(fieldname, value);
|
|
this[fieldname] = value;
|
|
}
|
|
|
|
// always run applyChange from the parentdoc
|
|
if (this.meta.isChild && this.parentdoc) {
|
|
await this.applyChange(fieldname);
|
|
await this.parentdoc.applyChange(this.parentfield);
|
|
} else {
|
|
await this.applyChange(fieldname);
|
|
}
|
|
}
|
|
}
|
|
|
|
async applyChange(fieldname) {
|
|
await this.applyFormula(fieldname);
|
|
this.roundFloats();
|
|
await this.trigger('change', {
|
|
doc: this,
|
|
changed: fieldname,
|
|
});
|
|
}
|
|
|
|
setDefaults() {
|
|
for (let field of this.meta.fields) {
|
|
if (this[field.fieldname] == null) {
|
|
let defaultValue = getPreDefaultValues(field.fieldtype);
|
|
|
|
if (typeof field.default === 'function') {
|
|
defaultValue = field.default(this);
|
|
} else if (field.default !== undefined) {
|
|
defaultValue = field.default;
|
|
}
|
|
|
|
if (field.fieldtype === 'Currency' && !isPesa(defaultValue)) {
|
|
defaultValue = frappe.pesa(defaultValue);
|
|
}
|
|
|
|
this[field.fieldname] = defaultValue;
|
|
}
|
|
}
|
|
|
|
if (this.meta.basedOn && this.meta.filters) {
|
|
this.setValues(this.meta.filters);
|
|
}
|
|
}
|
|
|
|
castValues() {
|
|
for (let field of this.meta.fields) {
|
|
let value = this[field.fieldname];
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
if (['Int', 'Check'].includes(field.fieldtype)) {
|
|
value = parseInt(value, 10);
|
|
} else if (field.fieldtype === 'Float') {
|
|
value = parseFloat(value);
|
|
} else if (field.fieldtype === 'Currency' && !isPesa(value)) {
|
|
value = frappe.pesa(value);
|
|
}
|
|
this[field.fieldname] = value;
|
|
}
|
|
}
|
|
|
|
setKeywords() {
|
|
let keywords = [];
|
|
for (let fieldname of this.meta.getKeywordFields()) {
|
|
keywords.push(this[fieldname]);
|
|
}
|
|
this.keywords = keywords.join(', ');
|
|
}
|
|
|
|
append(key, document = {}) {
|
|
// push child row and trigger change
|
|
this.push(key, document);
|
|
this._dirty = true;
|
|
this.applyChange(key);
|
|
}
|
|
|
|
push(key, document = {}) {
|
|
// push child row without triggering change
|
|
if (!this[key]) {
|
|
this[key] = [];
|
|
}
|
|
this[key].push(this._initChild(document, key));
|
|
}
|
|
|
|
_initChild(data, key) {
|
|
if (data instanceof Document) {
|
|
return data;
|
|
}
|
|
|
|
data.doctype = this.meta.getField(key).childtype;
|
|
data.parent = this.name;
|
|
data.parenttype = this.doctype;
|
|
data.parentfield = key;
|
|
data.parentdoc = this;
|
|
|
|
if (!data.idx) {
|
|
data.idx = (this[key] || []).length;
|
|
}
|
|
|
|
if (!data.name) {
|
|
data.name = getRandomString();
|
|
}
|
|
|
|
const childDoc = new Document(data);
|
|
childDoc.setDefaults();
|
|
return childDoc;
|
|
}
|
|
|
|
async validateInsert() {
|
|
this.validateMandatory();
|
|
await this.validateFields();
|
|
}
|
|
|
|
validateMandatory() {
|
|
let checkForMandatory = [this];
|
|
let tableFields = this.meta.fields.filter((df) => df.fieldtype === 'Table');
|
|
tableFields.map((df) => {
|
|
let rows = this[df.fieldname];
|
|
checkForMandatory = [...checkForMandatory, ...rows];
|
|
});
|
|
|
|
let missingMandatory = checkForMandatory
|
|
.map((doc) => getMissingMandatory(doc))
|
|
.filter(Boolean);
|
|
|
|
if (missingMandatory.length > 0) {
|
|
let fields = missingMandatory.join('\n');
|
|
let message = frappe.t`Value missing for ${fields}`;
|
|
throw new frappe.errors.MandatoryError(message);
|
|
}
|
|
|
|
function getMissingMandatory(doc) {
|
|
let mandatoryFields = doc.meta.fields.filter((df) => {
|
|
if (df.required instanceof Function) {
|
|
return df.required(doc);
|
|
}
|
|
return df.required;
|
|
});
|
|
let message = mandatoryFields
|
|
.filter((df) => {
|
|
let value = doc[df.fieldname];
|
|
if (df.fieldtype === 'Table') {
|
|
return value == null || value.length === 0;
|
|
}
|
|
return value == null || value === '';
|
|
})
|
|
.map((df) => {
|
|
return `"${df.label}"`;
|
|
})
|
|
.join(', ');
|
|
|
|
if (message && doc.meta.isChild) {
|
|
let parentfield = doc.parentdoc.meta.getField(doc.parentfield);
|
|
message = `${parentfield.label} Row ${doc.idx + 1}: ${message}`;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
}
|
|
|
|
async validateFields() {
|
|
let fields = this.meta.fields;
|
|
for (let field of fields) {
|
|
await this.validateField(field.fieldname, this.get(field.fieldname));
|
|
}
|
|
}
|
|
|
|
async validateField(key, value) {
|
|
let field = this.meta.getField(key);
|
|
if (!field) {
|
|
throw new frappe.errors.InvalidFieldError(`Invalid field ${key}`);
|
|
}
|
|
if (field.fieldtype == 'Select') {
|
|
this.meta.validateSelect(field, value);
|
|
}
|
|
if (field.validate && value != null) {
|
|
let validator = null;
|
|
if (typeof field.validate === 'object') {
|
|
validator = this.getValidateFunction(field.validate);
|
|
}
|
|
if (typeof field.validate === 'function') {
|
|
validator = field.validate;
|
|
}
|
|
if (validator) {
|
|
await validator(value, this);
|
|
}
|
|
}
|
|
}
|
|
|
|
getValidateFunction(validator) {
|
|
let functions = {
|
|
email(value) {
|
|
let isValid = /(.+)@(.+){2,}\.(.+){2,}/.test(value);
|
|
if (!isValid) {
|
|
throw new frappe.errors.ValidationError(`Invalid email: ${value}`);
|
|
}
|
|
},
|
|
phone(value) {
|
|
let isValid = /[+]{0,1}[\d ]+/.test(value);
|
|
if (!isValid) {
|
|
throw new frappe.errors.ValidationError(`Invalid phone: ${value}`);
|
|
}
|
|
},
|
|
};
|
|
|
|
return functions[validator.type];
|
|
}
|
|
|
|
getValidDict() {
|
|
let data = {};
|
|
for (let field of this.meta.getValidFields()) {
|
|
let value = this[field.fieldname];
|
|
if (Array.isArray(value)) {
|
|
value = value.map((doc) =>
|
|
doc.getValidDict ? doc.getValidDict() : doc
|
|
);
|
|
}
|
|
data[field.fieldname] = value;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
setStandardValues() {
|
|
// set standard values on server-side only
|
|
if (frappe.isServer) {
|
|
if (this.isSubmittable && this.submitted == null) {
|
|
this.submitted = 0;
|
|
}
|
|
|
|
let now = new Date().toISOString();
|
|
if (!this.owner) {
|
|
this.owner = frappe.auth.session.user;
|
|
}
|
|
|
|
if (!this.creation) {
|
|
this.creation = now;
|
|
}
|
|
|
|
this.updateModified();
|
|
}
|
|
}
|
|
|
|
updateModified() {
|
|
if (frappe.isServer) {
|
|
let now = new Date().toISOString();
|
|
this.modifiedBy = frappe.auth.session.user;
|
|
this.modified = now;
|
|
}
|
|
}
|
|
|
|
async load() {
|
|
let data = await frappe.db.get(this.doctype, this.name);
|
|
if (data && data.name) {
|
|
this.syncValues(data);
|
|
if (this.meta.isSingle) {
|
|
this.setDefaults();
|
|
this.castValues();
|
|
}
|
|
await this.loadLinks();
|
|
} else {
|
|
throw new frappe.errors.NotFoundError(
|
|
`Not Found: ${this.doctype} ${this.name}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async loadLinks() {
|
|
this._links = {};
|
|
let inlineLinks = this.meta.fields.filter((df) => df.inline);
|
|
for (let df of inlineLinks) {
|
|
await this.loadLink(df.fieldname);
|
|
}
|
|
}
|
|
|
|
async loadLink(fieldname) {
|
|
this._links = this._links || {};
|
|
let df = this.meta.getField(fieldname);
|
|
if (this[df.fieldname]) {
|
|
this._links[df.fieldname] = await frappe.getDoc(
|
|
df.target,
|
|
this[df.fieldname]
|
|
);
|
|
}
|
|
}
|
|
|
|
getLink(fieldname) {
|
|
return this._links ? this._links[fieldname] : null;
|
|
}
|
|
|
|
syncValues(data) {
|
|
this.clearValues();
|
|
this.setValues(data);
|
|
this._dirty = false;
|
|
this.trigger('change', {
|
|
doc: this,
|
|
});
|
|
}
|
|
|
|
clearValues() {
|
|
let toClear = ['_dirty', '_notInserted'].concat(
|
|
this.meta.getValidFields().map((df) => df.fieldname)
|
|
);
|
|
for (let key of toClear) {
|
|
this[key] = null;
|
|
}
|
|
}
|
|
|
|
setChildIdx() {
|
|
// renumber children
|
|
for (let field of this.meta.getValidFields()) {
|
|
if (field.fieldtype === 'Table') {
|
|
for (let i = 0; i < (this[field.fieldname] || []).length; i++) {
|
|
this[field.fieldname][i].idx = i;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async compareWithCurrentDoc() {
|
|
if (frappe.isServer && !this.isNew()) {
|
|
let currentDoc = await frappe.db.get(this.doctype, this.name);
|
|
|
|
// check for conflict
|
|
if (currentDoc && this.modified != currentDoc.modified) {
|
|
throw new frappe.errors.Conflict(
|
|
frappe.t`Document ${this.doctype} ${this.name} has been modified after loading`
|
|
);
|
|
}
|
|
|
|
if (this.submitted && !this.meta.isSubmittable) {
|
|
throw new frappe.errors.ValidationError(
|
|
frappe.t`Document type ${this.doctype} is not submittable`
|
|
);
|
|
}
|
|
|
|
// set submit action flag
|
|
this.flags = {};
|
|
if (this.submitted && !currentDoc.submitted) {
|
|
this.flags.submitAction = true;
|
|
}
|
|
|
|
if (currentDoc.submitted && !this.submitted) {
|
|
this.flags.revertAction = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
async applyFormula(fieldname) {
|
|
if (!this.meta.hasFormula()) {
|
|
return false;
|
|
}
|
|
|
|
let doc = this;
|
|
let changed = false;
|
|
|
|
// children
|
|
for (let tablefield of this.meta.getTableFields()) {
|
|
let formulaFields = frappe
|
|
.getMeta(tablefield.childtype)
|
|
.getFormulaFields();
|
|
if (formulaFields.length) {
|
|
const value = this[tablefield.fieldname] || [];
|
|
for (let row of value) {
|
|
for (let field of formulaFields) {
|
|
if (shouldApplyFormula(field, row)) {
|
|
let val = await this.getValueFromFormula(field, row);
|
|
let previousVal = row[field.fieldname];
|
|
if (val !== undefined && previousVal !== val) {
|
|
row[field.fieldname] = val;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// parent or child row
|
|
for (let field of this.meta.getFormulaFields()) {
|
|
if (shouldApplyFormula(field, doc)) {
|
|
let previousVal = doc[field.fieldname];
|
|
let val = await this.getValueFromFormula(field, doc);
|
|
if (val !== undefined && previousVal !== val) {
|
|
doc[field.fieldname] = val;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return changed;
|
|
|
|
function shouldApplyFormula(field, doc) {
|
|
if (field.readOnly) {
|
|
return true;
|
|
}
|
|
if (
|
|
fieldname &&
|
|
field.formulaDependsOn &&
|
|
field.formulaDependsOn.includes(fieldname)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (!frappe.isServer || frappe.isElectron) {
|
|
if (doc[field.fieldname] == null || doc[field.fieldname] == '') {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async getValueFromFormula(field, doc) {
|
|
let value;
|
|
|
|
if (doc.meta.isChild) {
|
|
value = await field.formula(doc, doc.parentdoc);
|
|
} else {
|
|
value = await field.formula(doc);
|
|
}
|
|
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
|
|
if ('Float' === field.fieldtype) {
|
|
value = this.round(value, field);
|
|
}
|
|
|
|
if (field.fieldtype === 'Table' && Array.isArray(value)) {
|
|
value = value.map((row) => {
|
|
let doc = this._initChild(row, field.fieldname);
|
|
doc.roundFloats();
|
|
return doc;
|
|
});
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
roundFloats() {
|
|
let fields = this.meta
|
|
.getValidFields()
|
|
.filter((df) => ['Float', 'Table'].includes(df.fieldtype));
|
|
|
|
for (let df of fields) {
|
|
let value = this[df.fieldname];
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
// child
|
|
if (Array.isArray(value)) {
|
|
value.map((row) => row.roundFloats());
|
|
continue;
|
|
}
|
|
// field
|
|
let roundedValue = this.round(value, df);
|
|
if (roundedValue && value !== roundedValue) {
|
|
this[df.fieldname] = roundedValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
async commit() {
|
|
// re-run triggers
|
|
this.setKeywords();
|
|
this.setChildIdx();
|
|
await this.applyFormula();
|
|
await this.trigger('validate');
|
|
}
|
|
|
|
async insert() {
|
|
await setName(this);
|
|
this.setStandardValues();
|
|
await this.commit();
|
|
await this.validateInsert();
|
|
await this.trigger('beforeInsert');
|
|
|
|
let oldName = this.name;
|
|
const data = await frappe.db.insert(this.doctype, this.getValidDict());
|
|
this.syncValues(data);
|
|
|
|
if (oldName !== this.name) {
|
|
frappe.removeFromCache(this.doctype, oldName);
|
|
}
|
|
|
|
await this.trigger('afterInsert');
|
|
await this.trigger('afterSave');
|
|
|
|
telemetry.log(Verb.Created, this.doctype);
|
|
return this;
|
|
}
|
|
|
|
async update(...args) {
|
|
if (args.length) {
|
|
await this.set(...args);
|
|
}
|
|
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 = await frappe.db.update(this.doctype, this.getValidDict());
|
|
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.doctype, this.name);
|
|
await this.trigger('afterDelete');
|
|
|
|
telemetry.log(Verb.Deleted, this.doctype);
|
|
}
|
|
|
|
async submitOrRevert(isSubmit) {
|
|
const wasSubmitted = this.submitted;
|
|
this.submitted = isSubmit;
|
|
try {
|
|
await this.update();
|
|
} catch (e) {
|
|
this.submitted = wasSubmitted;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async submit() {
|
|
this.cancelled = 0;
|
|
await this.submitOrRevert(1);
|
|
}
|
|
|
|
async revert() {
|
|
await this.submitOrRevert(0);
|
|
}
|
|
|
|
async rename(newName) {
|
|
await this.trigger('beforeRename');
|
|
await frappe.db.rename(this.doctype, this.name, newName);
|
|
this.name = newName;
|
|
await this.trigger('afterRename');
|
|
}
|
|
|
|
// trigger methods on the class if they match
|
|
// with the trigger name
|
|
async trigger(event, params) {
|
|
if (this[event]) {
|
|
await this[event](params);
|
|
}
|
|
await super.trigger(event, params);
|
|
}
|
|
|
|
// helper functions
|
|
getSum(tablefield, childfield, convertToFloat = true) {
|
|
const sum = (this[tablefield] || [])
|
|
.map((d) => {
|
|
const value = d[childfield] ?? 0;
|
|
if (!isPesa(value)) {
|
|
try {
|
|
return frappe.pesa(value);
|
|
} catch (err) {
|
|
err.message += ` value: '${value}' of type: ${typeof value}, fieldname: '${tablefield}', childfield: '${childfield}'`;
|
|
throw err;
|
|
}
|
|
}
|
|
return value;
|
|
})
|
|
.reduce((a, b) => a.add(b), frappe.pesa(0));
|
|
|
|
if (convertToFloat) {
|
|
return sum.float;
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
getFrom(doctype, name, fieldname) {
|
|
if (!name) return '';
|
|
return frappe.db.getCachedValue(doctype, name, fieldname);
|
|
}
|
|
|
|
round(value, df = null) {
|
|
if (typeof df === 'string') {
|
|
df = this.meta.getField(df);
|
|
}
|
|
const precision =
|
|
frappe.SystemSettings.internalPrecision ?? DEFAULT_INTERNAL_PRECISION;
|
|
return frappe.pesa(value).clip(precision).float;
|
|
}
|
|
|
|
isNew() {
|
|
return this._notInserted;
|
|
}
|
|
|
|
getFieldMetaMap() {
|
|
return this.meta.fields.reduce((obj, meta) => {
|
|
obj[meta.fieldname] = meta;
|
|
return obj;
|
|
}, {});
|
|
}
|
|
|
|
async duplicate() {
|
|
const updateMap = {};
|
|
const fieldValueMap = this.getValidDict();
|
|
const keys = this.meta.fields.map((f) => f.fieldname);
|
|
for (const key of keys) {
|
|
let value = fieldValueMap[key];
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
if (isPesa(value)) {
|
|
value = value.copy();
|
|
}
|
|
|
|
if (value instanceof Array) {
|
|
value.forEach((row) => {
|
|
delete row.name;
|
|
delete row.parent;
|
|
});
|
|
}
|
|
|
|
updateMap[key] = value;
|
|
}
|
|
|
|
if (this.numberSeries) {
|
|
delete updateMap.name;
|
|
} else {
|
|
updateMap.name = updateMap.name + ' CPY';
|
|
}
|
|
|
|
const doc = frappe.getEmptyDoc(this.doctype, false);
|
|
await doc.set(updateMap);
|
|
await doc.insert();
|
|
}
|
|
}
|
|
|
|
function getPreDefaultValues(fieldtype) {
|
|
switch (fieldtype) {
|
|
case 'Table':
|
|
return [];
|
|
case 'Currency':
|
|
return frappe.pesa(0.0);
|
|
case 'Int':
|
|
case 'Float':
|
|
return 0;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|