Prace nad celym modelem: cesty, ukazka tela a smycka nad seznamem
Odesilatel posila cely model. Objednavka ze Shoptetu ma zanoreni, ceny
v podobjektech a seznam polozek - a dosud sel napojit jen plochy seznam
skalarnich poli, takze items[] neslo pouzit vubec.
- Odkaz v sablone muze byt cesta: {{data.order.billingAddress.city}},
{{data.order.items[0].name}}, {{st_faktura.invoiceId}}. Overuje se prvni
cast odkazu, takze ploche odkazy funguji dal presne jako driv.
- Cele telo je v kontextu i v puvodnim tvaru. Deklarovane parametry maji
pri shode jmen prednost.
- Spoustec si pamatuje ukazku skutecneho tela. Server z ni odvodi model,
tedy seznam cest i s typy, a ten se v krocich klika misto opisovani.
Tlacitko doplni z hodnot v ukazce parametry pro podminky.
- Novy krok Pro kazdou polozku: projde seznam a za kazdou polozku vykona
vnoreny podstrom. Uvnitr je item a index, po skonceni krok.results se
seznamem vysledku. Kazdy vysledek nese i puvodni polozku - radek
objednavky potrebuje jak ID z CRM, tak mnozstvi z puvodnich dat.
Strop 200 polozek, mimo seznam krok selze s tim, co tam misto nej je.
- Prevod Za kazdou polozku seznamu v klikacim editoru mapovani. Engine ho
umel, sel ale napsat jen rucnim JSONem.
- Typograficke uvozovky a sipka z kodu pryc.
Overeno nad skutecnym modelem objednavky: 23 cest vcetne
data.order.items[].unitPrice.withoutVat, sablony s cestou i s indexem,
smycka nad dvema polozkami s posbiranymi vysledky.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc0c2ff6c7
commit
9b18531d3e
+90
-14
@@ -14,7 +14,8 @@ import { withMirror } from './store/mirror.js';
|
||||
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
|
||||
import { actionInputsFor, findService } from './services.js';
|
||||
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
|
||||
import { referencedFields } from './templates.js';
|
||||
import { referencedFields, rootOf } from './templates.js';
|
||||
import { describeModel, rootsOf, type ModelNode } from './model.js';
|
||||
|
||||
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
|
||||
|
||||
@@ -42,6 +43,18 @@ export interface FlowTrigger {
|
||||
operationId: string;
|
||||
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
|
||||
fields: TriggerField[];
|
||||
/**
|
||||
* Ukazka toho, co odesilatel opravdu posila.
|
||||
*
|
||||
* Vlepi se cele telo z realneho volani. Z nej se odvodi model, tedy seznam
|
||||
* cest i s typy, ktery se v builderu ukaze jako strom a klikanim se z nej
|
||||
* vklada odkaz do sablony.
|
||||
*
|
||||
* Proc ukazka a ne rucne psany seznam poli: model objednavky ze Shoptetu ma
|
||||
* desitky cest a seznam polozek. Vypsat ho po jednom nikdo nebude a pole
|
||||
* do plocheho seznamu nepatri vubec.
|
||||
*/
|
||||
sample?: unknown;
|
||||
/**
|
||||
* Jak casto se ma sluzba obvolavat, kdyz nam sama nezavola (v sekundach).
|
||||
* Plati jen u spoustecu, ktere se musi ptat. Minimum je 10 s.
|
||||
@@ -91,6 +104,25 @@ export type FlowStep =
|
||||
value?: string;
|
||||
yes: FlowStep[];
|
||||
no: FlowStep[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
/**
|
||||
* Podstrom, ktery se vykona **nad kazdou polozkou seznamu**.
|
||||
*
|
||||
* Bez tohohle nejde rict "kazdou polozku objednavky zaloz v CRM". Jedno
|
||||
* volani se seznamem uvnitr se da poskladat transformaci, ale kdyz ma
|
||||
* kazda polozka vyvolat vlastni akci a vratit vlastni ID, je potreba
|
||||
* smycka.
|
||||
*
|
||||
* Uvnitr je k dispozici `item` (cela polozka) a `index`. Po skonceni
|
||||
* `krok.results`, coz je seznam vysledku za kazdou polozku - prave to se
|
||||
* pak posila dal jako radky objednavky.
|
||||
*/
|
||||
kind: 'foreach';
|
||||
/** Cesta k seznamu v datech, napr. `data.order.items`. */
|
||||
path: string;
|
||||
steps: FlowStep[];
|
||||
};
|
||||
|
||||
export interface AutomationFlow {
|
||||
@@ -140,6 +172,14 @@ export interface Automation {
|
||||
|
||||
export interface AutomationDetail extends Automation {
|
||||
flow: AutomationFlow;
|
||||
/**
|
||||
* Model prichozich dat odvozeny z ukazky u spoustece.
|
||||
*
|
||||
* Pocita se pri cteni, neuklada se - je to pohled na `trigger.sample`.
|
||||
* Kdyby se ukladal, sel by rozejit s ukazkou a nikdo by nepoznal, ktery
|
||||
* z nich plati.
|
||||
*/
|
||||
model: ModelNode[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -225,6 +265,9 @@ export function countSteps(steps: FlowStep[]): number {
|
||||
if (step.kind === 'condition') {
|
||||
return sum + 1 + countSteps(step.yes) + countSteps(step.no);
|
||||
}
|
||||
if (step.kind === 'foreach') {
|
||||
return sum + 1 + countSteps(step.steps);
|
||||
}
|
||||
return sum + 1;
|
||||
}, 0);
|
||||
}
|
||||
@@ -239,6 +282,11 @@ export function countSteps(steps: FlowStep[]): number {
|
||||
function actionInputIssues(
|
||||
step: Extract<FlowStep, { kind: 'action' }>,
|
||||
available: TriggerField[],
|
||||
/**
|
||||
* Koreny, ktere jsou v poradku i kdyz nejsou deklarovanym parametrem:
|
||||
* klice z ukazky tela, ID predchozich kroku, `item` a `index` uvnitr smycky.
|
||||
*/
|
||||
extraRoots: Set<string>,
|
||||
): string[] {
|
||||
const knownNames = new Set(available.map((field) => field.name));
|
||||
const catalog = actionInputsFor(step.serviceId, step.operationId);
|
||||
@@ -251,14 +299,19 @@ function actionInputIssues(
|
||||
const value = step.inputs?.[field.id] ?? '';
|
||||
|
||||
if (field.required && value.trim().length === 0) {
|
||||
issues.push(`Krok „${operationName}": chybí ${field.label.toLowerCase()}.`);
|
||||
issues.push(`Krok "${operationName}": chybí ${field.label.toLowerCase()}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of referencedFields(value)) {
|
||||
if (!knownNames.has(name)) {
|
||||
for (const reference of referencedFields(value)) {
|
||||
/*
|
||||
* Overuje se jen **prvni cast** odkazu. Zbytek je cesta do struktury
|
||||
* a tu predem overit nejde - co presne prijde v tele, vime az pri behu.
|
||||
*/
|
||||
const root = rootOf(reference);
|
||||
if (!knownNames.has(root) && !extraRoots.has(root)) {
|
||||
issues.push(
|
||||
`Krok „${operationName}", pole ${field.label.toLowerCase()}: parametr „${name}" u spouštěče neexistuje.`,
|
||||
`Krok "${operationName}", pole ${field.label.toLowerCase()}: parametr "${root}" u spouštěče neexistuje.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -291,18 +344,37 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
|
||||
|
||||
const scopes = collectScopes(flow);
|
||||
|
||||
const walk = (steps: FlowStep[]) => {
|
||||
/*
|
||||
* Co dalsiho smi stat na zacatku odkazu. Klice z ukazky tela, protoze prave
|
||||
* kvuli nim se ukazka vlepuje, a `_body` s celym telem.
|
||||
*/
|
||||
const sampleRoots = rootsOf(flow.trigger.sample);
|
||||
sampleRoots.add('_body');
|
||||
|
||||
const walk = (steps: FlowStep[], roots: Set<string>) => {
|
||||
for (const step of steps) {
|
||||
const available = scopeFor(scopes, step.id);
|
||||
|
||||
for (const name of duplicateNames(available)) {
|
||||
issues.push(
|
||||
`Parametr „${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`,
|
||||
`Parametr "${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (step.kind === 'action') {
|
||||
issues.push(...actionInputIssues(step, available));
|
||||
issues.push(...actionInputIssues(step, available, roots));
|
||||
// ID kroku smi stat na zacatku odkazu: `{{st_faktura.invoiceId}}`.
|
||||
roots.add(step.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.kind === 'foreach') {
|
||||
if (step.path.trim().length === 0) {
|
||||
issues.push('Smyčka nemá vyplněnou cestu k seznamu.');
|
||||
}
|
||||
// Uvnitr smycky pribyva polozka a poradi, po ni vysledky za cely seznam.
|
||||
walk(step.steps, new Set([...roots, 'item', 'index']));
|
||||
roots.add(step.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -316,18 +388,18 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
|
||||
: 'Podmínka se odkazuje na parametr, který už neexistuje.',
|
||||
);
|
||||
} else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) {
|
||||
issues.push(`Podmínka nad parametrem „${field.name}" nemá vyplněnou hodnotu.`);
|
||||
issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`);
|
||||
} else if (field.type === 'number' && !isUnary(step.operator)) {
|
||||
if (Number.isNaN(Number(step.value))) {
|
||||
issues.push(`Podmínka nad parametrem „${field.name}" má nečíselnou hodnotu.`);
|
||||
issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`);
|
||||
}
|
||||
}
|
||||
|
||||
walk(step.yes);
|
||||
walk(step.no);
|
||||
walk(step.yes, new Set(roots));
|
||||
walk(step.no, new Set(roots));
|
||||
}
|
||||
};
|
||||
walk(flow.steps);
|
||||
walk(flow.steps, sampleRoots);
|
||||
|
||||
// Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru).
|
||||
// Uzivateli staci rict jednou.
|
||||
@@ -340,6 +412,7 @@ function flowUsesCategory(steps: FlowStep[], category: string): boolean {
|
||||
if (step.kind === 'condition') {
|
||||
return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category);
|
||||
}
|
||||
if (step.kind === 'foreach') return flowUsesCategory(step.steps, category);
|
||||
return findService(step.serviceId)?.category === category;
|
||||
});
|
||||
}
|
||||
@@ -514,7 +587,7 @@ function seedDemoAutomations(): void {
|
||||
});
|
||||
|
||||
seed({
|
||||
name: 'Synchronizace CRM ↔ účetnictví',
|
||||
name: 'Synchronizace CRM a účetnictví',
|
||||
enabled: true,
|
||||
runsToday: 96,
|
||||
successRate: 98.9,
|
||||
@@ -980,6 +1053,9 @@ function toDetail(stored: StoredAutomation): AutomationDetail {
|
||||
return {
|
||||
...toSummary(stored),
|
||||
flow: stored.flow,
|
||||
model: stored.flow.trigger?.sample === undefined
|
||||
? []
|
||||
: describeModel(stored.flow.trigger.sample),
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
|
||||
@@ -165,7 +165,7 @@ export function validateConnectorValues(
|
||||
const issues: ConnectorIssue[] = [];
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!known.has(key)) {
|
||||
issues.push({ field: key, message: `Služba ${service.name} pole „${key}" nemá.` });
|
||||
issues.push({ field: key, message: `Služba ${service.name} pole "${key}" nemá.` });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
|
||||
@@ -103,16 +103,16 @@ export function validateLayout(items: LayoutItem[], catalog: CatalogWidget[]): s
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) problems.push(`Widget s ID „${item.id}" je uvedený dvakrát.`);
|
||||
if (seen.has(item.id)) problems.push(`Widget s ID "${item.id}" je uvedený dvakrát.`);
|
||||
seen.add(item.id);
|
||||
|
||||
const definition = byId.get(item.widgetId);
|
||||
if (!definition) {
|
||||
problems.push(`Widget „${item.widgetId}" v katalogu neexistuje.`);
|
||||
problems.push(`Widget "${item.widgetId}" v katalogu neexistuje.`);
|
||||
continue;
|
||||
}
|
||||
if (!definition.sizes.includes(item.size)) {
|
||||
problems.push(`Widget „${definition.name}" nelze zobrazit v této šířce.`);
|
||||
problems.push(`Widget "${definition.name}" nelze zobrazit v této šířce.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,31 @@ export function collectScopes(flow: AutomationFlow): FlowScopes {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.kind === 'foreach') {
|
||||
/*
|
||||
* Uvnitr smycky je navic polozka a jeji poradi. Ven se nedostanou -
|
||||
* za smyckou uz zadna "aktualni polozka" neni.
|
||||
*/
|
||||
const inside: TriggerField[] = [
|
||||
...available,
|
||||
{ id: `${step.id}.item`, name: 'item', type: 'object', required: true },
|
||||
{ id: `${step.id}.index`, name: 'index', type: 'number', required: true },
|
||||
];
|
||||
walk(step.steps, inside);
|
||||
|
||||
/*
|
||||
* Po smycce je k dispozici seznam vysledku za kazdou polozku. Prave to
|
||||
* se posila dal jako radky objednavky nebo faktury.
|
||||
*/
|
||||
const results: TriggerField[] = [
|
||||
{ id: `${step.id}.results`, name: `${step.id}.results`, type: 'list', required: true },
|
||||
{ id: `${step.id}.count`, name: `${step.id}.count`, type: 'number', required: true },
|
||||
];
|
||||
for (const output of results) all.set(output.id, output);
|
||||
available = [...available, ...results];
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputs = outputsOf(step);
|
||||
if (outputs.length === 0) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Model prichozich dat odvozeny z ukazky.
|
||||
*
|
||||
* Odesilatel posle cely objekt - objednavka ze Shoptetu ma zanoreni, ceny
|
||||
* v podobjektech a seznam polozek. Vypsat to rucne po jednom poli nikdo nebude
|
||||
* a seznam polozek se do plocheho seznamu parametru nevejde vubec.
|
||||
*
|
||||
* Proto se vlepi **ukazka skutecneho tela** a z ni se odvodi seznam cest. Ten
|
||||
* slouzi ke trem vecem:
|
||||
* - v builderu je z nej strom, ze ktereho se klikanim vklada `{{cesta}}`,
|
||||
* - validace pozna, ze `{{data.order.code}}` neni preklep,
|
||||
* - u seznamu je videt, ze je to seznam, a co ma polozka uvnitr.
|
||||
*
|
||||
* Odvozuje se **z jedne ukazky**, takze to neni schema v pravem slova smyslu:
|
||||
* co odesilatel jednou neposlal, tady nebude. To je prijatelne - je to napoveda
|
||||
* pro cloveka, ne kontrakt, na kterem stoji beh.
|
||||
*/
|
||||
|
||||
import type { FieldType } from './conditions.js';
|
||||
|
||||
export interface ModelNode {
|
||||
/** Cesta k hodnote, napr. `data.order.items[].name`. */
|
||||
path: string;
|
||||
type: FieldType;
|
||||
/** Ukazkova hodnota, zkracena. Podle ni clovek pozna, co to je. */
|
||||
sample: string | null;
|
||||
/** Kolik polozek melo pole v ukazce. Jen u typu `list`. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** Kolik urovni zanoreni se prochazi. Hlubsi model uz nikdo necte. */
|
||||
const MAX_DEPTH = 8;
|
||||
/** Strop poctu cest. Chrani builder pred modelem o tisicich radcich. */
|
||||
const MAX_NODES = 400;
|
||||
|
||||
function typeOf(value: unknown): FieldType {
|
||||
if (Array.isArray(value)) return 'list';
|
||||
if (value === null) return 'string';
|
||||
switch (typeof value) {
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'object':
|
||||
return 'object';
|
||||
default:
|
||||
return isDate(value as string) ? 'date' : 'string';
|
||||
}
|
||||
}
|
||||
|
||||
/** Vypada to jako datum? Jen podle tvaru, at se v modelu pozna cas od textu. */
|
||||
function isDate(value: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function shorten(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return text.length > 60 ? `${text.slice(0, 60)}...` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam cest v ukazce.
|
||||
*
|
||||
* U pole se vytvori jeden uzel pro pole samotne a pod nim cesty polozky
|
||||
* s `[]` misto indexu: `data.order.items[].name`. Index se do napovedy nehodi -
|
||||
* clovek chce vedet, co polozka ma, ne co mela ta prvni.
|
||||
*/
|
||||
export function describeModel(sample: unknown): ModelNode[] {
|
||||
const nodes: ModelNode[] = [];
|
||||
|
||||
const walk = (value: unknown, path: string, depth: number): void => {
|
||||
if (nodes.length >= MAX_NODES || depth > MAX_DEPTH) return;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
nodes.push({ path, type: 'list', sample: null, count: value.length });
|
||||
// Tvar polozky se bere z prvni. Ruzne tvary v jednom poli jsou vzacne
|
||||
// a hadat z nich prunik by dalo model, ktery neodpovida nicemu.
|
||||
if (value.length > 0) walk(value[0], `${path}[]`, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
if (path !== '') nodes.push({ path, type: 'object', sample: null });
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
walk(item, path === '' ? key : `${path}.${key}`, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.push({ path, type: typeOf(value), sample: shorten(value) });
|
||||
};
|
||||
|
||||
walk(sample, '', 0);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Prvni cast kazde cesty. Podle ni se pozna, jestli odkaz ma o co se oprit. */
|
||||
export function rootsOf(sample: unknown): Set<string> {
|
||||
if (sample === null || typeof sample !== 'object' || Array.isArray(sample)) return new Set();
|
||||
return new Set(Object.keys(sample as Record<string, unknown>));
|
||||
}
|
||||
+57
-14
@@ -1,22 +1,32 @@
|
||||
/**
|
||||
* Sablony hodnot v krocich stromu.
|
||||
*
|
||||
* Hodnota nastavitelneho pole akce muze obsahovat odkazy na parametry
|
||||
* spoustece: `Reklamace od {{profileName}}`. Pri behu se `{{...}}` nahradi
|
||||
* skutecnou hodnotou.
|
||||
* Hodnota nastavitelneho pole akce muze obsahovat odkazy na to, co uz v behu
|
||||
* je: `Reklamace od {{profileName}}`. Pri behu se `{{...}}` nahradi skutecnou
|
||||
* hodnotou.
|
||||
*
|
||||
* Odkazuje se **jmenem** parametru, ne jeho ID. Uzivatel jmeno vidi a pise,
|
||||
* Odkazuje se **jmenem**, ne ID parametru. Uzivatel jmeno vidi a pise,
|
||||
* `{{f_42}}` by nikdo neprecetl. Cenou je, ze prejmenovani parametru sablonu
|
||||
* rozbije - proto se to hlida a hlasi jako nedodelek, ne jako tichá chyba.
|
||||
*
|
||||
* Odkaz muze byt i **cesta do struktury**: `{{data.order.billingAddress.city}}`
|
||||
* nebo `{{data.order.items[0].name}}`. Bez toho by slo pracovat jen s tim, co
|
||||
* nekdo predem vypsal jako ploche parametry - a cely model objednavky ze
|
||||
* Shoptetu se vypsat neda.
|
||||
*
|
||||
* POZOR: stejna logika je na klientovi v web/src/lib/flow.ts.
|
||||
* Server je autorita, kopie na klientovi existuje jen kvuli napovede v UI.
|
||||
*/
|
||||
|
||||
/** `{{nazev}}`, mezery kolem nazvu jsou povolene. */
|
||||
const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
import { getPath } from '../scripts/mapping.js';
|
||||
|
||||
/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit, v poradi vyskytu. */
|
||||
/**
|
||||
* `{{nazev}}` nebo `{{cesta.do.struktury}}` i s indexy `{{items[0].name}}`.
|
||||
* Mezery kolem jsou povolene.
|
||||
*/
|
||||
const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+|\[\d+\])*)\s*\}\}/g;
|
||||
|
||||
/** Odkazy, na ktere sablona ukazuje. Bez duplicit, v poradi vyskytu. */
|
||||
export function referencedFields(template: string): string[] {
|
||||
const found: string[] = [];
|
||||
for (const match of template.matchAll(REFERENCE)) {
|
||||
@@ -27,16 +37,49 @@ export function referencedFields(template: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dosadi hodnoty za odkazy. Chybejici parametr se nahradi prazdnym retezcem
|
||||
* a zaloguje - nechat v textu `{{neco}}` by znamenalo poslat to zakaznikovi.
|
||||
* Prvni cast odkazu, tedy jmeno parametru nebo kroku.
|
||||
*
|
||||
* Podle nej se pozna, jestli odkaz vubec ma o co se oprit. Zbytek je cesta
|
||||
* do struktury a tu predem overit nejde - co presne prijde v tele, vime az
|
||||
* pri behu.
|
||||
*/
|
||||
export function renderTemplate(template: string, values: Record<string, string>): string {
|
||||
return template.replace(REFERENCE, (_match, name: string) => {
|
||||
const value = values[name];
|
||||
export function rootOf(reference: string): string {
|
||||
const dot = reference.indexOf('.');
|
||||
const bracket = reference.indexOf('[');
|
||||
const end = Math.min(dot === -1 ? reference.length : dot, bracket === -1 ? reference.length : bracket);
|
||||
return reference.slice(0, end);
|
||||
}
|
||||
|
||||
function asText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (value === null || value === undefined) return '';
|
||||
// Struktura se preda jako JSON. Vstup typu object nebo list si ji rozparsuje zpet.
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dosadi hodnoty za odkazy. Chybejici odkaz se nahradi prazdnym retezcem
|
||||
* a zaloguje - nechat v textu `{{neco}}` by znamenalo poslat to zakaznikovi.
|
||||
*
|
||||
* Hleda se **nejdriv presny klic**, teprve pak cesta. Runtime uklada vystupy
|
||||
* kroku i pod klic s teckou (`st_faktura.invoiceId`), takze presna shoda musi
|
||||
* mit prednost - jinak by se hledal krok `st_faktura` a v nem pole `invoiceId`,
|
||||
* coz je sice totez, ale jen nahodou.
|
||||
*/
|
||||
export function renderTemplate(template: string, source: unknown): string {
|
||||
return template.replace(REFERENCE, (_match, reference: string) => {
|
||||
const direct =
|
||||
source !== null && typeof source === 'object'
|
||||
? (source as Record<string, unknown>)[reference]
|
||||
: undefined;
|
||||
|
||||
const value = direct === undefined ? getPath(source, reference) : direct;
|
||||
|
||||
if (value === undefined) {
|
||||
console.warn(`[template] odkaz na neznamy parametr "${name}", dosazuji prazdno`);
|
||||
console.warn(`[template] odkaz na neznamy parametr "${reference}", dosazuji prazdno`);
|
||||
return '';
|
||||
}
|
||||
return value;
|
||||
return asText(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function validateTicketFields(
|
||||
}
|
||||
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!known.has(key)) problems.push(`Typ ${type.name} pole „${key}" nemá.`);
|
||||
if (!known.has(key)) problems.push(`Typ ${type.name} pole "${key}" nemá.`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
|
||||
+34
-5
@@ -798,6 +798,13 @@ const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
|
||||
// Klic je ID pole z katalogu, hodnota sablona. Overuje se nize.
|
||||
inputs: z.record(z.string()).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.literal('foreach'),
|
||||
/** Cesta k seznamu v datech, napr. `data.order.items`. */
|
||||
path: z.string().trim().min(1, 'Smyčka musí mít cestu k seznamu.').max(200),
|
||||
steps: z.array(stepSchema),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.literal('condition'),
|
||||
@@ -849,6 +856,19 @@ const flowSchema = z.object({
|
||||
serviceId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
fields: z.array(fieldSchema),
|
||||
/**
|
||||
* Ukazka skutecneho tela. Odvozuje se z ni model, viz data/model.ts.
|
||||
*
|
||||
* Strop je kvuli tomu, ze se uklada s automatizaci a cte pri kazdem
|
||||
* nacteni - cele katalogy sem nepatri.
|
||||
*/
|
||||
sample: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.refine(
|
||||
(value) => value === undefined || JSON.stringify(value).length <= 100_000,
|
||||
'Ukázka těla je moc velká, vejde se 100 kB.',
|
||||
),
|
||||
/** Jak casto se ma sluzba obvolavat. Plati jen u spoustecu, co se ptaji. */
|
||||
intervalSec: z.number().int().min(10).max(86_400).optional(),
|
||||
// Token generuje server. Cokoliv od klienta se ignoruje.
|
||||
@@ -910,7 +930,7 @@ async function validateFlowReferences(
|
||||
const names = new Set<string>();
|
||||
for (const field of flow.trigger.fields) {
|
||||
if (names.has(field.name)) {
|
||||
problems.push(`Parametr „${field.name}" je uvedený dvakrát.`);
|
||||
problems.push(`Parametr "${field.name}" je uvedený dvakrát.`);
|
||||
}
|
||||
names.add(field.name);
|
||||
}
|
||||
@@ -927,13 +947,22 @@ async function validateFlowReferences(
|
||||
problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`);
|
||||
} else if (!operatorAllowedForType(step.operator, field.type)) {
|
||||
problems.push(
|
||||
`Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`,
|
||||
`Operátor "${step.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`,
|
||||
);
|
||||
}
|
||||
await walk(step.yes);
|
||||
await walk(step.no);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.kind === 'foreach') {
|
||||
if (step.path.trim().length === 0) {
|
||||
problems.push('Smyčka musí mít cestu k seznamu, například data.order.items.');
|
||||
}
|
||||
await walk(step.steps);
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = findOperation(step.serviceId, step.operationId, 'action');
|
||||
if (!action) {
|
||||
problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`);
|
||||
@@ -946,7 +975,7 @@ async function validateFlowReferences(
|
||||
for (const key of Object.keys(step.inputs ?? {})) {
|
||||
if (!allowed.has(key)) {
|
||||
problems.push(
|
||||
`Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole „${key}".`,
|
||||
`Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole "${key}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -965,12 +994,12 @@ async function validateFlowReferences(
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'neplatný JSON';
|
||||
issues.push(`Pole „${input.label}" není platný JSON: ${detail}`);
|
||||
issues.push(`Pole "${input.label}" není platný JSON: ${detail}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.kind === 'mapping') {
|
||||
issues.push(...validateRules(parsed, `pole „${input.label}"`));
|
||||
issues.push(...validateRules(parsed, `pole "${input.label}"`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,13 @@ webhookRouter.post('/:token', (req, res) => {
|
||||
* pod `_body`. Diky tomu se v sablonach pise `{{orderId}}` bez ohledu na
|
||||
* to, jak hluboko to odesilatel schoval, a zaroven se nic neztrati.
|
||||
*/
|
||||
payload: { ...values, _body: body },
|
||||
/*
|
||||
* Krome pojmenovanych hodnot i **cele telo v puvodnim tvaru**, takze
|
||||
* `{{data.order.billingAddress.city}}` funguje, aniz by to nekdo predem
|
||||
* vypsal jako parametr. Deklarovane hodnoty maji prednost: kdyz si nekdo
|
||||
* pojmenuje parametr stejne jako klic v tele, plati jeho.
|
||||
*/
|
||||
payload: { ...body, ...values, _body: body },
|
||||
})
|
||||
.then((item) => {
|
||||
console.info(
|
||||
@@ -283,13 +289,13 @@ function readPayload(
|
||||
if (value === undefined || value === null) {
|
||||
if (field.required) {
|
||||
const where = field.path && field.path !== field.name ? ` (cesta ${field.path})` : '';
|
||||
problems.push(`Chybí povinný parametr „${field.name}"${where}.`);
|
||||
problems.push(`Chybí povinný parametr "${field.name}"${where}.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesType(value, field.type)) {
|
||||
problems.push(`Parametr „${field.name}" má mít typ ${field.type}.`);
|
||||
problems.push(`Parametr "${field.name}" má mít typ ${field.type}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+134
-7
@@ -25,6 +25,7 @@ import { defaultConnectorFor, getConnector, type Connector } from '../data/conne
|
||||
import type { FlowStep } from '../data/automationStore.js';
|
||||
import { isUnary, type ConditionOperator } from '../data/conditions.js';
|
||||
import { renderTemplate } from '../data/templates.js';
|
||||
import { getPath } from '../scripts/mapping.js';
|
||||
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
@@ -58,7 +59,7 @@ export interface RunOptions {
|
||||
|
||||
export interface StepResult {
|
||||
stepId: string;
|
||||
kind: 'action' | 'condition';
|
||||
kind: 'action' | 'condition' | 'foreach';
|
||||
label: string;
|
||||
ok: boolean;
|
||||
/** true = do logu ticketu se nezapisuje. Nic se nestalo, viz StepOutcome. */
|
||||
@@ -94,6 +95,15 @@ export interface RunResult {
|
||||
/** Vychozi strop kroku. Vic uz neni strom, ale program. */
|
||||
const MAX_STEPS = 50;
|
||||
|
||||
/**
|
||||
* Kolik polozek smycka nejvys projde.
|
||||
*
|
||||
* Objednavka s tisicem radku znamena tisic volani cizi sluzby. To uz neni
|
||||
* automatizace, to je zatez, kterou nikdo necekal - proto strop a jasna hlaska
|
||||
* misto tichého useknuti.
|
||||
*/
|
||||
const MAX_LOOP_ITEMS = 200;
|
||||
|
||||
/**
|
||||
* Vykona strom.
|
||||
*
|
||||
@@ -140,6 +150,114 @@ export async function runFlow(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.kind === 'foreach') {
|
||||
const startedAt = Date.now();
|
||||
const source = getPath(working, step.path);
|
||||
|
||||
if (!Array.isArray(source)) {
|
||||
const result: StepResult = {
|
||||
stepId: step.id,
|
||||
kind: 'foreach',
|
||||
label: `pro každou položku: ${step.path}`,
|
||||
ok: false,
|
||||
summary: `na cestě ${step.path} není seznam`,
|
||||
detail:
|
||||
`Očekával se seznam, přišlo ${describeValue(source)}. ` +
|
||||
'Zkontrolujte cestu proti ukázce těla u spouštěče.',
|
||||
// Spatna cesta za minutu spravna nebude.
|
||||
retryable: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
results.push(result);
|
||||
error = result.summary;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (source.length > MAX_LOOP_ITEMS) {
|
||||
const result: StepResult = {
|
||||
stepId: step.id,
|
||||
kind: 'foreach',
|
||||
label: `pro každou položku: ${step.path}`,
|
||||
ok: false,
|
||||
summary: `seznam má ${source.length} položek, strop je ${MAX_LOOP_ITEMS}`,
|
||||
detail:
|
||||
`Smyčka by udělala ${source.length} průchodů. Tolik volání cizí služby ` +
|
||||
'nikdo nečeká, takže se běh zastavil. Rozdělte data, nebo použijte ' +
|
||||
'krok Transformace, který zpracuje celý seznam jedním voláním.',
|
||||
retryable: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
results.push(result);
|
||||
error = result.summary;
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vysledky za kazdou polozku. Krome vystupu kroku nese kazdy zaznam
|
||||
* i **puvodni polozku** - radek objednavky potrebuje jak ID z CRM,
|
||||
* tak mnozstvi, ktere prislo v puvodnich datech.
|
||||
*/
|
||||
const collected: Array<Record<string, unknown>> = [];
|
||||
const outer = { item: working.item, index: working.index };
|
||||
let failed = false;
|
||||
|
||||
/*
|
||||
* Radek smycky se zapisuje **pred** jejimi kroky a dopise se az potom.
|
||||
* Kdyby se pridal na konec, cetl by se log pozpatku: nejdriv co se delo
|
||||
* uvnitr a teprve pak, ze slo o smycku.
|
||||
*/
|
||||
const loopResult: StepResult = {
|
||||
stepId: step.id,
|
||||
kind: 'foreach',
|
||||
label: `pro každou položku: ${step.path}`,
|
||||
ok: true,
|
||||
summary: `${source.length}x`,
|
||||
detail: null,
|
||||
durationMs: 0,
|
||||
};
|
||||
results.push(loopResult);
|
||||
|
||||
for (const [index, item] of source.entries()) {
|
||||
working.item = item;
|
||||
working.index = index;
|
||||
|
||||
const before = results.length;
|
||||
const okRun = await walk(step.steps);
|
||||
|
||||
const merged: Record<string, unknown> = { index, item };
|
||||
for (const inner of results.slice(before)) {
|
||||
const outputs = working[inner.stepId];
|
||||
if (outputs !== null && typeof outputs === 'object' && !Array.isArray(outputs)) {
|
||||
Object.assign(merged, outputs as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
collected.push(merged);
|
||||
|
||||
if (!okRun) {
|
||||
failed = true;
|
||||
error = `${step.path}[${index}]: ${error ?? 'krok selhal'}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Polozka a poradi patri jen dovnitr smycky. Venku by matly.
|
||||
working.item = outer.item;
|
||||
working.index = outer.index;
|
||||
|
||||
working[step.id] = { results: collected, count: collected.length };
|
||||
working[`${step.id}.results`] = collected;
|
||||
working[`${step.id}.count`] = collected.length;
|
||||
|
||||
loopResult.ok = !failed;
|
||||
loopResult.summary = failed
|
||||
? `zastaveno na položce ${collected.length} z ${source.length}`
|
||||
: `${source.length}x`;
|
||||
loopResult.durationMs = Date.now() - startedAt;
|
||||
|
||||
if (failed) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await runAction(step, working, options);
|
||||
results.push(result);
|
||||
if (!result.ok) {
|
||||
@@ -314,18 +432,27 @@ function fillTemplates(
|
||||
inputs: Record<string, string>,
|
||||
context: RunContext,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
values[key] = value === null || value === undefined ? '' : stringify(value);
|
||||
}
|
||||
|
||||
/*
|
||||
* Kontext se predava **syrovy**, ne predem prevedeny na retezce. Odkaz
|
||||
* muze byt cesta do struktury (`{{data.order.items[0].name}}`) a do plocheho
|
||||
* seznamu retezcu se zanorit neda.
|
||||
*/
|
||||
const filled: Record<string, string> = {};
|
||||
for (const [key, template] of Object.entries(inputs)) {
|
||||
filled[key] = renderTemplate(template, values);
|
||||
filled[key] = renderTemplate(template, context);
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
/** Cim to teda je, kdyz to neni seznam. Do hlasky, ktera ma pomoct. */
|
||||
function describeValue(value: unknown): string {
|
||||
if (value === undefined) return 'nic (cesta neexistuje)';
|
||||
if (value === null) return 'null';
|
||||
if (Array.isArray(value)) return 'seznam';
|
||||
if (typeof value === 'object') return 'objekt';
|
||||
return `${typeof value} (${String(value).slice(0, 40)})`;
|
||||
}
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
|
||||
Reference in New Issue
Block a user