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:
JiriUhlir
2026-08-20 06:53:10 +02:00
co-authored by Claude Opus 5
parent fc0c2ff6c7
commit 9b18531d3e
25 changed files with 1299 additions and 124 deletions
+90 -14
View File
@@ -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,
};
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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.`);
}
}
+25
View File
@@ -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;
+102
View File
@@ -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
View File
@@ -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);
});
}
+1 -1
View File
@@ -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
View File
@@ -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}"`));
}
}
+9 -3
View File
@@ -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
View File
@@ -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);