2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +00:00
books/fyo/model/doc.ts
2022-12-23 12:00:52 +05:30

1059 lines
25 KiB
TypeScript

import { Fyo } from 'fyo';
import { Converter } from 'fyo/core/converter';
import { DocValue, DocValueMap, RawValueMap } from 'fyo/core/types';
import { Verb } from 'fyo/telemetry/types';
import { DEFAULT_USER } from 'fyo/utils/consts';
import { ConflictError, MandatoryError, NotFoundError } from 'fyo/utils/errors';
import Observable from 'fyo/utils/observable';
import { Money } from 'pesa';
import {
DynamicLinkField,
Field,
FieldTypeEnum,
OptionField,
RawValue,
Schema,
TargetField,
} from 'schemas/types';
import { getIsNullOrUndef, getMapFromList, getRandomString } from 'utils';
import { markRaw } from 'vue';
import { isPesa } from '../utils/index';
import { getDbSyncError } from './errorHelpers';
import {
areDocValuesEqual,
getMissingMandatoryMessage,
getPreDefaultValues,
setChildDocIdx,
shouldApplyFormula,
} from './helpers';
import { setName } from './naming';
import {
Action,
ChangeArg,
CurrenciesMap,
DefaultMap,
EmptyMessageMap,
FiltersMap,
FormulaMap,
FormulaReturn,
HiddenMap,
ListsMap,
ListViewSettings,
ReadOnlyMap,
RequiredMap,
TreeViewSettings,
ValidationMap,
} from './types';
import { validateOptions, validateRequired } from './validationFunction';
export class Doc extends Observable<DocValue | Doc[]> {
name?: string;
schema: Readonly<Schema>;
fyo: Fyo;
fieldMap: Record<string, Field>;
/**
* Fields below are used by child docs to maintain
* reference w.r.t their parent doc.
*/
idx?: number;
parentdoc?: Doc;
parentFieldname?: string;
parentSchemaName?: string;
links?: Record<string, Doc>;
_dirty: boolean = true;
_notInserted: boolean = true;
_syncing = false;
constructor(
schema: Schema,
data: DocValueMap,
fyo: Fyo,
convertToDocValue: boolean = true
) {
super();
this.fyo = markRaw(fyo);
this.schema = schema;
this.fieldMap = getMapFromList(schema.fields, 'fieldname');
if (this.schema.isSingle) {
this.name = this.schemaName;
}
this._setDefaults();
this._setValuesWithoutChecks(data, convertToDocValue);
}
get schemaName(): string {
return this.schema.name;
}
get notInserted(): boolean {
return this._notInserted;
}
get inserted(): boolean {
return !this._notInserted;
}
get tableFields(): TargetField[] {
return this.schema.fields.filter(
(f) => f.fieldtype === FieldTypeEnum.Table
) as TargetField[];
}
get dirty() {
return this._dirty;
}
get quickEditFields() {
let fieldnames = this.schema.quickEditFields;
if (fieldnames === undefined) {
fieldnames = [];
}
if (fieldnames.length === 0 && this.fieldMap['name']) {
fieldnames = ['name'];
}
return fieldnames.map((f) => this.fieldMap[f]);
}
get isSubmitted() {
return !!this.submitted && !this.cancelled;
}
get isCancelled() {
return !!this.submitted && !!this.cancelled;
}
get isSyncing() {
return this._syncing;
}
get canDelete() {
if (this.notInserted) {
return false;
}
if (this.schema.isSingle) {
return false;
}
if (!this.schema.isSubmittable) {
return true;
}
if (this.schema.isSubmittable && this.isCancelled) {
return true;
}
if (this.schema.isSubmittable && !this.isSubmitted) {
return true;
}
return false;
}
get canSave() {
if (!!this.submitted) {
return false;
}
if (!!this.cancelled) {
return false;
}
if (!this.dirty) {
return false;
}
return true;
}
get canSubmit() {
if (!this.schema.isSubmittable) {
return false;
}
if (this.dirty) {
return false;
}
if (this.notInserted) {
return false;
}
if (!!this.submitted) {
return false;
}
if (!!this.cancelled) {
return false;
}
return true;
}
get canCancel() {
if (!this.schema.isSubmittable) {
return false;
}
if (this.dirty) {
return false;
}
if (this.notInserted) {
return false;
}
if (!!this.cancelled) {
return false;
}
if (!this.submitted) {
return false;
}
return true;
}
_setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) {
for (const field of this.schema.fields) {
const { fieldname, fieldtype } = field;
const value = data[field.fieldname];
if (Array.isArray(value)) {
for (const row of value) {
this.push(fieldname, row, convertToDocValue);
}
} else if (
fieldtype === FieldTypeEnum.Currency &&
typeof value === 'number'
) {
this[fieldname] = this.fyo.pesa(value);
} else if (value !== undefined && !convertToDocValue) {
this[fieldname] = value;
} else if (value !== undefined) {
this[fieldname] = Converter.toDocValue(
value as RawValue,
field,
this.fyo
);
} else {
this[fieldname] = this[fieldname] ?? null;
}
if (field.fieldtype === FieldTypeEnum.Table && !this[fieldname]) {
this[fieldname] = [];
}
}
}
_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[] | DocValueMap[],
retriggerChildDocApplyChange: boolean = false
): Promise<boolean> {
if (typeof fieldname === 'object') {
return await this.setMultiple(fieldname as DocValueMap);
}
if (!this._canSet(fieldname, value)) {
return false;
}
this._setDirty(true);
if (typeof value === 'string') {
value = value.trim();
}
if (Array.isArray(value)) {
for (const row of value) {
this.push(fieldname, 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.parentFieldname as string);
} else {
await this._applyChange(fieldname, retriggerChildDocApplyChange);
}
return true;
}
async setMultiple(docValueMap: DocValueMap): Promise<boolean> {
let hasSet = false;
for (const fieldname in docValueMap) {
const isSet = await this.set(
fieldname,
docValueMap[fieldname] as DocValue | Doc[]
);
hasSet ||= isSet;
}
return hasSet;
}
_canSet(
fieldname: string,
value?: DocValue | Doc[] | DocValueMap[]
): boolean {
if (fieldname === 'numberSeries' && !this.notInserted) {
return false;
}
if (value === undefined) {
return false;
}
if (this.fieldMap[fieldname] === undefined) {
return false;
}
const currentValue = this.get(fieldname);
if (currentValue === undefined) {
return true;
}
return !areDocValuesEqual(currentValue as DocValue, value as DocValue);
}
async _applyChange(
changedFieldname: string,
retriggerChildDocApplyChange?: boolean
): Promise<boolean> {
await this._applyFormula(changedFieldname, retriggerChildDocApplyChange);
await this.trigger('change', {
doc: this,
changed: changedFieldname,
});
return true;
}
_setDefaults() {
for (const field of this.schema.fields) {
let defaultValue: DocValue | Doc[] = getPreDefaultValues(
field.fieldtype,
this.fyo
);
const defaultFunction =
this.fyo.models[this.schemaName]?.defaults?.[field.fieldname];
if (defaultFunction !== undefined) {
defaultValue = defaultFunction(this);
} else if (field.default !== undefined) {
defaultValue = field.default;
}
if (field.fieldtype === FieldTypeEnum.Currency && !isPesa(defaultValue)) {
defaultValue = this.fyo.pesa!(defaultValue as string | number);
}
this[field.fieldname] = defaultValue;
}
}
async remove(fieldname: string, idx: number) {
const childDocs = ((this[fieldname] ?? []) as Doc[]).filter(
(row, i) => row.idx !== idx || i !== idx
);
setChildDocIdx(childDocs);
this[fieldname] = childDocs;
this._setDirty(true);
return await this._applyChange(fieldname);
}
async append(fieldname: string, docValueMap: DocValueMap = {}) {
this.push(fieldname, docValueMap);
this._setDirty(true);
return await this._applyChange(fieldname);
}
push(
fieldname: string,
docValueMap: Doc | DocValueMap | RawValueMap = {},
convertToDocValue: boolean = false
) {
const childDocs = [
(this[fieldname] ?? []) as Doc[],
this._getChildDoc(docValueMap, fieldname, convertToDocValue),
].flat();
setChildDocIdx(childDocs);
this[fieldname] = childDocs;
}
_setChildDocsParent() {
for (const { fieldname } of this.tableFields) {
const value = this.get(fieldname);
if (!Array.isArray(value)) {
continue;
}
for (const childDoc of value) {
if (childDoc.parent) {
continue;
}
childDoc.parent = this.name;
}
}
}
_getChildDoc(
docValueMap: Doc | DocValueMap | RawValueMap,
fieldname: string,
convertToDocValue: boolean = false
): Doc {
if (!this.name && this.schema.naming !== 'manual') {
this.name = getRandomString();
}
docValueMap.name ??= getRandomString();
// Child Meta Fields
docValueMap.parent ??= this.name;
docValueMap.parentSchemaName ??= this.schemaName;
docValueMap.parentFieldname ??= fieldname;
if (docValueMap instanceof Doc) {
docValueMap.parentdoc ??= this;
return docValueMap;
}
const childSchemaName = (this.fieldMap[fieldname] as TargetField).target;
const childDoc = this.fyo.doc.getNewDoc(
childSchemaName,
docValueMap,
false,
undefined,
undefined,
convertToDocValue
);
childDoc.parentdoc = this;
return childDoc;
}
async _validateSync() {
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[];
if (!childDocs) {
continue;
}
checkForMandatory.push(...childDocs);
}
const missingMandatoryMessage = checkForMandatory
.map((doc) => getMissingMandatoryMessage(doc))
.filter(Boolean);
if (missingMandatoryMessage.length > 0) {
const fields = missingMandatoryMessage.join('\n');
const message = this.fyo.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 === FieldTypeEnum.Select ||
field.fieldtype === FieldTypeEnum.AutoComplete
) {
validateOptions(field as OptionField, value as string, this);
}
validateRequired(field, value, this);
if (getIsNullOrUndef(value)) {
return;
}
const validator = this.validations[field.fieldname];
if (validator === undefined) {
return;
}
await validator(value);
}
getValidDict(
filterMeta: boolean = false,
filterComputed: boolean = false
): DocValueMap {
let fields = this.schema.fields;
if (filterMeta) {
fields = this.schema.fields.filter((f) => !f.meta);
}
if (filterComputed) {
fields = fields.filter((f) => !f.computed);
}
const data: DocValueMap = {};
for (const field of fields) {
let value = this[field.fieldname] as DocValue | DocValueMap[];
if (Array.isArray(value)) {
value = value.map((doc) =>
(doc as Doc).getValidDict(filterMeta, filterComputed)
);
}
if (isPesa(value)) {
value = (value as Money).copy();
}
if (value === null && this.schema.isSingle) {
continue;
}
data[field.fieldname] = value;
}
return data;
}
_setBaseMetaValues() {
if (this.schema.isSubmittable) {
this.submitted = false;
this.cancelled = false;
}
if (!this.createdBy) {
this.createdBy = this.fyo.auth.session.user || DEFAULT_USER;
}
if (!this.created) {
this.created = new Date();
}
this._updateModifiedMetaValues();
}
_updateModifiedMetaValues() {
this.modifiedBy = this.fyo.auth.session.user || DEFAULT_USER;
this.modified = new Date();
}
async load() {
if (this.name === undefined) {
return;
}
const data = await this.fyo.db.get(this.schemaName, this.name);
if (this.schema.isSingle && !data?.name) {
data.name = this.name!;
}
if (data && data.name) {
await this._syncValues(data);
await this.loadLinks();
} else {
throw new NotFoundError(`Not Found: ${this.schemaName} ${this.name}`);
}
this._setDirty(false);
this._notInserted = false;
this.fyo.doc.observer.trigger(`load:${this.schemaName}`, this.name);
}
async loadLinks() {
this.links ??= {};
const linkFields = this.schema.fields.filter(
({ fieldtype }) =>
fieldtype === FieldTypeEnum.Link ||
fieldtype === FieldTypeEnum.DynamicLink
);
for (const field of linkFields) {
await this._loadLink(field);
}
}
async _loadLink(field: Field) {
if (field.fieldtype === FieldTypeEnum.Link) {
return await this._loadLinkField(field as TargetField);
}
if (field.fieldtype === FieldTypeEnum.DynamicLink) {
return await this._loadDynamicLinkField(field as DynamicLinkField);
}
}
async _loadLinkField(field: TargetField) {
const { fieldname, target } = field;
const value = this.get(fieldname) as string | undefined;
if (!value || !target) {
return;
}
await this._loadLinkDoc(fieldname, target, value);
}
async _loadDynamicLinkField(field: DynamicLinkField) {
const { fieldname, references } = field;
const value = this.get(fieldname) as string | undefined;
const reference = this.get(references) as string | undefined;
if (!value || !reference) {
return;
}
await this._loadLinkDoc(fieldname, reference, value);
}
async _loadLinkDoc(fieldname: string, schemaName: string, name: string) {
this.links![fieldname] = await this.fyo.doc.getDoc(schemaName, name);
}
getLink(fieldname: string): Doc | null {
return this.links?.[fieldname] ?? null;
}
async loadAndGetLink(fieldname: string): Promise<Doc | null> {
if (!this?.[fieldname]) {
return null;
}
if (this.links?.[fieldname]?.name !== this[fieldname]) {
await this.loadLinks();
}
return this.links?.[fieldname] ?? null;
}
async _syncValues(data: DocValueMap) {
this._clearValues();
this._setValuesWithoutChecks(data, false);
await this._setComputedValuesFromFormulas();
this._dirty = false;
this.trigger('change', {
doc: this,
});
}
async _setComputedValuesFromFormulas() {
for (const field of this.schema.fields) {
await this._setComputedValuesForChildren(field);
if (!field.computed) {
continue;
}
const value = await this._getValueFromFormula(field, this);
this[field.fieldname] = value ?? null;
}
}
async _setComputedValuesForChildren(field: Field) {
if (field.fieldtype !== 'Table') {
return;
}
const childDocs: Doc[] = (this[field.fieldname] as Doc[]) ?? [];
for (const doc of childDocs) {
await doc._setComputedValuesFromFormulas();
}
}
_clearValues() {
for (const { fieldname } of this.schema.fields) {
this[fieldname] = null;
}
this._dirty = true;
this._notInserted = true;
}
_setChildDocsIdx() {
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[]) ?? [];
setChildDocIdx(childDocs);
}
}
async _validateDbNotModified() {
if (this.notInserted || !this.name || this.schema.isSingle) {
return;
}
const dbValues = await this.fyo.db.get(this.schemaName, this.name);
const docModified = (this.modified as Date)?.toISOString();
const dbModified = (dbValues.modified as Date)?.toISOString();
if (dbValues && docModified !== dbModified) {
throw new ConflictError(
this.fyo
.t`${this.schema.label} ${this.name} has been modified after loading` +
` ${dbModified}, ${docModified}`
);
}
}
async _applyFormula(
changedFieldname?: string,
retriggerChildDocApplyChange?: boolean
): Promise<boolean> {
const doc = this;
let changed = await this._callAllTableFieldsApplyFormula(changedFieldname);
changed =
(await this._applyFormulaForFields(doc, changedFieldname)) || changed;
if (changed && retriggerChildDocApplyChange) {
await this._callAllTableFieldsApplyFormula(changedFieldname);
await this._applyFormulaForFields(doc, changedFieldname);
}
return changed;
}
async _callAllTableFieldsApplyFormula(
changedFieldname?: string
): Promise<boolean> {
let changed = false;
for (const { fieldname } of this.tableFields) {
const childDocs = this.get(fieldname) as Doc[];
if (!childDocs) {
continue;
}
changed =
(await this._callChildDocApplyFormula(childDocs, changedFieldname)) ||
changed;
}
return changed;
}
async _callChildDocApplyFormula(
childDocs: Doc[],
fieldname?: string
): Promise<boolean> {
let changed: boolean = false;
for (const childDoc of childDocs) {
if (!childDoc._applyFormula) {
continue;
}
changed = (await childDoc._applyFormula(fieldname)) || changed;
}
return changed;
}
async _applyFormulaForFields(doc: Doc, fieldname?: string) {
const formulaFields = this.schema.fields.filter(
({ fieldname }) => this.formulas?.[fieldname]
);
let changed = false;
for (const field of formulaFields) {
const shouldApply = shouldApplyFormula(field, doc, fieldname);
if (!shouldApply) {
continue;
}
const newVal = await this._getValueFromFormula(field, doc, fieldname);
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, fieldname?: string) {
const { formula } = doc.formulas[field.fieldname] ?? {};
if (formula === undefined) {
return;
}
let value: FormulaReturn;
try {
value = await formula(fieldname);
} catch {
return;
}
if (Array.isArray(value) && field.fieldtype === FieldTypeEnum.Table) {
value = value.map((row) => this._getChildDoc(row, field.fieldname));
}
return value;
}
async _preSync() {
this._setChildDocsIdx();
this._setChildDocsParent();
await this._applyFormula();
await this._validateSync();
await this.trigger('validate');
}
async _insert() {
await setName(this, this.fyo);
this._setBaseMetaValues();
await this._preSync();
const validDict = this.getValidDict(false, true);
let data: DocValueMap;
try {
data = await this.fyo.db.insert(this.schemaName, validDict);
} catch (err) {
throw await getDbSyncError(err as Error, this, this.fyo);
}
await this._syncValues(data);
this.fyo.telemetry.log(Verb.Created, this.schemaName);
return this;
}
async _update() {
await this._validateDbNotModified();
this._updateModifiedMetaValues();
await this._preSync();
const data = this.getValidDict(false, true);
try {
await this.fyo.db.update(this.schemaName, data);
} catch (err) {
throw await getDbSyncError(err as Error, this, this.fyo);
}
await this._syncValues(data);
return this;
}
async sync(): Promise<Doc> {
this._syncing = true;
await this.trigger('beforeSync');
let doc;
if (this.notInserted) {
doc = await this._insert();
} else {
doc = await this._update();
}
this._notInserted = false;
await this.trigger('afterSync');
this.fyo.doc.observer.trigger(`sync:${this.schemaName}`, this.name);
this._syncing = false;
return doc;
}
async delete() {
if (!this.canDelete) {
return;
}
await this.trigger('beforeDelete');
await this.fyo.db.delete(this.schemaName, this.name!);
await this.trigger('afterDelete');
this.fyo.telemetry.log(Verb.Deleted, this.schemaName);
this.fyo.doc.observer.trigger(`delete:${this.schemaName}`, this.name);
}
async submit() {
if (!this.schema.isSubmittable || this.submitted || this.cancelled) {
return;
}
await this.trigger('beforeSubmit');
await this.setAndSync('submitted', true);
await this.trigger('afterSubmit');
this.fyo.telemetry.log(Verb.Submitted, this.schemaName);
this.fyo.doc.observer.trigger(`submit:${this.schemaName}`, this.name);
}
async cancel() {
if (!this.schema.isSubmittable || !this.submitted || this.cancelled) {
return;
}
await this.trigger('beforeCancel');
await this.trigger('beforeCancel');
await this.setAndSync('cancelled', true);
await this.trigger('afterCancel');
this.fyo.telemetry.log(Verb.Cancelled, this.schemaName);
this.fyo.doc.observer.trigger(`cancel:${this.schemaName}`, this.name);
}
async rename(newName: string) {
if (this.submitted) {
return;
}
const oldName = this.name;
await this.trigger('beforeRename', { oldName, newName });
await this.fyo.db.rename(this.schemaName, this.name!, newName);
this.name = newName;
await this.trigger('afterRename', { oldName, newName });
this.fyo.doc.observer.trigger(`rename:${this.schemaName}`, this.name);
}
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 this.fyo.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), this.fyo.pesa(0));
if (convertToFloat) {
return sum.float;
}
return sum;
}
async setAndSync(fieldname: string | DocValueMap, value?: DocValue | Doc[]) {
await this.set(fieldname, value);
return await this.sync();
}
duplicate(): Doc {
const updateMap = this.getValidDict(true, true);
for (const field in updateMap) {
const value = updateMap[field];
if (!Array.isArray(value)) {
continue;
}
for (const row of value) {
delete row.name;
}
}
if (this.numberSeries) {
delete updateMap.name;
} else {
updateMap.name = updateMap.name + ' CPY';
}
const rawUpdateMap = this.fyo.db.converter.toRawValueMap(
this.schemaName,
updateMap
) as RawValueMap;
return this.fyo.doc.getNewDoc(this.schemaName, rawUpdateMap, true);
}
/**
* Lifecycle Methods
*
* Abstractish methods that are called using `this.trigger`.
* These are to be overridden if required when subclassing.
*
* Refrain from running methods that call `this.sync`
* in the `beforeLifecycle` methods.
*
* This may cause the lifecycle function to execute incorrectly.
*/
async change(ch: ChangeArg) {}
async validate() {}
async beforeSync() {}
async afterSync() {}
async beforeSubmit() {}
async afterSubmit() {}
async beforeRename() {}
async afterRename() {}
async beforeCancel() {}
async afterCancel() {}
async beforeDelete() {}
async afterDelete() {}
formulas: FormulaMap = {};
validations: ValidationMap = {};
required: RequiredMap = {};
hidden: HiddenMap = {};
readOnly: ReadOnlyMap = {};
getCurrencies: CurrenciesMap = {};
static lists: ListsMap = {};
static filters: FiltersMap = {};
static createFilters: FiltersMap = {}; // Used by the *Create* dropdown option
static defaults: DefaultMap = {};
static emptyMessages: EmptyMessageMap = {};
static getListViewSettings(fyo: Fyo): ListViewSettings {
return {};
}
static getTreeSettings(fyo: Fyo): TreeViewSettings | void {}
static getActions(fyo: Fyo): Action[] {
return [];
}
}