Merged PR 43: データ集計計算アクション

以下の変更があります。
- データ集計アクション
- データ集計関連の共通UIの追加
- 条件式エディタの変更
This commit is contained in:
Shohtetsu Ma
2024-07-11 00:44:23 +00:00
committed by Takuto Yoshida(タクト)
7 changed files with 511 additions and 339 deletions

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,6 @@ API_V1_AUTH_KEY = "X-Cybozu-Authorization"
DEPLOY_MODE = "PROD" #DEV,PROD
DEPLOY_JS_URL = "https://ka-addin.azurewebsites.net/alc_runtime.js"
#DEPLOY_JS_URL = "https://ce1c-133-139-70-194.ngrok-free.app/alc_runtime.js"
KINTONE_FIELD_TYPE=["GROUP","GROUP_SELECT","CHECK_BOX","SUBTABLE","DROP_DOWN","USER_SELECT","RADIO_BUTTON","RICH_TEXT","LINK","REFERENCE_TABLE","CALC","TIME","NUMBER","ORGANIZATION_SELECT","FILE","DATETIME","DATE","MULTI_SELECT","SINGLE_LINE_TEXT","MULTI_LINE_TEXT"]

View File

@@ -1,6 +1,6 @@
#開発環境
#KAB_BACKEND_URL="https://kab-backend.azurewebsites.net/"
KAB_BACKEND_URL="https://kab-backend.azurewebsites.net/"
#単体テスト環境
#KAB_BACKEND_URL="https://kab-backend-unittest.azurewebsites.net/"
#ローカル開発環境
KAB_BACKEND_URL="http://127.0.0.1:8000/"
#KAB_BACKEND_URL="http://127.0.0.1:8000/"

View File

