2
0
mirror of https://github.com/frappe/books.git synced 2024-11-15 17:57:08 +00:00
books/src/importer.ts

635 lines
15 KiB
TypeScript

import { Fyo } from 'fyo';
import { Converter } from 'fyo/core/converter';
import { DocValue, DocValueMap } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { getEmptyValuesByFieldTypes } from 'fyo/utils';
import { ValidationError } from 'fyo/utils/errors';
import {
Field,
FieldType,
FieldTypeEnum,
OptionField,
RawValue,
Schema,
TargetField,
} from 'schemas/types';
import { generateCSV, parseCSV } from 'utils/csvParser';
import { getValueMapFromList } from 'utils/index';
export type TemplateField = Field & TemplateFieldProps;
type TemplateFieldProps = {
schemaName: string;
schemaLabel: string;
fieldKey: string;
parentSchemaChildField?: TargetField;
};
type ValueMatrixItem =
| {
value: DocValue;
rawValue?: RawValue;
error?: boolean;
}
| { value?: DocValue; rawValue: RawValue; error?: boolean };
type ValueMatrix = ValueMatrixItem[][];
const skippedFieldsTypes: FieldType[] = [
FieldTypeEnum.AttachImage,
FieldTypeEnum.Attachment,
FieldTypeEnum.Table,
];
/**
* Tool that
* - Can make bulk entries for any kind of Doc
* - Takes in unstructured CSV data, converts it into Docs
* - Saves and or Submits the converted Docs
*/
export class Importer {
schemaName: string;
fyo: Fyo;
/**
* List of template fields that have been assigned a column, in
* the order they have been assigned.
*/
assignedTemplateFields: (string | null)[];
/**
* Map of all the template fields that can be imported.
*/
templateFieldsMap: Map<string, TemplateField>;
/**
* Map of Fields that have been picked, i.e.
* - Fields which will be included in the template
* - Fields for which values will be provided
*/
templateFieldsPicked: Map<string, boolean>;
/**
* Whether the schema type being imported has table fields
*/
hasChildTables: boolean;
/**
* Matrix containing the raw values which will be converted to
* doc values before importing.
*/
valueMatrix: ValueMatrix;
/**
* Data from the valueMatrix rows will be converted into Docs
* which will be stored in this array.
*/
docs: Doc[];
/**
* Used if an options field is imported where the import data
* provided maybe the label and not the value
*/
optionsMap: {
values: Record<string, Set<string>>;
labelValueMap: Record<string, Record<string, string>>;
};
constructor(schemaName: string, fyo: Fyo) {
if (!fyo.schemaMap[schemaName]) {
throw new ValidationError(
`Invalid schemaName ${schemaName} found in importer`
);
}
this.hasChildTables = false;
this.schemaName = schemaName;
this.fyo = fyo;
this.docs = [];
this.valueMatrix = [];
this.optionsMap = {
values: {},
labelValueMap: {},
};
const templateFields = getTemplateFields(schemaName, fyo, this);
this.assignedTemplateFields = templateFields.map((f) => f.fieldKey);
this.templateFieldsMap = new Map();
this.templateFieldsPicked = new Map();
templateFields.forEach((f, i) => {
this.templateFieldsMap.set(f.fieldKey, f);
this.templateFieldsPicked.set(f.fieldKey, true);
});
}
selectFile(data: string): boolean {
try {
const parsed = parseCSV(data);
this.selectParsed(parsed);
} catch {
return false;
}
return true;
}
async checkLinks() {
const tfKeys = this.assignedTemplateFields
.map((key, index) => ({
key,
index,
tf: this.templateFieldsMap.get(key ?? ''),
}))
.filter(({ key, tf }) => {
if (!key || !tf) {
return false;
}
return tf.fieldtype === FieldTypeEnum.Link;
}) as { key: string; index: number; tf: TemplateField }[];
const linksNames: Map<string, Set<string>> = new Map();
for (const row of this.valueMatrix) {
for (const { tf, index } of tfKeys) {
const target = (tf as TargetField).target;
const value = row[index]?.value;
if (typeof value !== 'string' || !value) {
continue;
}
if (!linksNames.has(target)) {
linksNames.set(target, new Set());
}
linksNames.get(target)?.add(value);
}
}
const doesNotExist = [];
for (const [target, values] of linksNames.entries()) {
for (const value of values) {
const exists = await this.fyo.db.exists(target, value);
if (exists) {
continue;
}
doesNotExist.push({
schemaName: target,
schemaLabel: this.fyo.schemaMap[this.schemaName]?.label,
name: value,
});
}
}
return doesNotExist;
}
checkCellErrors() {
const assigned = this.assignedTemplateFields
.map((key, index) => ({
key,
index,
tf: this.templateFieldsMap.get(key ?? ''),
}))
.filter(({ key, tf }) => !!key && !!tf) as {
key: string;
index: number;
tf: TemplateField;
}[];
const cellErrors = [];
for (const i in this.valueMatrix) {
const row = this.valueMatrix[i];
for (const { tf, index } of assigned) {
if (!row[index]?.error) {
continue;
}
const rowLabel = this.fyo.t`Row ${i + 1}`;
const columnLabel = getColumnLabel(tf);
cellErrors.push(`(${rowLabel}, ${columnLabel})`);
}
}
return cellErrors;
}
populateDocs() {
const { dataMap, childTableMap } =
this.getDataAndChildTableMapFromValueMatrix();
const schema = this.fyo.schemaMap[this.schemaName];
const targetFieldnameMap = schema?.fields
.filter((f) => f.fieldtype === FieldTypeEnum.Table)
.reduce((acc, f) => {
const { target, fieldname } = f as TargetField;
acc[target] = fieldname;
return acc;
}, {} as Record<string, string>);
for (const [name, data] of dataMap.entries()) {
const doc = this.fyo.doc.getNewDoc(this.schemaName, data, false);
for (const schemaName in targetFieldnameMap) {
const fieldname = targetFieldnameMap[schemaName];
const childTable = childTableMap[name]?.[schemaName];
if (!childTable) {
continue;
}
for (const childData of childTable.values()) {
doc.push(fieldname, childData);
}
}
this.docs.push(doc);
}
}
getDataAndChildTableMapFromValueMatrix() {
/**
* Record key is the doc.name value
*/
const dataMap: Map<string, DocValueMap> = new Map();
/**
* Record key is doc.name, childSchemaName, childDoc.name
*/
const childTableMap: Record<
string,
Record<string, Map<string, DocValueMap>>
> = {};
const nameIndices = this.assignedTemplateFields
.map((key, index) => ({ key, index }))
.filter((f) => f.key?.endsWith('.name'))
.reduce((acc, f) => {
if (f.key == null) {
return acc;
}
const schemaName = f.key.split('.')[0];
acc[schemaName] = f.index;
return acc;
}, {} as Record<string, number>);
const nameIndex = nameIndices?.[this.schemaName];
if (nameIndex < 0) {
return { dataMap, childTableMap };
}
for (const i in this.valueMatrix) {
const row = this.valueMatrix[i];
const name = row[nameIndex]?.value;
if (typeof name !== 'string') {
continue;
}
for (const j in row) {
const key = this.assignedTemplateFields[j];
const tf = this.templateFieldsMap.get(key ?? '');
if (!tf || !key) {
continue;
}
const isChild = this.fyo.schemaMap[tf.schemaName]?.isChild;
const vmi = row[j];
if (vmi.value == null) {
continue;
}
if (!isChild && !dataMap.has(name)) {
dataMap.set(name, {});
}
if (!isChild) {
dataMap.get(name)![tf.fieldname] = vmi.value;
continue;
}
const childNameIndex = nameIndices[tf.schemaName];
let childName = row[childNameIndex]?.value;
if (typeof childName !== 'string') {
childName = `${tf.schemaName}-${i}`;
}
childTableMap[name] ??= {};
childTableMap[name][tf.schemaName] ??= new Map();
const childMap = childTableMap[name][tf.schemaName];
if (!childMap.has(childName)) {
childMap.set(childName, {});
}
const childDocValueMap = childMap.get(childName);
if (!childDocValueMap) {
continue;
}
childDocValueMap[tf.fieldname] = vmi.value;
}
}
return { dataMap, childTableMap };
}
selectParsed(parsed: string[][]): void {
if (!parsed?.length) {
return;
}
let startIndex = -1;
let templateFieldsAssigned;
for (let i = 3; i >= 0; i--) {
const row = parsed[i];
if (!row?.length) {
continue;
}
templateFieldsAssigned = this.assignTemplateFieldsFromParsedRow(row);
if (templateFieldsAssigned) {
startIndex = i + 1;
break;
}
}
if (!templateFieldsAssigned) {
this.clearAndResizeAssignedTemplateFields(parsed[0].length);
}
if (startIndex === -1) {
startIndex = 0;
}
this.assignValueMatrixFromParsed(parsed.slice(startIndex));
}
clearAndResizeAssignedTemplateFields(size: number) {
for (let i = 0; i < size; i++) {
if (i >= this.assignedTemplateFields.length) {
this.assignedTemplateFields.push(null);
} else {
this.assignedTemplateFields[i] = null;
}
}
}
assignValueMatrixFromParsed(parsed: string[][]) {
if (!parsed?.length) {
return;
}
for (const row of parsed) {
this.pushToValueMatrixFromParsedRow(row);
}
}
pushToValueMatrixFromParsedRow(row: string[]) {
const vmRow: ValueMatrix[number] = [];
for (const i in row) {
const rawValue = row[i];
const index = Number(i);
if (index >= this.assignedTemplateFields.length) {
this.assignedTemplateFields.push(null);
}
vmRow.push(this.getValueMatrixItem(index, rawValue));
}
this.valueMatrix.push(vmRow);
}
setTemplateField(index: number, key: string | null) {
if (index >= this.assignedTemplateFields.length) {
this.assignedTemplateFields.push(key);
} else {
this.assignedTemplateFields[index] = key;
}
this.updateValueMatrixColumn(index);
}
updateValueMatrixColumn(index: number) {
for (const row of this.valueMatrix) {
const vmi = this.getValueMatrixItem(index, row[index].rawValue ?? null);
if (index >= row.length) {
row.push(vmi);
} else {
row[index] = vmi;
}
}
}
getValueMatrixItem(index: number, rawValue: RawValue) {
const vmi: ValueMatrixItem = { rawValue };
const key = this.assignedTemplateFields[index];
if (!key) {
return vmi;
}
const tf = this.templateFieldsMap.get(key);
if (!tf) {
return vmi;
}
if (vmi.rawValue === '') {
vmi.value = null;
return vmi;
}
if ('options' in tf && typeof vmi.rawValue === 'string') {
return this.getOptionFieldVmi(vmi, tf);
}
try {
vmi.value = Converter.toDocValue(rawValue, tf, this.fyo);
} catch {
vmi.error = true;
}
return vmi;
}
getOptionFieldVmi(
{ rawValue }: ValueMatrixItem,
tf: OptionField & TemplateFieldProps
): ValueMatrixItem {
if (typeof rawValue !== 'string') {
return { error: true, value: null, rawValue };
}
if (!tf?.options.length) {
return { value: null, rawValue };
}
if (!this.optionsMap.labelValueMap[tf.fieldKey]) {
const values = new Set(tf.options.map(({ value }) => value));
const labelValueMap = getValueMapFromList(tf.options, 'label', 'value');
this.optionsMap.labelValueMap[tf.fieldKey] = labelValueMap;
this.optionsMap.values[tf.fieldKey] = values;
}
const hasValue = this.optionsMap.values[tf.fieldKey].has(rawValue);
if (hasValue) {
return { value: rawValue, rawValue };
}
const value = this.optionsMap.labelValueMap[tf.fieldKey][rawValue];
if (value) {
return { value, rawValue };
}
return { error: true, value: null, rawValue };
}
assignTemplateFieldsFromParsedRow(row: string[]): boolean {
const isKeyRow = row.some((key) => this.templateFieldsMap.has(key));
if (!isKeyRow) {
return false;
}
for (const i in row) {
const value = row[i];
const tf = this.templateFieldsMap.get(value);
let key: string | null = value;
if (!tf) {
key = null;
}
if (key !== null && !this.templateFieldsPicked.get(value)) {
key = null;
}
if (Number(i) >= this.assignedTemplateFields.length) {
this.assignedTemplateFields.push(key);
} else {
this.assignedTemplateFields[i] = key;
}
}
return true;
}
addRow() {
const valueRow: ValueMatrix[number] = this.assignedTemplateFields.map(
(key) => {
key ??= '';
const { fieldtype } = this.templateFieldsMap.get(key) ?? {};
let value = null;
if (fieldtype) {
value = getEmptyValuesByFieldTypes(fieldtype, this.fyo);
}
return { value };
}
);
this.valueMatrix.push(valueRow);
}
removeRow(index: number) {
this.valueMatrix = this.valueMatrix.filter((_, i) => i !== index);
}
getCSVTemplate(): string {
const schemaLabels: string[] = [];
const fieldLabels: string[] = [];
const fieldKey: string[] = [];
for (const [name, picked] of this.templateFieldsPicked.entries()) {
if (!picked) {
continue;
}
const field = this.templateFieldsMap.get(name);
if (!field) {
continue;
}
schemaLabels.push(field.schemaLabel);
fieldLabels.push(field.label);
fieldKey.push(field.fieldKey);
}
return generateCSV([schemaLabels, fieldLabels, fieldKey]);
}
}
function getTemplateFields(
schemaName: string,
fyo: Fyo,
importer: Importer
): TemplateField[] {
const schemas: { schema: Schema; parentSchemaChildField?: TargetField }[] = [
{ schema: fyo.schemaMap[schemaName]! },
];
const fields: TemplateField[] = [];
while (schemas.length) {
const { schema, parentSchemaChildField } = schemas.pop() ?? {};
if (!schema) {
continue;
}
for (const field of schema.fields) {
if (
field.computed ||
field.meta ||
field.hidden ||
(field.readOnly && !field.required)
) {
continue;
}
if (field.fieldtype === FieldTypeEnum.Table) {
importer.hasChildTables = true;
schemas.push({
schema: fyo.schemaMap[field.target]!,
parentSchemaChildField: field,
});
}
if (skippedFieldsTypes.includes(field.fieldtype)) {
continue;
}
const tf = { ...field };
if (tf.readOnly) {
tf.readOnly = false;
}
if (schema.isChild && tf.fieldname === 'name') {
tf.required = false;
}
const schemaName = schema.name;
const schemaLabel = schema.label;
const fieldKey = `${schema.name}.${field.fieldname}`;
fields.push({
...tf,
schemaName,
schemaLabel,
fieldKey,
parentSchemaChildField,
});
}
}
return fields;
}
export function getColumnLabel(field: TemplateField): string {
if (field.parentSchemaChildField) {
return `${field.label} (${field.parentSchemaChildField.label})`;
}
return field.label;
}