diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index f58e08d..cc92438 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -32,6 +32,7 @@ React aplikaci ze slozky `dist/public`. | Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu | | Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad | | Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni | +| Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury | | Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu | | Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu | | Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | diff --git a/documentation/13-transformace-dat.md b/documentation/13-transformace-dat.md new file mode 100644 index 0000000..059987c --- /dev/null +++ b/documentation/13-transformace-dat.md @@ -0,0 +1,203 @@ +# 13 - Transformace dat + +Naprogramovano. Souvisi se skripty ([11](11-skripty-konektoru.md)) +a se sluzbami ([12](12-sluzby-a-konektory.md)). + +## K cemu to je + +Prijde objednavka z e-shopu a do iDokladu je potreba uplne jiny objekt. Jmena +poli nesedi, castky jsou textem, polozky maji jinou strukturu. Mezi tim musi byt +krok, ktery rekne, co se kam prepise a jak se to po ceste prevede. + +``` +Nova objednavka (e-shop) -> objekt "order" + Transformace dat -> objekt "result" ve tvaru dokladu + Vystavit fakturu z objektu -> iDoklad +``` + +## Kroky si predavaji i cele struktury + +Dosud si kroky predavaly jen jednotlive hodnoty. Pribyly dva typy parametru: + +| Typ | Co to je | Do sablony | Podminka | +| -------- | --------------------------- | ---------- | ----------------- | +| `object` | cely objekt | ne | je / neni prazdny | +| `list` | seznam | ne | je / neni prazdny | + +**Do sablony se nedosazuji.** `{{order}}` v textu by znamenalo vlozit do vety +kus JSONu, coz nikdo nechce. Struktura se predava **jako celek** dalsimu kroku, +proto je u ni v builderu vyber a ne textove pole. + +Nad strukturou ma z podminek smysl jen "prisla / neprisla". Porovnavat dva +objekty by znamenalo urcit, co je "stejny", a to zavisi na pripadu. + +**Strop na velikost.** Struktura nad `SCRIPT_MAX_VALUE_BYTES` (vychozi 256 kB) +neprojde kontrolou. Radek s vystupem kroku je nejrychleji rostouci tabulka +v systemu (viz [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md)), takze +hranice patri do kontroly parametru, ne az do uklidu databaze. + +## Dva rezimy + +Obe moznosti stoji na tom samem enginu v `src/scripts/mapping.ts`. +Volba je o tom, cehoz je vic: + +| Rezim | Kdy | Skript | +| ------------------------- | -------------------------------------- | ----------------------- | +| **Pravidla** (pole na pole) | hlavni prace je v prevodech hodnot | `transform.map-fields` | +| **Sablona JSON** | hlavni prace je ve tvaru struktury | `transform.to-json` | + +### Rezim 1: pravidla + +Jedno pravidlo je "vezmi tuhle cestu, projed prevody, uloz sem". + +```json +[ + { "to": "partnerId", "from": "customer.idokladId", "required": true }, + { "to": "description", "from": "number", "transforms": [{ "op": "string" }] }, + { "to": "dateOfIssue", "from": "createdAt", "transforms": [{ "op": "date", "format": "day" }] }, + { "to": "variableSymbol", "from": "number", "omitIfEmpty": true }, + { + "to": "items", + "from": "items", + "transforms": [ + { + "op": "map", + "rules": [ + { "to": "name", "from": "title", "required": true }, + { "to": "amount", "from": "quantity", "transforms": [{ "op": "number" }] }, + { "to": "unitPrice", "from": "priceWithoutVat", "transforms": [{ "op": "number" }, { "op": "round", "decimals": 2 }] }, + { "to": "unit", "value": "ks" }, + { "to": "priceType", "value": 0 }, + { "to": "vatRateType", "value": 0 }, + { "to": "discountPercentage", "value": 0 }, + { "to": "isTaxMovement", "value": false } + ] + } + ] + } +] +``` + +Prevod `map` je to, bez ceho by priklad nesel dokoncit. Bez nej by slo prevest +hlavicku dokladu, ale ne seznam polozek, a doklad by byl na nulu. + +| Klic | K cemu | +| ------------- | --------------------------------------------------------- | +| `to` | kam se ulozi. Tecka znamena zanoreni: `partner.id` | +| `from` | cesta ve zdroji. `items.0.name` i `items[0].name` | +| `value` | pevna hodnota, kdyz `from` chybi | +| `transforms` | prevody v uvedenem poradi | +| `fallback` | pouzije se, kdyz je vysledek prazdny | +| `omitIfEmpty` | prazdny vysledek se do vystupu vubec nezapise | +| `required` | prazdny vysledek je chyba | + +`required` neni formalita. Doklad bez `partnerId` iDoklad odmitne, a je lepsi +to poznat na kroku transformace s nazvem pole, nez z odpovedi 400 od iDokladu. + +### Rezim 2: sablona JSON + +Napise se cely cilovy objekt a na mistech hodnot se odkaze pres `${cesta}`: + +```json +{ + "partnerId": "${customer.idokladId}", + "description": "Objednávka ${number}", + "dateOfIssue": "${createdAt}", + "items": [{ "name": "Zboží dle objednávky", "unitPrice": "${total}", "amount": 1 }] +} +``` + +Dve veci, ktere v tom rezimu rozhoduji: + +**Marker je `${...}`, ne `{{...}}`.** Sablony kroku se dosazuji driv, nez se +krok spusti. `{{total}}` by tedy stihl vyhodnotit strom, zadny parametr toho +jmena by nenasel a dosadil by prazdno. Rozdilny marker to nemuze splest. + +**Cely retezec zachova typ.** `"unitPrice": "${total}"` vyrobi **cislo**, ne +text. Bez toho by cizi sluzba dostala castku jako retezec a vetsina jich to +odmitne. Uvnitř delsiho textu (`"Objednávka ${number}"`) se naopak vklada +jako text, protoze jinak to nedava smysl. + +## Prevody + +| Prevod | Co dela | +| ----------------------------- | ---------------------------------------- | +| `trim`, `lower`, `upper` | uprava textu | +| `string`, `number`, `boolean` | zmena typu | +| `date` (`iso` / `day`) | datum, `day` je jen `YYYY-MM-DD` | +| `round` (`decimals`) | zaokrouhleni | +| `multiply`, `add` (`by`) | pocty, napriklad prevod na cenu s DPH | +| `default` (`value`) | vyplneni prazdne hodnoty | +| `replace` (`find`, `with`) | nahrazeni textu | +| `slice` (`start`, `end`) | cast textu nebo seznamu | +| `split`, `join` (`separator`) | text na seznam a zpatky | +| `sum` (`path`) | soucet pres seznam | +| `count` | pocet polozek | +| `map` (`rules`) | kazdou polozku seznamu podle vlastnich pravidel | + +Sada je zamerne **uzavrena**. Volny vyraz by z transformace udelal dalsi jazyk +k ladeni a hlavne by to byl kod bez sandboxu na miste, kde ho nikdo neceka. +Kdo potrebuje vic, napise skript. + +## Kontrola + +| Kdy | Co se overi | Vysledek | +| ----------------- | ---------------------------------------------------- | --------- | +| Pri psani | JSON se parsuje, chyba se ukaze hned pod polem | jen v UI | +| Pri ulozeni stromu| JSON a tvar pravidel (`to`, `from`/`value`, `op`) | nedodelek | +| Pri behu | typy, `required`, prazdne hodnoty, hloubka zanoreni | chyba behu| + +Rozbite pravidlo je **nedodelek**, ne chyba ukladani. Rozdelana prace se +nezahazuje, jen automatizace nepujde zapnout. Stejny rezim jako u ostatnich +nedodelku, viz [05-dashboard-a-builder.md](05-dashboard-a-builder.md). + +Neznamy prevod je pri behu chyba, ne preskoceni. Tise ho ignorovat by znamenalo +odeslat neprevedena data a divit se az u odberatele. + +## Kde to je + +``` +src/scripts/mapping.ts engine: cesty, prevody, pravidla, sablona +scripts/transform.map-fields.js rezim 1 +scripts/transform.to-json.js rezim 2 +scripts/idoklad.create-invoice-from-object.js druha polovina prikladu +web/src/components/dashboard/flow/MappingEditor.tsx klikaci editor pravidel +web/src/components/dashboard/flow/StepInputs.tsx pole typu mapping, json a object +``` + +Engine je na serveru a skripty ho dostanou na `ctx.util` jako `get`, `applyRules` +a `fillJson`. Nemuze byt v souboru skriptu, protoze skripty nic neimportuji - +a nema tam byt, protoze by ho jinak mel kazdy skript ve vlastni verzi. + +## Cely priklad + +``` +Spoustec: Nova objednavka (e-shop) + vystupy: orderNumber, orderTotal, customerEmail, order (objekt), items (seznam) + +Krok 1: Premapovat pole + Zdrojova data: order + Pravidla: viz vyse + vystup: result (objekt), fieldCount + +Krok 2: Vystavit fakturu z objektu (iDoklad) + Telo dokladu: result + vystupy: invoiceId, documentNumber, totalWithVat, itemCount, invoice + +Krok 3: Odeslat fakturu e-mailem (iDoklad) + ID faktury: {{invoiceId}} + E-mail: {{customerEmail}} +``` + +Krok 2 doplni z `GET /issued-invoices/default` povinna pole, ktera z objednavky +nikdy neprijdou (`documentSerialNumber`, `isEet`, `isIncomeTax`). Objekt +z transformace ho prepise jen tam, kde neco rika. + +## Co chybi + +| Chybi | Poznamka | +| --------------------------- | ------------------------------------------------------- | +| Nahled transformace | pravidla jde zkusit jen pres test skriptu | +| Vnorena pravidla klikanim | prevod `map` se upravuje v rezimu JSON | +| Napoveda cest ze skutecnych dat | nabizeji se jmena parametru, ne cesty uvnitr objektu | +| Vykonavani ze stromu | runner je hotovy, runtime automatizaci ne | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index e1fe8ac..6bf1a57 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,44 @@ Nejnovejsi nahore. +## 2026-08-12 - transformace dat a oprava konektoru + +Transformace dat popsana v [13-transformace-dat.md](13-transformace-dat.md). + +### Pridano + +- Kroky si predavaji i cele struktury. `FieldType` ma `object` a `list`. + Do sablony se nedosazuji, predavaji se jako celek dalsimu kroku - proto je + u nich v builderu vyber a ne textove pole. Z podminek nad nimi ma smysl jen + "prisla / neprisla". +- Strop na velikost struktury (`SCRIPT_MAX_VALUE_BYTES`, vychozi 256 kB). + Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu. +- `src/scripts/mapping.ts`: cesty, prevody, pravidla a sablona JSON. + Skripty ho dostanou na `ctx.util` jako `get`, `applyRules` a `fillJson`. +- Dva rezimy transformace: `transform.map-fields` (pole na pole s prevody) + a `transform.to-json` (sablona cileveho objektu s `${cesta}`). + V sablone je marker `${...}` zamerne jiny nez `{{...}}`: sablony kroku se + dosazuji driv, nez krok bezi, a stihly by ji prepsat. +- Prevod `map` pro seznamy. Bez nej by slo prevest hlavicku dokladu, ale ne + polozky objednavky, a doklad by byl na nulu. +- `idoklad.create-invoice-from-object`: druha polovina prikladu, bere hotove + telo dokladu z transformace. +- Spoustec e-shopu predava celou objednavku jako objekt a polozky jako seznam. +- Klikaci editor pravidel v builderu vcetne rezimu JSON pro vnorena pravidla. +- Kontrola JSONu a tvaru pravidel uz pri ulozeni stromu. Preklep je nedodelek, + ne chyba ukladani - rozdelana prace se nezahazuje. + +### Opraveno + +- **Konektor neukladal pristupove udaje.** Server byl v poradku, chyba byla + v prohlizeci: u pole `type="password"` prohlizec ignoruje `autocomplete="off"` + a dosazuje ulozene prihlaseni. Uzivatel pak videl jednu hodnotu, React drzel + jinou, a ulozilo se to, co drzel React, tedy nic. Resi to `autocomplete` + `new-password`, jmena poli, ktera nepripominaji heslo, a prepinac zobrazeni, + aby slo overit, co je opravdu zapsane. +- Zakladani a uprava konektoru se presunuly do dialogu. Na strance jsou jen + male karty - formulare rozlozene po strance byly u vic konektoru neprehledne. + ## 2026-08-12 - sluzby a konektory Rozdeleni na sluzbu a konektor. Popis diff --git a/scripts/idoklad.create-invoice-from-object.js b/scripts/idoklad.create-invoice-from-object.js new file mode 100644 index 0000000..12006f0 --- /dev/null +++ b/scripts/idoklad.create-invoice-from-object.js @@ -0,0 +1,88 @@ +/** + * iDoklad: vystaveni faktury z hotoveho objektu. + * + * Sluzba: https://services.csbot.cz/apps/idoklad + * Endpointy: GET /issued-invoices/default, POST /issued-invoices + * + * Druha polovina prikladu z transformace: prijde objednavka z e-shopu, + * `transform.map-fields` z ni udela telo dokladu a tenhle krok ho odesle. + * + * Rozdil proti `idoklad.create-issued-invoice`: ta akce ma pole na kliknuti + * a umi jednu polozku. Tahle bere cely objekt, takze zvladne vic polozek + * a cokoliv dalsiho, co iDoklad prijima. Cenou je, ze tvar tela musi znat ten, + * kdo stavi transformaci. + * + * Vzor z `/default` se poradi i tady: nese povinna pole, ktera nikdo vyplnovat + * nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`). Objekt z transformace + * ho jen prepise tam, kde neco rika. + */ + +export const manifest = { + id: 'idoklad.create-invoice-from-object', + name: 'Vystavit fakturu z objektu', + description: + 'Odešle do iDokladu připravené tělo dokladu. Chybějící povinná pole se ' + + 'doplní z předvyplněného vzoru iDokladu.', + timeoutMs: 25000, + + inputs: [ + { + id: 'payload', + label: 'Tělo dokladu', + type: 'object', + required: true, + hint: 'Objekt z transformace dat. Klíče podle iDokladu, například partnerId, items.', + }, + { + id: 'requireItems', + label: 'Vyžadovat aspoň jednu položku', + type: 'boolean', + required: false, + default: true, + hint: 'Doklad bez položek iDoklad přijme, ale bude na nulu.', + }, + ], + + outputs: [ + { id: 'invoiceId', label: 'ID faktury', type: 'number', required: true }, + { id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true }, + { id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false }, + { id: 'itemCount', label: 'Počet položek', type: 'number', required: true }, + { id: 'invoice', label: 'Celý doklad', type: 'object', required: true }, + ], +}; + +export async function run(inputs, ctx) { + const { unwrap, pick, text, num, need } = ctx.util; + const payload = inputs.payload; + + const items = Array.isArray(payload.items) ? payload.items : []; + if ((inputs.requireItems ?? true) && items.length === 0) { + ctx.fail('Tělo dokladu neobsahuje žádné položky. Zkontrolujte pravidla transformace.'); + } + if (num(pick(payload, 'partnerId')) === null) { + ctx.fail('Tělo dokladu neobsahuje partnerId, iDoklad by doklad nepřijal.'); + } + + // Vzor nese povinna pole, ktera z objednavky nikdy neprijdou. + const defaults = unwrap((await ctx.http.get('/issued-invoices/default')).body); + if (!defaults || typeof defaults !== 'object') { + ctx.retry('iDoklad nevrátil předvyplněný vzor faktury.'); + } + + const body = { ...defaults, ...payload }; + + const response = await ctx.http.post('/issued-invoices', body); + const invoice = unwrap(response.body); + + ctx.log(`Doklad vystaven z objektu, položek: ${items.length}.`); + + return { + invoiceId: need(num(pick(invoice, 'id')), 'ID vystavené faktury'), + documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'), + totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')), + itemCount: items.length, + // Cely doklad dal, aby na nej sel navazat dalsi krok bez druheho volani. + invoice: invoice && typeof invoice === 'object' ? invoice : {}, + }; +} diff --git a/scripts/transform.map-fields.js b/scripts/transform.map-fields.js new file mode 100644 index 0000000..66ef72e --- /dev/null +++ b/scripts/transform.map-fields.js @@ -0,0 +1,63 @@ +/** + * Transformace dat, rezim 1: pole na pole. + * + * Prijde objekt z jedne sluzby a vypadne objekt pro druhou. Kazde pravidlo rika + * "vezmi tuhle cestu ve zdroji, projed temito prevody a uloz to sem". + * + * Typicky pripad: objednavka ze Shoptetu a doklad pro iDoklad maji uplne jina + * jmena poli. Mezi nimi je tenhle krok. + * + * Rezim 2 je `transform.to-json` - tam se napise cely cilovy objekt a dosadi se + * do nej hodnoty. Volba mezi rezimy je o tom, cehoz je vic: kdyz se prevadi + * hodnoty, jsou lepsi pravidla. Kdyz se sklada tvar, je lepsi sablona. + * + * Sluzba je obecna, takze skript nic nevola a nepotrebuje konektor. + */ + +export const manifest = { + id: 'transform.map-fields', + name: 'Přemapovat pole', + description: + 'Převede objekt na jiný objekt podle pravidel. U každého cílového pole se ' + + 'určí cesta ve zdroji a případné převody.', + + inputs: [ + { + id: 'source', + label: 'Zdrojová data', + type: 'object', + required: true, + hint: 'Objekt z předchozího kroku, například objednávka.', + }, + { + id: 'rules', + label: 'Pravidla', + type: 'list', + required: true, + control: 'mapping', + hint: 'Seznam pravidel. V builderu se klikají, pod tím je vidět JSON.', + }, + ], + + outputs: [ + { id: 'result', label: 'Výsledek', type: 'object', required: true }, + { id: 'fieldCount', label: 'Počet vyplněných polí', type: 'number', required: true }, + ], +}; + +export async function run(inputs, ctx) { + const rules = inputs.rules; + + if (!Array.isArray(rules) || rules.length === 0) { + ctx.fail('Nejsou zadaná žádná pravidla, transformace by nic nevytvořila.'); + } + + // Mapovaci engine je na kontextu, ne v tomhle souboru. Pouziva ho i rezim 2 + // a mel by byt na jednom miste, ne ve dvou skriptech. + const result = ctx.util.applyRules(inputs.source, rules); + + const fieldCount = Object.keys(result).length; + ctx.log(`Přemapováno ${fieldCount} polí podle ${rules.length} pravidel.`); + + return { result, fieldCount }; +} diff --git a/scripts/transform.to-json.js b/scripts/transform.to-json.js new file mode 100644 index 0000000..cc97fac --- /dev/null +++ b/scripts/transform.to-json.js @@ -0,0 +1,69 @@ +/** + * Transformace dat, rezim 2: sablona JSON. + * + * Napise se cely cilovy objekt tak, jak ma vypadat, a na mistech hodnot se + * odkaze na zdroj pres `${cesta}`. Hodi se, kdyz je hlavni prace ve **tvaru** + * cilove struktury - zanoreni, seznamy, pevna pole. + * + * Rezim 1 je `transform.map-fields`. Ten je lepsi, kdyz je hlavni prace + * v prevodech hodnot. + * + * Proc `${cesta}` a ne `{{cesta}}`: sablony kroku se dosazuji driv, nez se krok + * spusti. `{{order.total}}` by tedy stihl vyhodnotit strom, zadny parametr toho + * jmena by nenasel a dosadil by prazdno. Jiny marker to nemuze splest. + * + * Kdyz je hodnota **cely** retezec, zachova se puvodni typ: + * `"total": "${order.total}"` vyrobi cislo, ne text. Bez toho by cizi sluzba + * dostala castku jako retezec a vetsina jich to odmitne. + */ + +export const manifest = { + id: 'transform.to-json', + name: 'Poskládat objekt ze šablony', + description: + 'Vezme šablonu JSON a dosadí do ní hodnoty ze zdroje přes ${cesta}. ' + + 'Hodí se, když je potřeba postavit strukturu se zanořením a seznamy.', + + inputs: [ + { + id: 'source', + label: 'Zdrojová data', + type: 'object', + required: true, + hint: 'Objekt z předchozího kroku.', + }, + { + id: 'template', + label: 'Šablona JSON', + type: 'string', + required: true, + multiline: true, + control: 'json', + hint: 'Cílový objekt. Hodnoty ze zdroje se vkládají jako ${cesta.k.poli}.', + }, + ], + + outputs: [ + { id: 'result', label: 'Výsledek', type: 'object', required: true }, + ], +}; + +export async function run(inputs, ctx) { + let template; + try { + template = JSON.parse(String(inputs.template)); + } catch (err) { + // Rozbita sablona je chyba nastaveni, opakovat ji nema smysl. + ctx.fail(`Šablona není platný JSON: ${err instanceof Error ? err.message : String(err)}`); + } + + if (template === null || typeof template !== 'object' || Array.isArray(template)) { + ctx.fail('Šablona musí být objekt, tedy začínat složenou závorkou.'); + } + + const result = ctx.util.fillJson(template, inputs.source); + + ctx.log(`Poskládán objekt s ${Object.keys(result).length} polemi.`); + + return { result }; +} diff --git a/src/config.ts b/src/config.ts index 5b2bf1f..5d83c46 100644 --- a/src/config.ts +++ b/src/config.ts @@ -94,6 +94,12 @@ export const config = { scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000), /** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */ scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000), + /** + * Strop na jednu strukturu (parametr typu object nebo list). + * Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu, takze + * hranice patri do kontroly parametru, ne az do uklidu databaze. + */ + scriptMaxValueBytes: positiveNumber(process.env.SCRIPT_MAX_VALUE_BYTES, 256_000), /** * Povoli skriptum volat na localhost a do privatnich rozsahu IP. * Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute. diff --git a/src/data/conditions.ts b/src/data/conditions.ts index 8b2eb71..deb40dc 100644 --- a/src/data/conditions.ts +++ b/src/data/conditions.ts @@ -5,7 +5,12 @@ * Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md). */ -export type FieldType = 'string' | 'number' | 'boolean' | 'date'; +/** + * `object` a `list` jsou celé struktury, ne jednotlive hodnoty. Vznikly kvuli + * transformacim: krok muze predat dal cely objekt objednavky, ne jen jeho pole. + * Do sablony se nedosazuji, predavaji se jen jako celek dalsimu kroku. + */ +export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list'; export type ConditionOperator = | 'eq' @@ -21,7 +26,17 @@ export type ConditionOperator = | 'isTrue' | 'isFalse'; -export const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date']; +export const fieldTypes: FieldType[] = [ + 'string', + 'number', + 'boolean', + 'date', + 'object', + 'list', +]; + +/** Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece. */ +export const declarableFieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date']; /** Ktere operatory maji smysl pro ktery typ. */ export const operatorsByType: Record = { @@ -29,6 +44,10 @@ export const operatorsByType: Record = { number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], boolean: ['isTrue', 'isFalse'], date: ['eq', 'gt', 'lt'], + // Nad strukturou ma smysl jen to, jestli vubec neco prisla. Porovnavat dva + // objekty by znamenalo urcit, co je "stejny", a to zalezi na pripadu. + object: ['isEmpty', 'isNotEmpty'], + list: ['isEmpty', 'isNotEmpty'], }; /** Operatory, ktere nepotrebuji hodnotu k porovnani. */ diff --git a/src/data/services.ts b/src/data/services.ts index be99b31..47f1398 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -104,8 +104,20 @@ export interface ProvidedField { export interface OperationField { id: string; label: string; - /** text = jeden radek, longtext = vice radku, choice = vyber z `options` */ - kind: 'text' | 'longtext' | 'choice'; + /** + * Jak se pole vykresli v builderu. + * + * `text` jeden radek + * `longtext` vice radku + * `choice` vyber z `options` + * `json` JSON, kontroluje se uz pri ulozeni stromu + * `mapping` pravidla transformace, klikatelny editor nad JSONem + * `object` odkaz na parametr typu objekt nebo seznam z predchoziho kroku + * + * Hodnota je vzdy retezec, i u `json` a `mapping`. Diky tomu se nemenil + * `FlowStep.inputs` a strukturu si rozparsuje az ten, kdo ji pouziva. + */ + kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object'; required: boolean; options?: Array<{ value: string; label: string }>; hint?: string; @@ -738,7 +750,18 @@ export const services: Service[] = [ { id: 'order-created', name: 'Nová objednávka', - description: 'Spustí se při vytvoření objednávky v e-shopu.', + description: + 'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' + + 'dál jako objekt, takže se dá přemapovat na doklad.', + providedFields: [ + { id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true }, + { id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true }, + { id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false }, + // Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku + // jako celek - typicky do transformace dat. + { id: 'eshop.order', name: 'order', type: 'object', required: true }, + { id: 'eshop.items', name: 'items', type: 'list', required: true }, + ], }, { id: 'order-status-changed', diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 0f41f21..880ebff 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -53,6 +53,7 @@ import { type TicketStatus, } from '../data/ticketStore.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateRules } from '../scripts/mapping.js'; import { connectorsRouter } from './connectors.js'; import { scriptsRouter } from './scripts.js'; import { streamRouter } from './stream.js'; @@ -434,7 +435,10 @@ const fieldSchema = z.object({ .min(1, 'Parametr musí mít název.') // Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu. .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'), - type: z.enum(['string', 'number', 'boolean', 'date']), + // `object` a `list` sem chodi z katalogu (providedFields), viz normalizeTriggerFields. + // Builder nabizi uzivateli jen skalarni typy, protoze strukturu do sablony + // dosadit nejde - predava se jen jako celek dalsimu kroku. + type: z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']), required: z.boolean(), }); @@ -544,6 +548,29 @@ function validateFlowReferences( } } + // Pole typu json a mapping nesou strukturu zapsanou jako text. Preklep + // v zavorce je nedodelek, ne chyba: rozdelana prace se nezahazuje, jen + // automatizace nepujde zapnout. Bez teto kontroly by se to poznalo + // az z padleho behu. + for (const input of action.inputs ?? []) { + if (input.kind !== 'json' && input.kind !== 'mapping') continue; + const raw = (step.inputs ?? {})[input.id]; + if (raw === undefined || raw.trim() === '') continue; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const detail = err instanceof Error ? err.message : 'neplatný JSON'; + issues.push(`Pole „${input.label}" není platný JSON: ${detail}`); + continue; + } + + if (input.kind === 'mapping') { + issues.push(...validateRules(parsed, `pole „${input.label}"`)); + } + } + // Vybrany konektor musi patrit te same firme a te same sluzbe. // Cizi konektor je rozbity strom, ne nedodelek. if (step.connectorId) { diff --git a/src/scripts/manifest.ts b/src/scripts/manifest.ts index ee7f8b1..b886590 100644 --- a/src/scripts/manifest.ts +++ b/src/scripts/manifest.ts @@ -59,12 +59,26 @@ export function parseManifest(raw: unknown, expectedId: string): ParseResult { /** * Nastavitelne pole akce pro builder. * `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat. + * + * Poradi rozhodovani: vyslovne urceny `control` vyhrava, pak vyber z hodnot, + * pak typ struktury, pak vic radku. Vyslovne urceni je prvni zamerne - jen + * autor skriptu vi, ze `rules` je mapovani a ne obycejny seznam. */ function toOperationField(field: ScriptField): OperationField { + const kind: OperationField['kind'] = field.control + ? field.control + : field.options + ? 'choice' + : field.type === 'object' || field.type === 'list' + ? 'object' + : field.multiline + ? 'longtext' + : 'text'; + return { id: field.id, label: field.label, - kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text', + kind, required: field.required, ...(field.options ? { options: field.options } : {}), ...(field.hint ? { hint: field.hint } : {}), diff --git a/src/scripts/mapping.ts b/src/scripts/mapping.ts new file mode 100644 index 0000000..c08404c --- /dev/null +++ b/src/scripts/mapping.ts @@ -0,0 +1,353 @@ +/** + * Transformace dat: prevod jedne struktury na druhou. + * + * Je to odpoved na typickou vec v automatizaci: prijde objednavka z e-shopu + * a do iDokladu potrebuju uplne jiny objekt. Mezi tim musi byt krok, ktery + * rekne, co se kam prepise a jak se to po cest prevede. + * + * Dva rezimy, oba stoji na tomhle souboru: + * - **pravidla** (`applyRules`): pole na pole, klikatelne, s prevody, + * - **sablona JSON** (`fillJson`): cilovy objekt se napise a do nej se dosadi + * `${cesta}` ze zdroje. + * + * Engine je tady na serveru a skripty ho dostanou na `ctx.util`. Nemuze byt + * v souboru skriptu, protoze skripty nic neimportuji - a nema tam byt, protoze + * by ho pak kazdy skript mel ve vlastni verzi. + */ + +import { ScriptError, type ScriptJson } from './types.js'; + +/** Kolik urovni zanoreni se jeste prochazi. Chrani proti cyklickym datum. */ +const MAX_DEPTH = 20; + +// ---------------------------------------------------------------------- cesty + +/** + * Hodnota na ceste. Prijima `customer.email`, `items.0.name` i `items[0].name`, + * protoze lidi pisou oboji a hadat, ktere je spravne, nema smysl. + * + * `undefined` znamena "na te ceste nic neni". Zamerne se nerozlisuje od `null` + * na vyssi urovni - pro dosazeni do vystupu je to totez. + */ +export function getPath(source: unknown, path: string): unknown { + if (path.trim() === '') return source; + + const parts = path + .replace(/\[(\d+)\]/g, '.$1') + .split('.') + .filter((part) => part !== ''); + + let current: unknown = source; + for (const part of parts) { + if (current === null || current === undefined) return undefined; + + if (Array.isArray(current)) { + const index = Number(part); + if (!Number.isInteger(index)) return undefined; + current = current[index]; + continue; + } + + if (typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** Zapise hodnotu na cestu a cestou vytvori chybejici objekty. */ +function setPath(target: Record, path: string, value: ScriptJson): void { + const parts = path.split('.').filter((part) => part !== ''); + if (parts.length === 0) return; + + let current: Record = target; + for (const part of parts.slice(0, -1)) { + const next = current[part]; + if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) { + current[part] = {}; + } + current = current[part] as Record; + } + current[parts[parts.length - 1]] = value; +} + +// ------------------------------------------------------------------- prevody + +/** + * Jeden prevod hodnoty. Zamerne mala uzavrena sada - pri volnem vyrazu by + * transformace prestala byt nastaveni a stala se dalsim jazykem k ladeni. + */ +export type TransformOp = + | { op: 'trim' } + | { op: 'lower' } + | { op: 'upper' } + | { op: 'string' } + | { op: 'number' } + | { op: 'boolean' } + | { op: 'date'; format?: 'iso' | 'day' } + | { op: 'round'; decimals?: number } + | { op: 'multiply'; by: number } + | { op: 'add'; by: number } + | { op: 'default'; value: ScriptJson } + | { op: 'replace'; find: string; with: string } + | { op: 'slice'; start?: number; end?: number } + | { op: 'split'; separator: string } + | { op: 'join'; separator?: string } + | { op: 'sum'; path?: string } + | { op: 'count' } + /** Nad seznamem objektu: kazdy prvek prevede podle vlastnich pravidel. */ + | { op: 'map'; rules: MappingRule[] }; + +export interface MappingRule { + /** Kam se hodnota ulozi. Tecka znamena zanoreni: `partner.id`. */ + to: string; + /** Cesta ve zdroji. Bez markeru, je to cesta, ne sablona. */ + from?: string; + /** Pevna hodnota. Pouzije se, kdyz `from` chybi. */ + value?: ScriptJson; + /** Prevody v uvedenem poradi. */ + transforms?: TransformOp[]; + /** Kdyz je vysledek prazdny, pouzije se tohle. */ + fallback?: ScriptJson; + /** Prazdny vysledek se do vystupu vubec nezapise. */ + omitIfEmpty?: boolean; + /** Prazdny vysledek je chyba. Pro pole, bez kterych cizi sluzba doklad neprijme. */ + required?: boolean; +} + +function isEmpty(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (typeof value === 'string') return value.trim() === ''; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +function toNumber(value: unknown, label: string): number { + if (typeof value === 'number') return value; + // Ceska desetinna carka je bezna, nema smysl na ni padat. + const parsed = Number(String(value ?? '').trim().replace(',', '.')); + if (!Number.isFinite(parsed)) { + throw new ScriptError('terminal', `${label}: "${String(value)}" není číslo.`); + } + return parsed; +} + +function applyOne(value: unknown, operation: TransformOp, label: string, depth: number): ScriptJson { + switch (operation.op) { + case 'trim': + return String(value ?? '').trim(); + case 'lower': + return String(value ?? '').toLowerCase(); + case 'upper': + return String(value ?? '').toUpperCase(); + case 'string': + return value === undefined || value === null ? '' : String(value); + case 'number': + return toNumber(value, label); + case 'boolean': { + if (typeof value === 'boolean') return value; + const text = String(value ?? '').trim().toLowerCase(); + return text === 'true' || text === '1' || text === 'yes' || text === 'ano'; + } + case 'date': { + const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value ?? '')); + if (Number.isNaN(parsed)) { + throw new ScriptError('terminal', `${label}: "${String(value)}" není platné datum.`); + } + const iso = new Date(parsed).toISOString(); + return operation.format === 'day' ? iso.slice(0, 10) : iso; + } + case 'round': { + const factor = 10 ** (operation.decimals ?? 2); + return Math.round(toNumber(value, label) * factor) / factor; + } + case 'multiply': + return toNumber(value, label) * operation.by; + case 'add': + return toNumber(value, label) + operation.by; + case 'default': + return isEmpty(value) ? operation.value : (value as ScriptJson); + case 'replace': + return String(value ?? '').split(operation.find).join(operation.with); + case 'slice': { + if (Array.isArray(value)) return value.slice(operation.start ?? 0, operation.end) as ScriptJson; + return String(value ?? '').slice(operation.start ?? 0, operation.end); + } + case 'split': + return String(value ?? '') + .split(operation.separator) + .map((part) => part.trim()) + .filter((part) => part !== ''); + case 'join': + return Array.isArray(value) + ? value.map((item) => String(item ?? '')).join(operation.separator ?? ', ') + : String(value ?? ''); + case 'sum': { + if (!Array.isArray(value)) return toNumber(value, label); + return value.reduce((total, item) => { + const part = operation.path ? getPath(item, operation.path) : item; + return total + toNumber(part ?? 0, label); + }, 0); + } + case 'count': + return Array.isArray(value) ? value.length : isEmpty(value) ? 0 : 1; + case 'map': { + if (!Array.isArray(value)) { + throw new ScriptError('terminal', `${label}: prevod map potřebuje seznam.`); + } + return value.map((item) => applyRules(item, operation.rules, depth + 1)); + } + default: { + // Neznamy prevod je chyba nastaveni, ne dat. Tise ho preskocit by znamenalo + // odeslat neprevedena data a divit se az u odberatele. + const unknown = operation as { op?: string }; + throw new ScriptError('terminal', `Neznámý převod "${String(unknown.op)}" u ${label}.`); + } + } +} + +// ------------------------------------------------------------------ pravidla + +/** Prevede zdroj na novy objekt podle pravidel. */ +export function applyRules( + source: unknown, + rules: MappingRule[], + depth = 0, +): Record { + if (depth > MAX_DEPTH) { + throw new ScriptError('terminal', 'Mapování je zanořené příliš hluboko.'); + } + + const result: Record = {}; + + for (const rule of rules) { + if (!rule.to || rule.to.trim() === '') { + throw new ScriptError('terminal', 'Pravidlo mapování musí mít vyplněné cílové pole.'); + } + const label = `pole ${rule.to}`; + + let value: unknown = rule.from !== undefined ? getPath(source, rule.from) : rule.value; + + for (const operation of rule.transforms ?? []) { + value = applyOne(value, operation, label, depth); + } + + if (isEmpty(value) && rule.fallback !== undefined) value = rule.fallback; + + if (isEmpty(value)) { + if (rule.required) { + const where = rule.from ? ` (zdroj: ${rule.from})` : ''; + throw new ScriptError('terminal', `${label} je povinné, ale nic do něj nedošlo${where}.`); + } + if (rule.omitIfEmpty) continue; + } + + setPath(result, rule.to, (value ?? null) as ScriptJson); + } + + return result; +} + +// -------------------------------------------------------------- sablona JSON + +/** + * Dosadi `${cesta}` ze zdroje do sablony. + * + * Marker je zamerne `${...}` a ne `{{...}}`. Sablony kroku se dosazuji driv, + * nez se krok spusti, takze `{{order.total}}` v sablone JSON by stihl vyhodnotit + * strom, nenasel by parametr toho jmena a dosadil prazdno. Rozdilny marker to + * nemuze splest. + * + * Kdyz je hodnota **cely** retezec, zachova se puvodni typ. `"${order.total}"` + * tedy vyrobi cislo, ne text - jinak by cizi sluzba dostala castku jako string. + */ +const wholeToken = /^\$\{([^}]+)\}$/; +const anyToken = /\$\{([^}]+)\}/g; + +export function fillJson(template: ScriptJson, source: unknown, depth = 0): ScriptJson { + if (depth > MAX_DEPTH) { + throw new ScriptError('terminal', 'Šablona JSON je zanořená příliš hluboko.'); + } + + if (typeof template === 'string') { + const whole = wholeToken.exec(template); + if (whole) { + const value = getPath(source, whole[1]); + return (value ?? null) as ScriptJson; + } + return template.replace(anyToken, (_match, path: string) => { + const value = getPath(source, path); + if (value === undefined || value === null) return ''; + return typeof value === 'object' ? JSON.stringify(value) : String(value); + }); + } + + if (Array.isArray(template)) { + return template.map((item) => fillJson(item, source, depth + 1)); + } + + if (template !== null && typeof template === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(template)) { + result[key] = fillJson(value, source, depth + 1); + } + return result; + } + + return template; +} + +// -------------------------------------------------------------------- kontrola + +/** + * Overi tvar pravidel prisly z nastaveni kroku. + * Vraci popisy problemu, prazdne pole znamena v poradku. + * + * Kontroluje se pri ukladani stromu, aby se preklep v pravidlech nedozvedel + * uzivatel az z padleho behu. + */ +export function validateRules(value: unknown, path = 'pravidla'): string[] { + if (!Array.isArray(value)) return [`${path}: očekává se seznam pravidel.`]; + + const problems: string[] = []; + value.forEach((rule, index) => { + const where = `${path}[${index}]`; + if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) { + problems.push(`${where}: pravidlo musí být objekt.`); + return; + } + const record = rule as Record; + if (typeof record.to !== 'string' || record.to.trim() === '') { + problems.push(`${where}: chybí cílové pole "to".`); + } + if (record.from === undefined && record.value === undefined) { + problems.push(`${where}: vyplňte "from" (cesta ve zdroji) nebo "value" (pevná hodnota).`); + } + if (record.transforms !== undefined) { + if (!Array.isArray(record.transforms)) { + problems.push(`${where}: "transforms" musí být seznam.`); + } else { + record.transforms.forEach((operation, opIndex) => { + if (operation === null || typeof operation !== 'object') { + problems.push(`${where}.transforms[${opIndex}]: převod musí být objekt.`); + return; + } + const op = (operation as { op?: unknown }).op; + if (typeof op !== 'string') { + problems.push(`${where}.transforms[${opIndex}]: chybí "op".`); + return; + } + if (op === 'map') { + problems.push( + ...validateRules( + (operation as { rules?: unknown }).rules, + `${where}.transforms[${opIndex}].rules`, + ), + ); + } + }); + } + } + }); + + return problems; +} diff --git a/src/scripts/types.ts b/src/scripts/types.ts index f9ce48c..c04db35 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -13,16 +13,31 @@ */ import { z } from 'zod'; +import type { MappingRule } from './mapping.js'; // ------------------------------------------------------------------- hodnoty -/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */ -export type ScriptValue = string | number | boolean | null; +/** + * Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem. + * + * Objekty a seznamy tu jsou kvuli transformacim: krok muze predat dal celou + * objednavku, ne jen jeji jednotliva pole. Do sablon se ale nedosazuji, + * predavaji se jen jako celek dalsimu kroku. + */ +export type ScriptJson = + | string + | number + | boolean + | null + | ScriptJson[] + | { [key: string]: ScriptJson }; + +export type ScriptValue = ScriptJson; export type ScriptValues = Record; // -------------------------------------------------------------------- schema -const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']); +const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']); /** * Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe @@ -53,8 +68,24 @@ export const scriptFieldSchema = z pattern: z.string().optional(), /** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */ multiline: z.boolean().optional(), + /** + * Cim se pole vyplnuje v builderu, kdyz to z typu nejde poznat. + * + * `mapping` pravidla transformace, klikatelny editor + * `json` JSON, kontroluje se uz pri ulozeni stromu + * `object` vyber parametru typu objekt nebo seznam z predchoziho kroku + * + * Explicitne, ne odvozene z nazvu pole. Hadat podle `id === 'rules'` by + * fungovalo do prvniho skriptu, ktery to pole pojmenuje jinak. + */ + control: z.enum(['mapping', 'json', 'object']).optional(), /** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */ default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(), + /** + * Jen u `object` a `list`: strop na velikost v bajtech. + * Bez nej by jeden krok mohl protahnout logem megabajty. + */ + maxBytes: z.number().int().min(256).max(4_000_000).optional(), }) .strict(); @@ -210,6 +241,21 @@ export interface ScriptUtil { * poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome. */ need(value: T | null | undefined, label: string): T; + /** + * Hodnota na ceste: `customer.email`, `items.0.name` i `items[0].name`. + * Chybejici cesta vraci `undefined`, ne vyjimku. + */ + get(source: unknown, path: string): unknown; + /** + * Prevede zdroj na novy objekt podle pravidel. + * Tohle je rezim "pole na pole" u transformace dat. + */ + applyRules(source: unknown, rules: MappingRule[]): Record; + /** + * Dosadi `${cesta}` ze zdroje do sablony JSON. + * Marker je zamerne jiny nez `{{...}}` u sablon kroku, duvod je v mapping.ts. + */ + fillJson(template: ScriptJson, source: unknown): ScriptJson; } export interface ScriptContext { diff --git a/src/scripts/util.ts b/src/scripts/util.ts index c1b4d76..38861b2 100644 --- a/src/scripts/util.ts +++ b/src/scripts/util.ts @@ -7,6 +7,7 @@ * z nich by to resil spatne. */ +import { applyRules, fillJson, getPath } from './mapping.js'; import { ScriptError, type ScriptUtil } from './types.js'; /** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */ @@ -83,7 +84,20 @@ function need(value: T | null | undefined, label: string): T { return value; } -export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need }; +export const scriptUtil: ScriptUtil = { + unwrap, + pick, + first, + text, + num, + bool, + date, + round, + need, + get: getPath, + applyRules: (source, rules) => applyRules(source, rules), + fillJson: (template, source) => fillJson(template, source), +}; // ------------------------------------------------------------------- redakce diff --git a/src/scripts/values.ts b/src/scripts/values.ts index 0d5a569..433c890 100644 --- a/src/scripts/values.ts +++ b/src/scripts/values.ts @@ -11,7 +11,8 @@ * - parametr, ktery v manifestu neni, se zahodi a zaloguje. */ -import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js'; +import { config } from '../config.js'; +import type { FieldIssue, ScriptField, ScriptJson, ScriptValue, ScriptValues } from './types.js'; export type ValidationResult = | { ok: true; values: ScriptValues } @@ -25,6 +26,43 @@ function isMissing(value: unknown): boolean { return value === undefined || value === null || (typeof value === 'string' && value.trim() === ''); } +/** + * Rozparsuje JSON, kdyz hodnota prisla jako text. + * + * Deje se to bezne: z nastaveni kroku i z testovaciho formulare prijde struktura + * jako retezec. Odmitnout ji by znamenalo, ze uzivatel musi resit, kde presne + * se to prevadi. + */ +function parseIfText(raw: unknown): unknown { + if (typeof raw !== 'string') return raw; + const text = raw.trim(); + if (text === '') return raw; + if (!text.startsWith('{') && !text.startsWith('[')) return raw; + try { + return JSON.parse(text); + } catch { + return raw; + } +} + +/** + * Strop na velikost struktury. + * + * Bez nej by jeden krok mohl protahnout logem a databazi megabajty. Radek + * `run_step` je nejrychleji rostouci tabulka v systemu, viz dokument 10. + */ +function sizeProblem(field: ScriptField, value: ScriptJson): string | null { + const limit = field.maxBytes ?? config.scriptMaxValueBytes; + let size: number; + try { + size = Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8'); + } catch { + return 'strukturu nelze zapsat jako JSON (asi obsahuje cyklus).'; + } + if (size > limit) return `je ${size} B, což je nad povolený strop ${limit} B.`; + return null; +} + /** * Prevede jednu hodnotu na deklarovany typ. * Vraci bud hodnotu, nebo text chyby - nikdy nehada. @@ -70,6 +108,22 @@ function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { er return { value: new Date(parsed).toISOString() }; } + case 'object': { + const parsed = parseIfText(raw); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { error: 'Očekává se objekt.' }; + } + const problem = sizeProblem(field, parsed as ScriptJson); + return problem ? { error: problem } : { value: parsed as ScriptJson }; + } + + case 'list': { + const parsed = parseIfText(raw); + if (!Array.isArray(parsed)) return { error: 'Očekává se seznam.' }; + const problem = sizeProblem(field, parsed as ScriptJson); + return problem ? { error: problem } : { value: parsed as ScriptJson }; + } + default: { // Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu. return { error: 'Neznámý typ parametru.' }; @@ -115,7 +169,13 @@ export function validateValues( continue; } - if (field.options && !field.options.some((option) => option.value === String(result.value))) { + // Vyber ma smysl jen u skalarni hodnoty. U struktury by se porovnaval JSON. + if ( + field.options && + field.type !== 'object' && + field.type !== 'list' && + !field.options.some((option) => option.value === String(result.value)) + ) { const allowed = field.options.map((option) => option.value).join(', '); issues.push({ field: field.id, diff --git a/web/src/components/dashboard/flow/MappingEditor.tsx b/web/src/components/dashboard/flow/MappingEditor.tsx new file mode 100644 index 0000000..e5c943f --- /dev/null +++ b/web/src/components/dashboard/flow/MappingEditor.tsx @@ -0,0 +1,447 @@ +import { ArrowRight, Braces, Plus, Trash2 } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { Badge } from '@/components/ui/Badge'; +import { cn } from '@/lib/cn'; + +/** + * Editor pravidel transformace dat. + * + * Jedno pravidlo rika "vezmi tuhle cestu ve zdroji, projed temito prevody + * a uloz to sem". Vysledkem je JSON, ktery se uklada do nastaveni kroku - + * hodnota kroku zustala retezec, takze se model stromu nemenil. + * + * Editor umi i rezim JSON. Neni to jen pro znale: vnorena pravidla u prevodu + * `map` se v radkovem editoru kreslit nedaji rozumne, a zakazat je kvuli tomu + * by znamenalo, ze seznam polozek objednavky nejde prevest vubec. + * + * Popis prevodu je v documentation/13-transformace-dat.md. + */ + +interface TransformOp { + op: string; + [key: string]: unknown; +} + +interface MappingRule { + to?: string; + from?: string; + value?: unknown; + transforms?: TransformOp[]; + fallback?: unknown; + omitIfEmpty?: boolean; + required?: boolean; +} + +/** Jeden parametr prevodu. Podle nej se vykresli policko. */ +interface OpParam { + key: string; + label: string; + kind: 'text' | 'number' | 'choice'; + options?: string[]; + placeholder?: string; +} + +/** + * Popis prevodu na jednom miste. UI se z toho odvozuje, aby se pri pridani + * prevodu nemusel upravovat vykreslovaci kod. + * Musi odpovidat `TransformOp` v `src/scripts/mapping.ts`. + */ +const opCatalog: Array<{ op: string; label: string; params?: OpParam[] }> = [ + { op: 'trim', label: 'Odstranit mezery' }, + { op: 'lower', label: 'Na malá písmena' }, + { op: 'upper', label: 'Na velká písmena' }, + { op: 'string', label: 'Převést na text' }, + { op: 'number', label: 'Převést na číslo' }, + { op: 'boolean', label: 'Převést na ano/ne' }, + { + op: 'date', + label: 'Převést na datum', + params: [{ key: 'format', label: 'Tvar', kind: 'choice', options: ['iso', 'day'] }], + }, + { + op: 'round', + label: 'Zaokrouhlit', + params: [{ key: 'decimals', label: 'Desetinná místa', kind: 'number', placeholder: '2' }], + }, + { + op: 'multiply', + label: 'Vynásobit', + params: [{ key: 'by', label: 'Čím', kind: 'number', placeholder: '1.21' }], + }, + { + op: 'add', + label: 'Přičíst', + params: [{ key: 'by', label: 'Kolik', kind: 'number', placeholder: '0' }], + }, + { + op: 'default', + label: 'Když je prázdné, použít', + params: [{ key: 'value', label: 'Hodnota', kind: 'text' }], + }, + { + op: 'replace', + label: 'Nahradit text', + params: [ + { key: 'find', label: 'Najít', kind: 'text' }, + { key: 'with', label: 'Čím', kind: 'text' }, + ], + }, + { + op: 'slice', + label: 'Vzít část', + params: [ + { key: 'start', label: 'Od', kind: 'number', placeholder: '0' }, + { key: 'end', label: 'Do', kind: 'number' }, + ], + }, + { + op: 'split', + label: 'Rozdělit na seznam', + params: [{ key: 'separator', label: 'Oddělovač', kind: 'text', placeholder: ',' }], + }, + { + op: 'join', + label: 'Spojit seznam do textu', + params: [{ key: 'separator', label: 'Oddělovač', kind: 'text', placeholder: ', ' }], + }, + { + op: 'sum', + label: 'Sečíst seznam', + params: [{ key: 'path', label: 'Cesta v položce', kind: 'text', placeholder: 'total' }], + }, + { op: 'count', label: 'Počet položek' }, +]; + +function opLabel(op: string): string { + return opCatalog.find((item) => item.op === op)?.label ?? op; +} + +/** Bezpecne rozparsuje ulozenou hodnotu. Rozbity JSON znamena prazdna pravidla. */ +function parseRules(raw: string): { rules: MappingRule[]; broken: boolean } { + const text = raw.trim(); + if (text === '') return { rules: [], broken: false }; + try { + const parsed = JSON.parse(text); + if (!Array.isArray(parsed)) return { rules: [], broken: true }; + return { rules: parsed as MappingRule[], broken: false }; + } catch { + return { rules: [], broken: true }; + } +} + +export function MappingEditor({ + value, + onChange, + /** Parametry viditelne v tomhle kroku. Nabizeji se jako zdroj. */ + sourceHints, +}: { + value: string; + onChange: (value: string) => void; + sourceHints: string[]; +}) { + const { rules, broken } = useMemo(() => parseRules(value), [value]); + const [rawMode, setRawMode] = useState(broken); + + function write(next: MappingRule[]) { + onChange(JSON.stringify(next, null, 2)); + } + + function updateRule(index: number, patch: Partial) { + write(rules.map((rule, at) => (at === index ? { ...rule, ...patch } : rule))); + } + + function removeRule(index: number) { + write(rules.filter((_rule, at) => at !== index)); + } + + function addRule() { + write([...rules, { to: '', from: '' }]); + } + + if (rawMode) { + return ( +
+