@@ -6,67 +6,19 @@ import {
IContext,
} from "../types/ActionTypes";
import { actionAddins } from ".";
import {KintoneRestAPIClient} from '@kintone/rest-api-client';
import { Aggregator,Operator} from '../util/aggregates';
import { FieldForm } from "../types/FieldLayout";
interface Props {
interface IDataProcessingProps {
displayName: string;
sources: Sources;
condition: string;
conditionO: Condition;
verName: VerName;
verName?: VerName;
}
interface Condition {
queryString: string;
index: number;
type: string;
children: Child[];
parent: null;
logicalOperator: string;
}
interface Child {
index: number;
type: string;
parent: string;
object: Object;
operator: ChildOperator;
value: Value;
}
interface Value {
sharedText: string;
_t: string;
objectType: string;
actionName: string;
displayName: string;
name: Name;
}
interface Name {
name: string;
}
interface ChildOperator {
label: string;
value: string;
}
interface Object {
sharedText: string;
_t: string;
name: string;
objectType: string;
type: string;
code: string;
label: string;
noLabel: boolean;
required: boolean;
minLength: string;
maxLength: string;
expression: string;
hideExpression: boolean;
unique: boolean;
defaultValue: string;
}
interface VerName {
@@ -78,170 +30,26 @@ interface VerName {
interface Var {
id: string;
field: Field2;
logicalOperator: LogicalOperator;
field: FieldForm;
logicalOperator: CalcOperator;
vName: string;
}
interface LogicalOperator {
operator: string;
interface CalcOperator {
operator: Operator;
label: string;
}
interface Field2 {
sharedText: string;
_t: string;
name: string;
objectType: string;
type: string;
code: string;
label: string;
noLabel: boolean;
required: boolean;
minLength: string;
maxLength: string;
expression: string;
hideExpression: boolean;
unique: boolean;
defaultValue: string;
}
interface Sources {
app: App;
fields: Field[];
}
interface Field {
name: string;
type: string;
code: string;
label: string;
noLabel: boolean;
required: boolean;
minLength: string;
maxLength: string;
expression: string;
hideExpression: boolean;
unique: boolean;
defaultValue: string;
fields: FieldForm[];
}
interface App {
id: string;
name: string;
description: string;
createdate: string;
name?: string;
}
export class DataProcessingAction implements IAction {
name: string;
actionProps: IActionProperty[];
dataProcessingProps: Props | null;
constructor() {
this.name = "データ処理";
this.actionProps = [];
this.dataProcessingProps = null;
this.register();
}
async process(
nodes: IActionNode,
event: any,
context: IContext
): Promise<IActionResult> {
this.actionProps = nodes.actionProps;
this.dataProcessingProps = nodes.ActionValue as Props;
this.dataProcessingProps.conditionO = JSON.parse(
this.dataProcessingProps.condition
);
let result = {
canNext: true,
result: "",
} as IActionResult;
try {
if (!this.dataProcessingProps) {
return result;
}
const data = await selectData(
varGet(
this.dataProcessingProps.conditionO.queryString,
context.variables
)
);
console.log("data ", data);
context.variables[this.dataProcessingProps.verName.name] =
this.dataProcessingProps.verName.vars.reduce((acc, f) => {
const v = calc(f, data);
if (v) {
acc[f.vName] = calc(f, data);
}
return acc;
}, {} as AnyObject);
console.log("context ", context);
return result;
} catch (error) {
console.error(error);
event.error = error;
return result;
}
}
register(): void {
actionAddins[this.name] = this;
}
}
new DataProcessingAction();
const varGet = (str: string, vars: any) => {
console.log(str);
const regex = /var\((.*?)\)/g;
let match;
while ((match = regex.exec(str)) !== null) {
const varName = match[1];
if (varName in vars) {
str = str.replace(match[0], vars[varName]);
} else {
throw new Error(`変数${varName}が見つかりません`);
}
}
console.log(str);
return str;
};
const selectData = async (query?: string) => {
return kintone
.api(kintone.api.url("/k/v1/records", true), "GET", {
app: kintone.app.getId(),
query: query,
})
.then((resp: Resp) => {
const result: Result = {};
resp.records.forEach((element) => {
for (const [key, value] of Object.entries(element)) {
if (!result[key]) {
result[key] = { type: value.type, value: [] };
}
result[key].value.push(value.value);
}
});
return result;
});
};
type Resp = { records: RespRecordType[] };
type RespRecordType = {
[key: string]: {
type: string;
value: any;
};
};
type Result = {
[key: string]: {
type: string;
@@ -253,122 +61,125 @@ type AnyObject = {
[key: string]: any;
};
const ERROR_TYPE = "ERROR_TYPE";
export class DataProcessingAction implements IAction {
name: string;
actionProps: IActionProperty[];
props: IDataProcessingProps ;
const calc = (f: Var, result: Result) => {
const type = typeCheck(f.field.type);
if (!type) {
return ERROR_TYPE;
constructor() {
this.name = "データ処理";
this.actionProps = [];
this.props = {
displayName:'',
condition:'',
sources:{
app:{
id:""
},
fields:[]
},
};
this.register();
}
const fun =
calcFunc[
`${type}_${Operator[f.logicalOperator.operator as keyof typeof Operator]}`
];
if (!fun) {
return ERROR_TYPE;
}
const values = result[f.field.code].value;
if (!values) {
return null;
}
return fun(values);
};
async process(
nodes: IActionNode,
event: any,
context: IContext
): Promise<IActionResult> {
this.actionProps = nodes.actionProps;
const typeCheck = (type: string) => {
switch (type) {
case "RECORD_NUMBER":
case "NUMBER":
return CalcType.NUMBER;
case "SINGLE_LINE_TEXT":
case "MULTI_LINE_TEXT":
case "RICH_TEXT":
return CalcType.STRING;
case "DATE":
return CalcType.DATE;
case "TIME":
return CalcType.TIME;
case "DATETIME":
case "UPDATED_TIME":
return CalcType.DATETIME;
default:
return null;
}
};
this.props = nodes.ActionValue as IDataProcessingProps;
const condition = JSON.parse(this.props.condition) as Condition;
let result = {
canNext: true,
result: "",
} as IActionResult;
try {
if (!this.props) {
return result;
}
enum Operator {
SUM = "SUM",
AVG = "AVG",
MAX = "MAX",
MIN = "MIN",
COUNT = "COUNT",
FIRST = "FIRST",
const query = this.getQuery(condition.queryString,context.variables);
const data = await this.selectData(query);
console.log("data ", data);
if(this.props.verName){
const varValues= this.props.verName.vars.reduce((acc, f) => {
const datas=data[f.field.code].value;
const agg = new Aggregator(datas,f.field);
const result = agg.calculate(f.logicalOperator.operator)
acc[f.vName]=result;
return acc;
}, {} as AnyObject);
context.variables[this.props.verName.name]=varValues;
console.log("context ", context);
}
return result;
} catch (error) {
console.error("データ集計中例外が発生しました。", error);
if(error instanceof Error){
event.error = `データ集計中例外が発生しました。 ${error.message}`;
}else{
event.error = "データ集計中例外が発生しました。";
}
result.canNext = false;
return result;
}
}
/**
*
* @param str
* @param vars
* @returns
*/
getQuery = (str: string, vars: any) => {
console.log(str);
const regex = /var\((.*?)\)/g;
let match;
while ((match = regex.exec(str)) !== null) {
const varName = match[1];
if (varName in vars) {
str = str.replace(match[0], vars[varName]);
} else {
throw new Error(`変数${varName}が見つかりません`);
}
}
console.log(str);
return str;
};
/**
* データを取得する
* @param query
* @returns
*/
selectData = async ( query?: string) => {
const api = new KintoneRestAPIClient();
const fields = this.props.sources.fields.map((field)=>field.code);
const resp = await api.record.getAllRecords({
app: this.props.sources.app.id,
fields:fields,
condition:query
});
const result: Result = {};
resp.forEach((element) => {
for (const [key, value] of Object.entries(element)) {
if (!result[key]) {
result[key] = { type: value.type, value: [] };
}
result[key].value.push(value.value);
}
});
return result;
};
register(): void {
actionAddins[this.name] = this;
}
}
enum CalcType {
NUMBER = "number",
STRING = "string",
DATE = "date",
TIME = "time",
DATETIME = "datetime",
}
const calcFunc: Record<string, (value: string[]) => string | null> = {
[`${CalcType.NUMBER}_${Operator.COUNT}`]: (value: string[]) =>
value.length.toString(),
[`${CalcType.STRING}_${Operator.COUNT}`]: (value: string[]) =>
value.length.toString(),
[`${CalcType.DATE}_${Operator.COUNT}`]: (value: string[]) =>
value.length.toString(),
[`${CalcType.TIME}_${Operator.COUNT}`]: (value: string[]) =>
value.length.toString(),
[`${CalcType.DATETIME}_${Operator.COUNT}`]: (value: string[]) =>
value.length.toString(),
[`${CalcType.NUMBER}_${Operator.SUM}`]: (value: string[]) =>
value.reduce((acc, v) => acc + Number(v), 0).toString(),
[`${CalcType.NUMBER}_${Operator.AVG}`]: (value: string[]) =>
(value.reduce((acc, v) => acc + Number(v), 0) / value.length).toString(),
[`${CalcType.NUMBER}_${Operator.MAX}`]: (value: string[]) =>
Math.max(...value.map(Number)).toString(),
[`${CalcType.NUMBER}_${Operator.MIN}`]: (value: string[]) =>
Math.min(...value.map(Number)).toString(),
[`${CalcType.STRING}_${Operator.SUM}`]: (value: string[]) => value.join(" "),
[`${CalcType.DATE}_${Operator.MAX}`]: (value: string[]) =>
value.reduce((maxDate, currentDate) =>
maxDate > currentDate ? maxDate : currentDate
),
[`${CalcType.DATE}_${Operator.MIN}`]: (value: string[]) =>
value.reduce((minDate, currentDate) =>
minDate < currentDate ? minDate : currentDate
),
[`${CalcType.TIME}_${Operator.MAX}`]: (value: string[]) =>
value.reduce((maxTime, currentTime) =>
maxTime > currentTime ? maxTime : currentTime
),
[`${CalcType.TIME}_${Operator.MIN}`]: (value: string[]) =>
value.reduce((minTime, currentTime) =>
minTime < currentTime ? minTime : currentTime
),
[`${CalcType.DATETIME}_${Operator.MAX}`]: (value: string[]) =>
value.reduce((maxDateTime, currentDateTime) =>
new Date(maxDateTime) > new Date(currentDateTime)
? maxDateTime
: currentDateTime
),
[`${CalcType.DATETIME}_${Operator.MIN}`]: (value: string[]) =>
value.reduce((minDateTime, currentDateTime) =>
new Date(minDateTime) < new Date(currentDateTime)
? minDateTime
: currentDateTime
),
[`${CalcType.STRING}_${Operator.FIRST}`]: (value: string[]) => {
return value[0];
},
};
new DataProcessingAction();

View File

@@ -0,0 +1,240 @@
// src/types/field.d.ts
export enum FieldType {
CALC = "CALC",
CATEGORY = "CATEGORY",
CHECK_BOX = "CHECK_BOX",
CREATED_TIME = "CREATED_TIME",
CREATOR = "CREATOR",
DATE = "DATE",
DATETIME = "DATETIME",
DROP_DOWN = "DROP_DOWN",
FILE = "FILE",
GROUP = "GROUP",
GROUP_SELECT = "GROUP_SELECT",
LINK = "LINK",
MODIFIER = "MODIFIER",
MULTI_LINE_TEXT = "MULTI_LINE_TEXT",
MULTI_SELECT = "MULTI_SELECT",
NUMBER = "NUMBER",
ORGANIZATION_SELECT = "ORGANIZATION_SELECT",
RADIO_BUTTON = "RADIO_BUTTON",
RECORD_NUMBER = "RECORD_NUMBER",
REFERENCE_TABLE = "REFERENCE_TABLE",
RICH_TEXT = "RICH_TEXT",
SINGLE_LINE_TEXT = "SINGLE_LINE_TEXT",
STATUS = "STATUS",
STATUS_ASSIGNEE = "STATUS_ASSIGNEE",
SUBTABLE = "SUBTABLE",
TIME = "TIME",
UPDATED_TIME = "UPDATED_TIME",
USER_SELECT = "USER_SELECT"
}
export enum CalcFormat{
NUMBER = "NUMBER",
NUMBER_DIGIT = "NUMBER_DIGIT",
DATETIME = "DATETIME",
DATE = "DATE",
TIME = "TIME",
HOUR_MINUTE= "HOUR_MINUTE",
DAY_HOUR_MINUTE= "DAY_HOUR_MINUTE"
}
export interface FieldForm {
code: string;
type: FieldType;
required?: boolean;
noLabel?: boolean;
}
export interface OptionForm{
index: string;
label: string;
}
export interface EntityForm {
code: string;
type: 'USER' | 'ORGANIZATION' | 'GROUP' | 'FUNCTION';
}
export interface CalcFieldForm extends FieldForm {
unit?: string;
expression?: string;
format?:CalcFormat;
unitPosition?: 'BEFORE' | 'AFTER';
displayScale?: string;
hideExpression?: boolean;
}
export interface CategoryFieldForm extends FieldForm {
enabled?: boolean;
}
export interface DateFieldForm extends FieldForm {
defaultValue?: string;
unique?: boolean;
defaultNowValue?: boolean;
}
export interface DatetimeFieldForm extends FieldForm {
defaultValue?: string;
unique?: boolean;
defaultNowValue?: boolean;
}
export interface DecimalFieldForm extends FieldForm {
maxValue?: string;
defaultValue?: string;
unitPosition?: 'BEFORE' | 'AFTER';
minValue?: string;
unit?: string;
unique?: boolean;
displayScale?: string;
digit?: boolean;
}
export interface EditorFieldForm extends FieldForm {
defaultValue?: string;
}
export interface EntityForm {
code: string;
type: 'USER' | 'ORGANIZATION' | 'GROUP' | 'FUNCTION';
}
export interface FieldMappingForm {
relatedField: string;
field: string;
}
export interface FileFieldForm extends FieldForm {
thumbnailSize?: string;
}
export interface GroupFieldForm extends FieldForm {
openGroup?: boolean;
}
export interface GroupSelectFieldForm extends FieldForm {
entities: EntityForm[];
defaultValue?: EntityForm[];
}
export interface LinkFieldForm extends FieldForm {
protocol?: 'WEB' | 'CALL' | 'MAIL';
defaultValue?: string;
minLength?: string;
unique?: boolean;
maxLength?: string;
}
export interface LookupFieldForm extends FieldForm {
lookup: {
lookupPickerFields: string[];
relatedKeyField: string;
relatedApp: {
app: string;
code: string;
};
fieldMappings: FieldMappingForm[];
sort?: string;
filterCond?: string;
};
}
export interface ModifiedAtFieldForm extends FieldForm {}
export interface ModifierFieldForm extends FieldForm {}
export interface MultipleCheckFieldForm extends FieldForm {
defaultValue?: string[];
options: {
[key: string]: OptionForm;
};
align?: 'HORIZONTAL' | 'VERTICAL';
}
export interface MultipleLineTextFieldForm extends FieldForm {
defaultValue?: string;
}
export interface MultipleSelectFieldForm extends FieldForm {
defaultValue?: string[];
options: {
[key: string]: OptionForm;
};
}
export interface OrganizationSelectFieldForm extends FieldForm {
entities: EntityForm[];
defaultValue?: EntityForm[];
}
export interface RecordIdFieldForm extends FieldForm {}
export interface ReferenceTableFieldForm extends FieldForm {
referenceTable: {
condition: {
relatedField: string;
field: string;
};
size?: string;
relatedApp: {
app: string;
code: string;
};
sort?: string;
filterCond?: string;
displayFields: string[];
};
}
export interface SingleCheckFieldForm extends FieldForm {
defaultValue?: string;
options: {
[key: string]: OptionForm;
};
align?: 'HORIZONTAL' | 'VERTICAL';
}
export interface SingleLineTextFieldForm extends FieldForm {
expression?: string;
defaultValue?: string;
minLength?: string;
unique?: boolean;
maxLength?: string;
hideExpression?: boolean;
}
export interface SingleSelectFieldForm extends FieldForm {
defaultValue?: string;
options: {
[key: string]: OptionForm;
};
}
export interface StatusAssigneeFieldForm extends FieldForm {
enabled?: boolean;
}
export interface StatusFieldForm extends FieldForm {
enabled?: boolean;
}
export interface TableForm extends FieldForm {
fields: {
[key: string]: CalcFieldForm | CategoryFieldForm | DateFieldForm | DatetimeFieldForm | DecimalFieldForm | EditorFieldForm | FileFieldForm | GroupFieldForm | GroupSelectFieldForm | LinkFieldForm | LookupFieldForm | ModifiedAtFieldForm | ModifierFieldForm | MultipleCheckFieldForm | MultipleLineTextFieldForm | MultipleSelectFieldForm | OrganizationSelectFieldForm | RecordIdFieldForm | ReferenceTableFieldForm | SingleCheckFieldForm | SingleLineTextFieldForm | SingleSelectFieldForm | StatusAssigneeFieldForm | StatusFieldForm | TableForm | TimeFieldForm | UserSelectFieldForm;
};
}
export interface TimeFieldForm extends FieldForm {
defaultValue?: string;
defaultNowValue?: boolean;
}
export interface UserSelectFieldForm extends FieldForm {
entities: EntityForm[];
defaultValue?: EntityForm[];
}

View File

@@ -0,0 +1,121 @@
import { FieldForm,CalcFieldForm,FieldType, CalcFormat} from "../types/FieldLayout";
/**
* 集計操作子
*/
export enum Operator {
SUM = "SUM",
AVG = "AVG",
MAX = "MAX",
MIN = "MIN",
COUNT = "COUNT",
FIRST = "FIRST",
LAST = "LAST"
}
/**
* 集計関数
*/
export class Aggregator {
private data: string[];
private field: FieldForm;
constructor(data: string[], field: FieldForm) {
this.data = data;
this.field = field;
}
private toNumberArray(): number[] {
const numberArray = this.data.map(item => {
const num = Number(item);
if (isNaN(num)) {
throw new Error(`フィールド「${this.field.code} 」は数値に変換できないため、計算を実行できません。`);
}
return num;
});
return numberArray;
}
private sum(): number | null {
if (this.data.length === 0) return null;
const numberArray = this.toNumberArray();
return numberArray.reduce((acc, val) => acc + val, 0);
}
private avg(): number | null {
if (this.data.length === 0) return null;
const numberArray = this.toNumberArray();
const total = numberArray.reduce((acc, val) => acc + val, 0);
return total / numberArray.length;
}
private max(): number | string | null {
if (this.data.length === 0) return null;
if (this.field.type === FieldType.NUMBER ) {
const numberArray = this.toNumberArray();
return Math.max(...numberArray);
}
if(this.field.type===FieldType.CALC){
const calcField = this.field as CalcFieldForm;
if(calcField.format===CalcFormat.NUMBER || calcField.format===CalcFormat.NUMBER_DIGIT){
const numberArray = this.toNumberArray();
return Math.max(...numberArray);
}
}
return this.data.reduce((max, item) => (item > max ? item : max), this.data[0]);
}
private min(): number | string | null {
if (this.data.length === 0) return null;
if (this.field.type === FieldType.NUMBER ) {
const numberArray = this.toNumberArray();
return Math.min(...numberArray);
}
if(this.field.type===FieldType.CALC){
const calcField = this.field as CalcFieldForm;
if(calcField.format===CalcFormat.NUMBER || calcField.format===CalcFormat.NUMBER_DIGIT){
const numberArray = this.toNumberArray();
return Math.min(...numberArray);
}
}
if(this.data===null || this.data.length===0){
return null;
}
return this.data.reduce((min, item) => (item < min ? item : min), this.data[0]);
}
private count(): number {
return this.data.length;
}
private first(): string | null {
return this.data.length ? this.data[0] : null;
}
private last(): string | null {
return this.data.length ? this.data[this.data.length - 1] : null;
}
public calculate(operator: Operator): number | string | null {
switch (operator) {
case Operator.SUM:
return this.sum();
case Operator.AVG:
return this.avg();
case Operator.MAX:
return this.max();
case Operator.MIN:
return this.min();
case Operator.COUNT:
return this.count();
case Operator.FIRST:
return this.first();
case Operator.LAST:
return this.last();
default:
return null;
}
}
}