Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript
Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.
Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.
Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.
Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.
Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Sablona noveho skriptu. Soubory od podtrzitka se nenacitaji, takze tenhle
|
||||
* nikde nevznikne jako operace - je tu jen ke zkopirovani.
|
||||
*
|
||||
* Postup:
|
||||
* 1. zkopirovat na `<sluzba>.<operace>.js`, napriklad `idoklad.get-contact.js`,
|
||||
* 2. srovnat `manifest.id` s nazvem souboru, musi byt stejne,
|
||||
* 3. vyplnit vstupy a vystupy,
|
||||
* 4. napsat `run`.
|
||||
*
|
||||
* Nic se nerestartuje. Server si zmenu vsimne podle casu souboru a nacte ji
|
||||
* pri dalsim dotazu. Totez plati pri uprave v portalu.
|
||||
*
|
||||
* Co ma skript k dispozici je jen `ctx`. Zadny import, zadny pristup k sitim
|
||||
* mimo `ctx.http` a zadne pristupove udaje - ty dosazuje runtime podle napojeni.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
/** Musi odpovidat nazvu souboru bez .js. Sluzba musi existovat v katalogu. */
|
||||
id: 'sluzba.operace',
|
||||
name: 'Nazev, ktery uvidi uzivatel v builderu',
|
||||
description: 'Jedna veta o tom, co se stane. Cte to clovek, ktery staví strom.',
|
||||
|
||||
/**
|
||||
* Co skript potrebuje. Presne tohle se v builderu vykresli jako pole kroku
|
||||
* a server to pred spustenim overi.
|
||||
*
|
||||
* type: 'string' | 'number' | 'boolean' | 'date'
|
||||
* required: true = bez hodnoty se skript vubec nespusti
|
||||
* options: vyber z hodnot, jina neprojde
|
||||
* multiline: pole na vic radku
|
||||
* default: dosadi se, kdyz hodnota chybi a pole neni povinne
|
||||
* pattern: dalsi kontrola regularnim vyrazem (jen u string)
|
||||
*/
|
||||
inputs: [
|
||||
{
|
||||
id: 'prikladVstupu',
|
||||
label: 'Příklad vstupu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Napoveda pod polem.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Co skript vraci. Tohle je to, s cim pak umi pracovat strom - podminka se
|
||||
* na to muze zeptat a sablona to muze dosadit jako `{{prikladVystupu}}`.
|
||||
*
|
||||
* Povinny vystup, ktery skript nevrati, je chyba skriptu. Zamerne: strom by
|
||||
* jinak veril parametru, ktery nikdy nedosel.
|
||||
*/
|
||||
outputs: [{ id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true }],
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | number | boolean | null>} inputs
|
||||
* Uz overene a prevedene na typy z manifestu.
|
||||
* @param {{
|
||||
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function,
|
||||
* postForm: Function },
|
||||
* util: { unwrap: Function, pick: Function, first: Function, list: Function,
|
||||
* text: Function, num: Function, bool: Function, date: Function, day: Function,
|
||||
* round: Function, need: Function, get: Function, addresses: Function,
|
||||
* quote: Function, base64: Function, applyRules: Function, fillJson: Function },
|
||||
* log: Function, config: Record<string, string>, idempotencyKey: string,
|
||||
* fail: Function, retry: Function,
|
||||
* }} ctx
|
||||
*/
|
||||
export async function run(inputs, ctx) {
|
||||
// Soubor se posila pres `ctx.http.postForm`, obsah jako Base64:
|
||||
// await ctx.http.postForm('/files', {
|
||||
// purpose: 'user_data',
|
||||
// file: { filename: 'faktura.pdf', base64: inputs.obsah, contentType: 'application/pdf' },
|
||||
// });
|
||||
|
||||
// Cesta je relativni k adrese napojeni, cela adresa se nikam nepise.
|
||||
const { body } = await ctx.http.get('/nejaky-endpoint', {
|
||||
query: { hledat: inputs.prikladVstupu },
|
||||
});
|
||||
|
||||
const data = ctx.util.unwrap(body);
|
||||
|
||||
// ctx.fail = koncova chyba, neopakuje se.
|
||||
// ctx.retry = docasna chyba, runtime to zkusi znovu.
|
||||
if (!data) ctx.fail('Služba nic nevrátila.');
|
||||
|
||||
return {
|
||||
prikladVystupu: ctx.util.need(ctx.util.text(ctx.util.pick(data, 'nazev')), 'název'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* CSOB (PSD2): nacteni pohybu na uctu.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/csob
|
||||
* Endpointy: GET /accounts, GET /accounts/{id}/transactions
|
||||
*
|
||||
* Vzor **dvou volani, kde prvni jen zjistuje ID**. Cislo uctu, ktere clovek
|
||||
* zna, neni to, cim se ucet v PSD2 adresuje - banka pouziva vlastni
|
||||
* identifikator. Prekladat si ho rucne do kroku stromu by znamenalo, ze po
|
||||
* kazde vymene souhlasu prestane strom fungovat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'csob.list-transactions',
|
||||
name: 'Načíst pohyby',
|
||||
description:
|
||||
'Stáhne zaúčtované pohyby na účtu za období. Nic nemění, jen čte. ' +
|
||||
'Účet se vybírá číslem účtu, převod na identifikátor banky řeší krok sám.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'accountNumber',
|
||||
label: 'Číslo účtu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint:
|
||||
'Ve tvaru, jakým ho zná klient, například 123456789/0300, nebo IBAN. ' +
|
||||
'Bez vyplnění se vezme první účet, na který souhlas dosáhne.',
|
||||
},
|
||||
{
|
||||
id: 'dateFrom',
|
||||
label: 'Od data',
|
||||
type: 'date',
|
||||
required: false,
|
||||
hint: 'Bez vyplnění rozhoduje banka, obvykle vrací poslední pohyby.',
|
||||
},
|
||||
{ id: 'dateTo', label: 'Do data', type: 'date', required: false },
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik pohybů',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
hint: 'Strop na jednu stránku. Vyšší číslo znamená větší odpověď.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'accountId', label: 'Identifikátor účtu', type: 'string', required: true },
|
||||
{ id: 'count', label: 'Počet pohybů', type: 'number', required: true },
|
||||
{ id: 'transactions', label: 'Pohyby', type: 'list', required: true },
|
||||
{ id: 'newest', label: 'Datum nejnovějšího pohybu', type: 'string', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Jen cislice. Cislo uctu se pise s pomlckami, lomitkem i s mezerami. */
|
||||
function digits(value) {
|
||||
return String(value ?? '').replace(/[^0-9]/g, '');
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const { body: accountsBody } = await ctx.http.get('/accounts', { query: { size: 50 } });
|
||||
// COBS obaluje kolekci pod vlastni nazev, `list` zkusi i obvykle obalky.
|
||||
const accounts = ctx.util.list(accountsBody, 'accounts');
|
||||
if (accounts === null) ctx.fail('Odpověď banky nemá seznam účtů.', accountsBody);
|
||||
if (accounts.length === 0) {
|
||||
ctx.fail('Souhlas nedosáhne na žádný účet. Zkontrolujte platnost souhlasu v ČSOB.');
|
||||
}
|
||||
|
||||
let account = accounts[0];
|
||||
|
||||
if (inputs.accountNumber) {
|
||||
const wanted = digits(inputs.accountNumber);
|
||||
const match = accounts.find((item) => {
|
||||
const iban = digits(pick(item, 'iban'));
|
||||
const number = digits(
|
||||
pick(pick(item, 'accountNumber'), 'number') ?? pick(item, 'accountNumber'),
|
||||
);
|
||||
return (iban !== '' && iban.includes(wanted)) || (number !== '' && wanted.endsWith(number));
|
||||
});
|
||||
if (!match) {
|
||||
ctx.fail(
|
||||
`Účet ${inputs.accountNumber} mezi ${accounts.length} dostupnými není. ` +
|
||||
'Souhlas může platit na jiný účet.',
|
||||
);
|
||||
}
|
||||
account = match;
|
||||
} else {
|
||||
ctx.log(`Číslo účtu nezadáno, bere se první z ${accounts.length} dostupných.`);
|
||||
}
|
||||
|
||||
const accountId = need(text(pick(account, 'accountId', 'id')), 'identifikátor účtu');
|
||||
|
||||
const { body } = await ctx.http.get(`/accounts/${accountId}/transactions`, {
|
||||
query: {
|
||||
dateFrom: inputs.dateFrom ? inputs.dateFrom.slice(0, 10) : undefined,
|
||||
dateTo: inputs.dateTo ? inputs.dateTo.slice(0, 10) : undefined,
|
||||
size: inputs.limit,
|
||||
},
|
||||
});
|
||||
|
||||
const transactions = ctx.util.list(body, 'transactions');
|
||||
if (transactions === null) ctx.fail('Odpověď banky nemá seznam pohybů.', body);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
count: transactions.length,
|
||||
transactions,
|
||||
newest:
|
||||
transactions.length > 0
|
||||
? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate'))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Google Analytics 4: report.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /ga/data/properties/{property_id}/runReport
|
||||
*
|
||||
* GA4 vraci report jako **hlavicky zvlast a hodnoty zvlast**, tedy tvar, se
|
||||
* kterym se ve strome nedá pracovat. Skript ho proto srovna na seznam objektu,
|
||||
* kde ma kazdy radek pojmenovana pole - podminka za krokem se pak muze zeptat
|
||||
* na `activeUsers` misto na `metricValues.0.value`.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'ga4.run-report',
|
||||
name: 'Načíst report',
|
||||
description:
|
||||
'Stáhne metriky z Google Analytics 4 za období. Vrací řádky s pojmenovanými ' +
|
||||
'poli, takže se s nimi dá rovnou počítat.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'propertyId',
|
||||
label: 'ID property',
|
||||
type: 'string',
|
||||
required: true,
|
||||
pattern: '^[0-9]+$',
|
||||
hint: 'Jen číslice. GA4: Administrace, Nastavení property, ID property.',
|
||||
},
|
||||
{
|
||||
id: 'metrics',
|
||||
label: 'Metriky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'activeUsers,sessions',
|
||||
hint: 'Oddělené čárkou, například activeUsers,sessions,conversions.',
|
||||
},
|
||||
{
|
||||
id: 'dimensions',
|
||||
label: 'Dimenze',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Oddělené čárkou, například date,country. Bez nich vyjde jeden souhrnný řádek.',
|
||||
},
|
||||
{
|
||||
id: 'startDate',
|
||||
label: 'Od',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: '7daysAgo',
|
||||
hint: 'Datum jako 2026-08-01, nebo relativně: 7daysAgo, yesterday, today.',
|
||||
},
|
||||
{
|
||||
id: 'endDate',
|
||||
label: 'Do',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'today',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik řádků',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'totalRows', label: 'Řádků celkem v GA', type: 'number', required: false },
|
||||
{ id: 'firstRow', label: 'První řádek', type: 'object', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 45000,
|
||||
};
|
||||
|
||||
/** Pole, nebo prazdno. Cizi odpoved nemusi mit tvar, ktery cekame. */
|
||||
function list(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** Seznam z jednoho radku. Prazdne polozky se zahazuji, at neposilame "". */
|
||||
function names(value) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num } = ctx.util;
|
||||
|
||||
const metrics = names(inputs.metrics);
|
||||
if (metrics.length === 0) ctx.fail('Vyplňte aspoň jednu metriku.');
|
||||
const dimensions = names(inputs.dimensions);
|
||||
|
||||
const { body } = await ctx.http.post(`/ga/data/properties/${inputs.propertyId}/runReport`, {
|
||||
dateRanges: [{ startDate: inputs.startDate, endDate: inputs.endDate }],
|
||||
metrics: metrics.map((name) => ({ name })),
|
||||
...(dimensions.length > 0 ? { dimensions: dimensions.map((name) => ({ name })) } : {}),
|
||||
limit: inputs.limit,
|
||||
});
|
||||
|
||||
const dimensionHeaders = list(pick(body, 'dimensionHeaders')).map((header) =>
|
||||
String(pick(header, 'name')),
|
||||
);
|
||||
const metricHeaders = list(pick(body, 'metricHeaders')).map((header) =>
|
||||
String(pick(header, 'name')),
|
||||
);
|
||||
|
||||
const rows = list(pick(body, 'rows')).map((row) => {
|
||||
const flat = {};
|
||||
const dimensionValues = list(pick(row, 'dimensionValues'));
|
||||
const metricValues = list(pick(row, 'metricValues'));
|
||||
|
||||
dimensionHeaders.forEach((name, index) => {
|
||||
flat[name] = pick(dimensionValues[index], 'value') ?? null;
|
||||
});
|
||||
// Metriky se prevadeji na cisla. GA je vraci jako text a soucet retezcu
|
||||
// by v dalsim kroku dal "12" misto 3.
|
||||
metricHeaders.forEach((name, index) => {
|
||||
flat[name] = num(pick(metricValues[index], 'value'), 0);
|
||||
});
|
||||
return flat;
|
||||
});
|
||||
|
||||
return {
|
||||
count: rows.length,
|
||||
rows,
|
||||
totalRows: num(pick(body, 'rowCount')),
|
||||
firstRow: rows[0] ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Google Ads: vykon kampani.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /googleads/customers/{customer_id}/search
|
||||
*
|
||||
* Google Ads se pta jazykem GAQL. Skript proto nabizi dve cesty: bud se vyplni
|
||||
* obdobi a vezme se pripraveny dotaz na kampane, nebo se napise vlastni GAQL.
|
||||
* Bez te druhe moznosti by kazdy jiny report znamenal novy skript.
|
||||
*
|
||||
* Castky chodi v mikrojednotkach (milion = jedna koruna). Prepocitavaji se
|
||||
* tady, aby se v kazdem dalsim kroku nedelilo znovu a nekde spatne.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'google-ads.campaign-report',
|
||||
name: 'Načíst výkon kampaní',
|
||||
description:
|
||||
'Stáhne náklady, prokliky a konverze podle kampaní za období. ' +
|
||||
'Místo období lze zadat vlastní GAQL dotaz.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'customerId',
|
||||
label: 'ID účtu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
pattern: '^[0-9-]{10,14}$',
|
||||
hint: 'Desetimístné číslo účtu. Pomlčky se odstraní.',
|
||||
},
|
||||
{
|
||||
id: 'period',
|
||||
label: 'Období',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'LAST_7_DAYS',
|
||||
options: [
|
||||
{ value: 'TODAY', label: 'Dnes' },
|
||||
{ value: 'YESTERDAY', label: 'Včera' },
|
||||
{ value: 'LAST_7_DAYS', label: 'Posledních 7 dní' },
|
||||
{ value: 'LAST_30_DAYS', label: 'Posledních 30 dní' },
|
||||
{ value: 'THIS_MONTH', label: 'Tento měsíc' },
|
||||
{ value: 'LAST_MONTH', label: 'Minulý měsíc' },
|
||||
],
|
||||
hint: 'Použije se jen tehdy, když není vyplněný vlastní dotaz.',
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
label: 'Vlastní GAQL dotaz',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Když je vyplněný, období se ignoruje. Například SELECT ad_group.name FROM ad_group.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'cost', label: 'Náklady celkem', type: 'number', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
const campaignQuery = (period) =>
|
||||
'SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, ' +
|
||||
'metrics.clicks, metrics.cost_micros, metrics.conversions ' +
|
||||
`FROM campaign WHERE segments.date DURING ${period}`;
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const customerId = inputs.customerId.replace(/-/g, '');
|
||||
const query = inputs.query ?? campaignQuery(inputs.period);
|
||||
|
||||
const { body } = await ctx.http.post(`/googleads/customers/${customerId}/search`, { query });
|
||||
|
||||
const raw = pick(body, 'results', 'rows');
|
||||
const items = Array.isArray(raw) ? raw : [];
|
||||
|
||||
// Vlastni dotaz vraci vlastni tvar. Prevlekat ho do poli kampane by znamenalo
|
||||
// seznam samych null - radky se proto vrati tak, jak prisly.
|
||||
if (inputs.query) {
|
||||
ctx.log(`Vlastní dotaz vrátil ${items.length} řádků, souhrny se nepočítají.`);
|
||||
return { count: items.length, rows: items, cost: 0, clicks: 0 };
|
||||
}
|
||||
|
||||
let cost = 0;
|
||||
let clicks = 0;
|
||||
|
||||
const rows = items.map((row) => {
|
||||
const campaign = pick(row, 'campaign') ?? {};
|
||||
const metrics = pick(row, 'metrics') ?? {};
|
||||
|
||||
// Mikrojednotky na koruny. Pri deleni az v dalsim kroku by se na to
|
||||
// nekde zapomnelo a report by hlasil milionove castky.
|
||||
const rowCost = round(num(pick(metrics, 'costMicros', 'cost_micros'), 0) / 1_000_000, 2);
|
||||
const rowClicks = num(pick(metrics, 'clicks'), 0);
|
||||
cost += rowCost;
|
||||
clicks += rowClicks;
|
||||
|
||||
return {
|
||||
campaignId: pick(campaign, 'id') ?? null,
|
||||
campaignName: pick(campaign, 'name') ?? null,
|
||||
status: pick(campaign, 'status') ?? null,
|
||||
impressions: num(pick(metrics, 'impressions'), 0),
|
||||
clicks: rowClicks,
|
||||
cost: rowCost,
|
||||
conversions: num(pick(metrics, 'conversions'), 0),
|
||||
};
|
||||
});
|
||||
|
||||
return { count: rows.length, rows, cost: round(cost, 2), clicks };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Google Workspace: pripsani radku do tabulky.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/google-service
|
||||
* Endpoint: POST /google/sheets/spreadsheets/{spreadsheetId}/values/append
|
||||
*
|
||||
* Tabulka se zadava odkazem, ne ID. Odkaz je to, co ma clovek v prohlizeci;
|
||||
* ID z nej vytahne skript. Kdyby ho mel opisovat rucne, byl by to prvni zdroj
|
||||
* chyb, ktery se pozna az podle prazdne tabulky.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'google.append-sheet-row',
|
||||
name: 'Přidat řádek do tabulky',
|
||||
description:
|
||||
'Připíše řádek na konec listu v Google Tabulkách. Hodí se na evidenci ' +
|
||||
'poptávek, objednávek nebo běhů automatizace.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'spreadsheet',
|
||||
label: 'Tabulka',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Odkaz na tabulku z prohlížeče, nebo samotné ID.',
|
||||
},
|
||||
{
|
||||
id: 'sheetName',
|
||||
label: 'Název listu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'List1',
|
||||
hint: 'Přesně tak, jak je napsaný na záložce dole.',
|
||||
},
|
||||
{
|
||||
id: 'values',
|
||||
label: 'Hodnoty řádku',
|
||||
type: 'list',
|
||||
required: true,
|
||||
hint: 'Seznam hodnot zleva doprava, například ["2026-08-26", "Nová poptávka", 1200].',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'updatedRange', label: 'Kam se zapsalo', type: 'string', required: true },
|
||||
{ id: 'updatedCells', label: 'Počet zapsaných buněk', type: 'number', required: false },
|
||||
{ id: 'spreadsheetId', label: 'ID tabulky', type: 'string', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/**
|
||||
* ID tabulky z odkazu.
|
||||
*
|
||||
* Odkaz ma tvar .../spreadsheets/d/<id>/edit. Kdyz uzivatel vlozi rovnou ID,
|
||||
* projde beze zmeny - obe cesty vedou ke stejnemu vysledku.
|
||||
*/
|
||||
function spreadsheetId(value) {
|
||||
const match = /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/.exec(value);
|
||||
return match ? match[1] : value.trim();
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
if (inputs.values.length === 0) ctx.fail('Řádek nemá žádné hodnoty.');
|
||||
|
||||
const id = spreadsheetId(inputs.spreadsheet);
|
||||
// Rozsah je jen nazev listu: append si sam najde prvni volny radek.
|
||||
const range = inputs.sheetName;
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
`/google/sheets/spreadsheets/${encodeURIComponent(id)}/values/append`,
|
||||
{
|
||||
range,
|
||||
majorDimension: 'ROWS',
|
||||
// Vsechno na text: Sheets si cisla i data prevede samo podle formatu
|
||||
// bunky, kdezto smisene typy v jednom poli odmita.
|
||||
values: [inputs.values.map((value) => (value === null ? '' : String(value)))],
|
||||
},
|
||||
{ query: { range } },
|
||||
);
|
||||
|
||||
const updates = pick(body, 'updates') ?? body;
|
||||
|
||||
return {
|
||||
updatedRange: need(text(pick(updates, 'updatedRange')), 'zapsaný rozsah'),
|
||||
updatedCells: num(pick(updates, 'updatedCells')),
|
||||
spreadsheetId: id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Google Workspace: odeslani e-mailu pres Gmail.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/google-service
|
||||
* Endpoint: POST /google/gmail/messages/send
|
||||
*
|
||||
* Gmail neprijima predmet a telo zvlast. Chce **cely e-mail podle RFC 2822**
|
||||
* zakodovany do base64url, takze zpravu sklada skript. Predmet s diakritikou
|
||||
* se navic musi zabalit do MIME slova, jinak z nej cestou zbydou otazniky.
|
||||
*
|
||||
* Kodovani dela `ctx.util.base64`, ne Buffer ani `btoa`: skript nesmi
|
||||
* zaviset na globalech prostredi, aby bezel stejne i az pobezi v sandboxu.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'google.send-email',
|
||||
name: 'Odeslat e-mail',
|
||||
description:
|
||||
'Pošle e-mail přes Gmail účtu, pod kterým je napojení. Příjemců může být víc, ' +
|
||||
'oddělují se čárkou.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'to',
|
||||
label: 'Příjemci',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-maily oddělené čárkou.',
|
||||
},
|
||||
{ id: 'cc', label: 'Kopie', type: 'string', required: false },
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
|
||||
{ id: 'body', label: 'Text zprávy', type: 'string', required: true, multiline: true },
|
||||
{
|
||||
id: 'html',
|
||||
label: 'Text je HTML',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Vypnuté odešle zprávu jako čistý text.',
|
||||
},
|
||||
{
|
||||
id: 'from',
|
||||
label: 'Odesílatel',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Jen když má účet povolený alias. Bez vyplnění pošle Gmail zprávu za vlastníka schránky.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'messageId', label: 'ID zprávy', type: 'string', required: true },
|
||||
{ id: 'threadId', label: 'ID konverzace', type: 'string', required: false },
|
||||
{ id: 'recipients', label: 'Počet příjemců', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Base64url bez vypln, jak ho chce Gmail. `base64` je `ctx.util.base64`. */
|
||||
function base64Url(base64, value) {
|
||||
return base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Hlavicka s diakritikou musi byt MIME slovo, jinak z ni zbydou otazniky. */
|
||||
function header(base64, value) {
|
||||
for (const character of value) {
|
||||
if (character.codePointAt(0) > 127) return `=?UTF-8?B?${base64(value)}?=`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need, addresses, base64 } = ctx.util;
|
||||
|
||||
// Clovek pise carky, Gmail chce jeden retezec.
|
||||
const to = addresses(inputs.to);
|
||||
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
|
||||
const cc = addresses(inputs.cc);
|
||||
|
||||
const lines = [
|
||||
`To: ${to.join(', ')}`,
|
||||
...(cc.length > 0 ? [`Cc: ${cc.join(', ')}`] : []),
|
||||
...(inputs.from ? [`From: ${inputs.from}`] : []),
|
||||
`Subject: ${header(base64, inputs.subject)}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: text/${inputs.html ? 'html' : 'plain'}; charset="UTF-8"`,
|
||||
'Content-Transfer-Encoding: 8bit',
|
||||
'',
|
||||
inputs.body,
|
||||
];
|
||||
|
||||
const { body } = await ctx.http.post('/google/gmail/messages/send', {
|
||||
raw: base64Url(base64, lines.join('\r\n')),
|
||||
});
|
||||
|
||||
ctx.log(`Odesláno ${to.length + cc.length} příjemcům.`);
|
||||
|
||||
return {
|
||||
messageId: need(text(pick(body, 'id')), 'ID zprávy'),
|
||||
threadId: text(pick(body, 'threadId')),
|
||||
recipients: to.length + cc.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* iDoklad: zalozeni odberatele.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpoint: POST /contacts
|
||||
*
|
||||
* **Nic nedohledava.** Kdyz uz kontakt existuje, vznikne druhy se stejnym ICO -
|
||||
* a to je spravne chovani teto operace, ne chyba. Hledani je vlastni krok
|
||||
* (`Najit kontakt`), takze si strom sam rekne, kdy hledat a kdy zakladat:
|
||||
*
|
||||
* 1. Najit kontakt (podle ICO)
|
||||
* 2. podminka: nenalezeno?
|
||||
* ano -> Najit kontakt (podle e-mailu)
|
||||
* 3. podminka: porad nenalezeno?
|
||||
* ano -> Zalozit kontakt
|
||||
*
|
||||
* Kdyz nekomu staci "chci mit jistotu, ze tam je", pouzije jednim krokem
|
||||
* `Najit nebo zalozit kontakt`. Obojí ma smysl a ani jedno nenahrazuje druhe.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.create-contact',
|
||||
name: 'Založit kontakt',
|
||||
description:
|
||||
'Založí v iDokladu nového odběratele a vrátí jeho ID. Nic nedohledává - ' +
|
||||
'na to je krok Najít kontakt.',
|
||||
timeoutMs: 25000,
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'companyName',
|
||||
label: 'Název odběratele',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'U firmy název, u fyzické osoby jméno.',
|
||||
},
|
||||
{
|
||||
id: 'identificationNumber',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[0-9]{6,12}$',
|
||||
hint: 'Jen číslice. U fyzické osoby zůstane prázdné.',
|
||||
},
|
||||
{ id: 'vatNumber', label: 'DIČ', type: 'string', required: false },
|
||||
{ id: 'email', label: 'E-mail', type: 'string', required: false },
|
||||
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
|
||||
{ id: 'street', label: 'Ulice', type: 'string', required: false },
|
||||
{ id: 'city', label: 'Město', type: 'string', required: false },
|
||||
{ id: 'postalCode', label: 'PSČ', type: 'string', required: false },
|
||||
{
|
||||
id: 'countryCode',
|
||||
label: 'Země',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'CZ',
|
||||
hint: 'Dvoupísmenný kód. Nevyplněno = CZ.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
|
||||
{ id: 'companyName', label: 'Název odběratele', type: 'string', required: true },
|
||||
{ id: 'contact', label: 'Celý kontakt', type: 'object', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, text, num, need } = ctx.util;
|
||||
|
||||
const companyName = text(inputs.companyName);
|
||||
if (!companyName) {
|
||||
ctx.fail('Bez názvu odběratele iDoklad kontakt nezaloží.');
|
||||
}
|
||||
|
||||
/*
|
||||
* Posilaji se jen vyplnena pole. Prazdny retezec neni totez co "neuvedeno" -
|
||||
* u DIC by z nej iDoklad udelal platce DPH s prazdnym cislem.
|
||||
*/
|
||||
const body = {
|
||||
CompanyName: companyName,
|
||||
CountryId: null,
|
||||
...(text(inputs.identificationNumber)
|
||||
? { IdentificationNumber: text(inputs.identificationNumber) }
|
||||
: {}),
|
||||
...(text(inputs.vatNumber) ? { VatIdentificationNumber: text(inputs.vatNumber) } : {}),
|
||||
...(text(inputs.email) ? { Email: text(inputs.email) } : {}),
|
||||
...(text(inputs.phone) ? { Mobile: text(inputs.phone) } : {}),
|
||||
...(text(inputs.street) ? { Street: text(inputs.street) } : {}),
|
||||
...(text(inputs.city) ? { City: text(inputs.city) } : {}),
|
||||
...(text(inputs.postalCode) ? { PostalCode: text(inputs.postalCode) } : {}),
|
||||
...(text(inputs.countryCode) ? { CountryCode: text(inputs.countryCode) } : {}),
|
||||
};
|
||||
|
||||
const response = await ctx.http.post('/contacts', body);
|
||||
const created = unwrap(response.body);
|
||||
|
||||
ctx.log(`Kontakt ${companyName} založen.`);
|
||||
|
||||
return {
|
||||
contactId: need(num(pick(created, 'id')), 'ID založeného kontaktu'),
|
||||
companyName: text(pick(created, 'companyName', 'name')) || companyName,
|
||||
contact: created && typeof created === 'object' ? created : {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/*
|
||||
* Polozky se hledaji pod obema jmeny. iDoklad je v tele ceka jako
|
||||
* `IssuedInvoiceItems`, ale prevod je casto pojmenuje `items` podle toho,
|
||||
* jak se jmenuji ve zdrojove objednavce. Trvat na jednom z nich by znamenalo,
|
||||
* ze prevod projde kontrolou a doklad prijde do iDokladu bez radku.
|
||||
*/
|
||||
const rawItems = pick(payload, 'IssuedInvoiceItems', 'items');
|
||||
const items = Array.isArray(rawItems) ? rawItems : [];
|
||||
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.');
|
||||
}
|
||||
|
||||
/*
|
||||
* Vzor nese povinna pole, prevod prepise ta, kterym rozumime. Polozky se
|
||||
* doplni pod jmenem, ktere ceka iDoklad, at uz prisly pojmenovane jakkoliv.
|
||||
*/
|
||||
const body = { ...defaults, ...payload, IssuedInvoiceItems: items };
|
||||
delete body.items;
|
||||
|
||||
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 : {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* iDoklad: vystaveni vydane faktury s jednou polozkou.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpointy: GET /issued-invoices/default, POST /issued-invoices
|
||||
*
|
||||
* Vzor **dvou volani za sebou**. iDoklad u faktury vyzaduje pole, ktera nikdo
|
||||
* rucne vyplnovat nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`),
|
||||
* takze se nejdriv vezme predvyplneny vzor z `/issued-invoices/default`
|
||||
* a prepisou se v nem jen ty veci, ktere prisly ze stromu.
|
||||
*
|
||||
* Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli
|
||||
* na strane iDokladu. Takhle se prepisuje jen to, cemu rozumime.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.create-issued-invoice',
|
||||
name: 'Vystavit vydanou fakturu',
|
||||
description:
|
||||
'Vystaví v iDokladu vydanou fakturu s jednou položkou. Chybějící údaje ' +
|
||||
'se doplní z předvyplněného vzoru iDokladu.',
|
||||
timeoutMs: 25000,
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'partnerId',
|
||||
label: 'ID odběratele v iDokladu',
|
||||
type: 'number',
|
||||
required: true,
|
||||
hint: 'Umí ho dohledat akce Najít kontakt.',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
label: 'Popis dokladu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Text v hlavičce faktury, například Objednávka {{orderNumber}}.',
|
||||
},
|
||||
{ id: 'itemName', label: 'Název položky', type: 'string', required: true },
|
||||
{
|
||||
id: 'unitPrice',
|
||||
label: 'Cena za jednotku',
|
||||
type: 'number',
|
||||
required: true,
|
||||
hint: 'V měně dokladu. Desetinná čárka i tečka projdou.',
|
||||
},
|
||||
{ id: 'amount', label: 'Počet jednotek', type: 'number', required: false, default: 1 },
|
||||
{ id: 'unit', label: 'Jednotka', type: 'string', required: false, default: 'ks' },
|
||||
{
|
||||
id: 'dateOfIssue',
|
||||
label: 'Datum vystavení',
|
||||
type: 'date',
|
||||
required: false,
|
||||
hint: 'Nevyplněno = dnes.',
|
||||
},
|
||||
{
|
||||
id: 'maturityDays',
|
||||
label: 'Splatnost ve dnech',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 14,
|
||||
},
|
||||
{ id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
|
||||
{ id: 'note', label: 'Poznámka', type: 'string', required: false, multiline: true },
|
||||
{
|
||||
id: 'vatRateType',
|
||||
label: 'Kód sazby DPH',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 0,
|
||||
hint: 'Číselný kód VatRateType z iDokladu. Když nevíte, nechte 0.',
|
||||
},
|
||||
{
|
||||
id: 'priceType',
|
||||
label: 'Kód typu ceny',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 0,
|
||||
hint: 'Číselný kód PriceType z iDokladu (cena s DPH nebo bez). Když nevíte, nechte 0.',
|
||||
},
|
||||
],
|
||||
|
||||
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: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
function addDays(value, days) {
|
||||
const date = new Date(value);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
// `day` dava datum ve tvaru, ktery iDoklad ceka (2026-08-20).
|
||||
const { unwrap, pick, text, num, date, need, day } = ctx.util;
|
||||
|
||||
if (inputs.unitPrice < 0) ctx.fail('Cena za jednotku nemůže být záporná.');
|
||||
const amount = inputs.amount ?? 1;
|
||||
if (amount <= 0) ctx.fail('Počet jednotek musí být větší než nula.');
|
||||
|
||||
// 1. Predvyplneny vzor. Nese povinna pole, ktera nechceme vyplnovat rucne.
|
||||
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 issuedAt = inputs.dateOfIssue ? new Date(inputs.dateOfIssue) : new Date();
|
||||
const maturityAt = addDays(issuedAt, inputs.maturityDays ?? 14);
|
||||
|
||||
// 2. Prepisou se jen ta pole, kterym rozumime. Zbytek zustava ze vzoru.
|
||||
const payload = {
|
||||
...defaults,
|
||||
partnerId: inputs.partnerId,
|
||||
description: inputs.description,
|
||||
dateOfIssue: day(issuedAt),
|
||||
dateOfTaxing: day(issuedAt),
|
||||
dateOfMaturity: day(maturityAt),
|
||||
items: [
|
||||
{
|
||||
name: inputs.itemName,
|
||||
amount,
|
||||
unit: inputs.unit ?? 'ks',
|
||||
unitPrice: inputs.unitPrice,
|
||||
discountPercentage: 0,
|
||||
isTaxMovement: false,
|
||||
priceType: inputs.priceType ?? 0,
|
||||
vatRateType: inputs.vatRateType ?? 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (inputs.variableSymbol) payload.variableSymbol = inputs.variableSymbol;
|
||||
if (inputs.note) payload.note = inputs.note;
|
||||
|
||||
const { body } = await ctx.http.post('/issued-invoices', payload);
|
||||
const invoice = unwrap(body);
|
||||
|
||||
ctx.log(`Faktura vystavena pro odběratele ${inputs.partnerId}.`);
|
||||
|
||||
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')),
|
||||
dateOfMaturity:
|
||||
date(pick(invoice, 'dateOfMaturity')) ?? need(date(maturityAt), 'datum splatnosti'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* iDoklad: dohledani kontaktu podle ICO nebo e-mailu.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpoint: GET /contacts?filter=(IdentificationNumber~eq~12345678)
|
||||
*
|
||||
* Vzor **vlastni kontroly vstupu**. Manifest umi rict "tohle pole je povinne",
|
||||
* ale ne "aspon jedno z dvojice". Takova pravidla patri do kodu, protoze jen
|
||||
* tam jde napsat citelny duvod.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.find-contact',
|
||||
name: 'Najít kontakt',
|
||||
description:
|
||||
'Dohledá odběratele v iDokladu podle IČO nebo e-mailu. Nic nezakládá. ' +
|
||||
'Výsledek se použije jako ID odběratele při vystavení faktury.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'identificationNumber',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[0-9]{6,12}$',
|
||||
hint: 'Jen číslice. Přesnější než e-mail, hledá se podle něj první.',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Použije se, když IČO není k dispozici.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'found', label: 'Kontakt nalezen', type: 'boolean', required: true },
|
||||
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: false },
|
||||
{ id: 'companyName', label: 'Název firmy', type: 'string', required: false },
|
||||
{ id: 'identificationNumber', label: 'IČO', type: 'string', required: false },
|
||||
{ id: 'email', label: 'E-mail', type: 'string', required: false },
|
||||
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
const notFound = {
|
||||
found: false,
|
||||
contactId: null,
|
||||
companyName: null,
|
||||
identificationNumber: null,
|
||||
email: null,
|
||||
matchedBy: 'nenalezeno',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, text, num } = ctx.util;
|
||||
|
||||
if (!inputs.identificationNumber && !inputs.email) {
|
||||
ctx.fail('Vyplňte IČO nebo e-mail, jinak není podle čeho hledat.');
|
||||
}
|
||||
|
||||
/** Jedno hledani podle jednoho pole. Vraci kontakt, nebo null. */
|
||||
async function search(field, value) {
|
||||
const { body } = await ctx.http.get('/contacts', {
|
||||
query: { filter: `(${field}~eq~${value})`, filtertype: 'and', pageSize: 1 },
|
||||
});
|
||||
const items = unwrap(body);
|
||||
return Array.isArray(items) && items.length > 0 ? items[0] : null;
|
||||
}
|
||||
|
||||
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny.
|
||||
const attempts = [];
|
||||
if (inputs.identificationNumber) {
|
||||
attempts.push(['IdentificationNumber', inputs.identificationNumber, 'IČO']);
|
||||
}
|
||||
if (inputs.email) attempts.push(['Email', inputs.email, 'e-mail']);
|
||||
|
||||
for (const [field, value, label] of attempts) {
|
||||
const contact = await search(field, value);
|
||||
if (!contact) {
|
||||
ctx.log(`Podle ${label} ${value} se nic nenašlo.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
contactId: num(pick(contact, 'id')),
|
||||
companyName: text(pick(contact, 'companyName', 'name')),
|
||||
identificationNumber: text(pick(contact, 'identificationNumber')),
|
||||
email: text(pick(contact, 'email')),
|
||||
matchedBy: label,
|
||||
};
|
||||
}
|
||||
|
||||
return notFound;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* iDoklad: dohledani vydane faktury podle cisla dokladu.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpoint: GET /issued-invoices?filter=(DocumentNumber~eq~2024001)
|
||||
*
|
||||
* Tohle je vzor **predvalidace**: skript nic nemeni, jen odpovi, jestli doklad
|
||||
* existuje. Vystup `found` je pak to, na co se ve strome vetvi podminka.
|
||||
* Stejny princip jako akce "Dohledat firmu" u CRM, viz documentation/06-tickety.md.
|
||||
*
|
||||
* Proto taky nenalezena faktura NENI chyba. Kdyby skript spadl, nesla by
|
||||
* postavit vetev "doklad neznam, zaloz ho".
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.find-issued-invoice',
|
||||
name: 'Najít vydanou fakturu',
|
||||
description:
|
||||
'Zjistí, jestli v iDokladu existuje vydaná faktura s daným číslem dokladu. ' +
|
||||
'Nic nezakládá ani nemění. Podle výsledku se strom větví.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'documentNumber',
|
||||
label: 'Číslo dokladu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Číslo, jak je na faktuře, například 2024001.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'found', label: 'Faktura nalezena', type: 'boolean', required: true },
|
||||
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: false },
|
||||
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
|
||||
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: false },
|
||||
{ id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: false },
|
||||
{
|
||||
id: 'ambiguous',
|
||||
label: 'Odpovídá víc faktur',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, num, bool, date } = ctx.util;
|
||||
|
||||
const { body } = await ctx.http.get('/issued-invoices', {
|
||||
query: {
|
||||
// Tvar filtru je dany iDokladem: (Pole~operator~hodnota)
|
||||
filter: `(DocumentNumber~eq~${inputs.documentNumber})`,
|
||||
filtertype: 'and',
|
||||
// Dva staci: jeden na vysledek, druhy na zjisteni, ze neni jednoznacny.
|
||||
pageSize: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const items = unwrap(body);
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
|
||||
if (list.length === 0) {
|
||||
ctx.log(`Faktura ${inputs.documentNumber} v iDokladu není.`);
|
||||
return {
|
||||
found: false,
|
||||
invoiceId: null,
|
||||
totalWithVat: null,
|
||||
dateOfMaturity: null,
|
||||
isPaid: null,
|
||||
ambiguous: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (list.length > 1) {
|
||||
// Neni to chyba, ale nekdo to ma vedet - cislo dokladu ma byt jednoznacne.
|
||||
ctx.log(`Číslu dokladu ${inputs.documentNumber} odpovídá víc faktur, beru první.`);
|
||||
}
|
||||
|
||||
const invoice = list[0];
|
||||
|
||||
return {
|
||||
found: true,
|
||||
invoiceId: num(pick(invoice, 'id')),
|
||||
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
|
||||
dateOfMaturity: date(pick(invoice, 'dateOfMaturity')),
|
||||
isPaid: bool(pick(invoice, 'isPaid')),
|
||||
ambiguous: list.length > 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* iDoklad: nacteni vydane faktury podle ID.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpoint: GET /issued-invoices/{id}
|
||||
*
|
||||
* Nejjednodussi tvar skriptu: jedno volani a prevod odpovedi na vystupy.
|
||||
*
|
||||
* iDoklad vraci pole s velkym pocatecnim pismenem a nekdy obaluje odpoved
|
||||
* do `Data`. Proto `unwrap` a `pick` - nespoléhá se na presny tvar odpovedi,
|
||||
* protoze ten se u cizich sluzeb meni bez ohlaseni.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.get-issued-invoice',
|
||||
name: 'Získat vydanou fakturu',
|
||||
description:
|
||||
'Načte vydanou fakturu z iDokladu podle jejího ID. Používá se před rozhodnutím, ' +
|
||||
'co s ní dál, například jestli je už uhrazená.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'invoiceId',
|
||||
label: 'ID faktury v iDokladu',
|
||||
type: 'number',
|
||||
required: true,
|
||||
hint: 'Interní ID, ne číslo dokladu. Číslo dokladu umí dohledat akce Najít vydanou fakturu.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
|
||||
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
|
||||
{ id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
|
||||
{ id: 'partnerId', label: 'ID odběratele', type: 'number', required: false },
|
||||
{ id: 'partnerName', label: 'Odběratel', type: 'string', required: false },
|
||||
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: true },
|
||||
{ id: 'currencyId', label: 'ID měny', type: 'number', required: false },
|
||||
{ id: 'dateOfIssue', label: 'Datum vystavení', type: 'date', required: true },
|
||||
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
|
||||
{ id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, text, num, bool, date, need } = ctx.util;
|
||||
|
||||
const { body } = await ctx.http.get(`/issued-invoices/${inputs.invoiceId}`);
|
||||
const invoice = unwrap(body);
|
||||
|
||||
if (!invoice || typeof invoice !== 'object') {
|
||||
ctx.fail(`Faktura ${inputs.invoiceId} v iDokladu neexistuje.`);
|
||||
}
|
||||
|
||||
// Odberatel muze byt jak plocha hodnota, tak vnoreny objekt partnera.
|
||||
const partner = pick(invoice, 'partner', 'customer');
|
||||
const partnerName =
|
||||
text(pick(invoice, 'partnerName', 'customerName')) ??
|
||||
text(pick(partner, 'companyName', 'name'));
|
||||
|
||||
return {
|
||||
invoiceId: need(num(pick(invoice, 'id')), 'ID faktury'),
|
||||
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
|
||||
variableSymbol: text(pick(invoice, 'variableSymbol')),
|
||||
partnerId: num(pick(invoice, 'partnerId')) ?? num(pick(partner, 'id')),
|
||||
partnerName,
|
||||
totalWithVat: need(
|
||||
num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
|
||||
'celkovou částku',
|
||||
),
|
||||
currencyId: num(pick(invoice, 'currencyId')),
|
||||
dateOfIssue: need(date(pick(invoice, 'dateOfIssue')), 'datum vystavení'),
|
||||
dateOfMaturity: need(date(pick(invoice, 'dateOfMaturity')), 'datum splatnosti'),
|
||||
isPaid: bool(pick(invoice, 'isPaid')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* iDoklad: zapsani uhrady k vydane fakture.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpointy: GET /issued-payments/default/{invoiceId}, POST /issued-payments
|
||||
*
|
||||
* Vzor akce, ktera **neco meni**. U te zalezi na idempotenci: kdyz runtime krok
|
||||
* zopakuje po timeoutu, nesmi vzniknout druha uhrada. Klic `ctx.idempotencyKey`
|
||||
* je pro tentyz krok stejny pres vsechny pokusy a runtime ho posila v hlavicce
|
||||
* `Idempotency-Key` automaticky, takze skript nemusi delat nic navic.
|
||||
*
|
||||
* Castka se necha prazdna pro plnou uhradu - vzor z iDokladu uz nese zbytek
|
||||
* k zaplaceni, takze se nemusi pocitat tady.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.register-payment',
|
||||
name: 'Zapsat úhradu faktury',
|
||||
description:
|
||||
'Zapíše k vydané faktuře úhradu. Bez zadané částky se použije zbytek ' +
|
||||
'k zaplacení podle iDokladu.',
|
||||
|
||||
inputs: [
|
||||
{ id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
|
||||
{
|
||||
id: 'amount',
|
||||
label: 'Uhrazená částka',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Nevyplněno = celý zbytek k zaplacení.',
|
||||
},
|
||||
{
|
||||
id: 'dateOfPayment',
|
||||
label: 'Datum úhrady',
|
||||
type: 'date',
|
||||
required: false,
|
||||
hint: 'Nevyplněno = dnes.',
|
||||
},
|
||||
{
|
||||
id: 'sendConfirmation',
|
||||
label: 'Poslat potvrzení odběrateli',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'paymentId', label: 'ID úhrady', type: 'number', required: true },
|
||||
{ id: 'amount', label: 'Zapsaná částka', type: 'number', required: true },
|
||||
{ id: 'dateOfPayment', label: 'Datum úhrady', type: 'date', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, num, date, need, day } = ctx.util;
|
||||
|
||||
if (inputs.amount !== null && inputs.amount <= 0) {
|
||||
ctx.fail('Uhrazená částka musí být větší než nula.');
|
||||
}
|
||||
|
||||
// Vzor nese zbytek k zaplaceni i vychozi zpusob platby.
|
||||
const defaults = unwrap(
|
||||
(await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body,
|
||||
);
|
||||
if (!defaults || typeof defaults !== 'object') {
|
||||
ctx.fail(`K faktuře ${inputs.invoiceId} nejde zapsat úhradu, iDoklad ji nezná.`);
|
||||
}
|
||||
|
||||
const suggested = num(pick(defaults, 'paymentAmount'));
|
||||
const amount = inputs.amount ?? suggested;
|
||||
if (amount === null) {
|
||||
ctx.fail('iDoklad nevrátil zbytek k zaplacení, zadejte částku ručně.');
|
||||
}
|
||||
|
||||
const paidAt = inputs.dateOfPayment ? new Date(inputs.dateOfPayment) : new Date();
|
||||
|
||||
const payload = {
|
||||
...defaults,
|
||||
invoiceId: inputs.invoiceId,
|
||||
paymentAmount: amount,
|
||||
dateOfPayment: day(paidAt),
|
||||
sendPaymentConfirmation: inputs.sendConfirmation ?? false,
|
||||
};
|
||||
|
||||
const { body } = await ctx.http.post('/issued-payments', payload);
|
||||
const payment = unwrap(body);
|
||||
|
||||
ctx.log(`K faktuře ${inputs.invoiceId} zapsána úhrada ${amount}.`);
|
||||
|
||||
return {
|
||||
paymentId: need(num(pick(payment, 'id')), 'ID úhrady'),
|
||||
amount: need(num(pick(payment, 'paymentAmount')) ?? amount, 'zapsanou částku'),
|
||||
dateOfPayment: need(date(pick(payment, 'dateOfPayment')) ?? date(paidAt), 'datum úhrady'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* iDoklad: odeslani vydane faktury e-mailem.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpoint: POST /mail/issued-invoices/send
|
||||
*
|
||||
* Vzor akce, u ktere **odpoved sluzby nic nevraci**. Vystup se proto sklada
|
||||
* z toho, co skript posilal, ne z toho, co prislo zpatky. Bez toho by strom
|
||||
* za timhle krokem nemel na cem stavet podminku.
|
||||
*
|
||||
* Zaroven je to vzor toho, jak nahradit jeden vstup dvema chovanimi: kdyz je
|
||||
* vyplneny e-mail, posle se na nej. Kdyz neni, posle se na adresu odberatele
|
||||
* vedenou v iDokladu.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.send-invoice-email',
|
||||
name: 'Odeslat fakturu e-mailem',
|
||||
description:
|
||||
'Odešle vydanou fakturu e-mailem. Bez zadané adresy jde na e-mail ' +
|
||||
'odběratele vedený v iDokladu.',
|
||||
timeoutMs: 30000,
|
||||
|
||||
inputs: [
|
||||
{ id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail příjemce',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$',
|
||||
hint: 'Nevyplněno = adresa odběratele z iDokladu.',
|
||||
},
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: false },
|
||||
{ id: 'body', label: 'Text e-mailu', type: 'string', required: false, multiline: true },
|
||||
{
|
||||
id: 'sendAttachment',
|
||||
label: 'Přiložit PDF faktury',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'sendToSelf',
|
||||
label: 'Poslat kopii sobě',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
|
||||
{ id: 'recipient', label: 'Komu se odeslalo', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const toGivenAddress = Boolean(inputs.email);
|
||||
|
||||
const payload = {
|
||||
documentId: inputs.invoiceId,
|
||||
// Vsechna tri pole jsou u iDokladu povinna, i kdyz jsou nepravdiva.
|
||||
sendToPartner: !toGivenAddress,
|
||||
sendToAccountant: false,
|
||||
sendToSelf: inputs.sendToSelf ?? false,
|
||||
sendAttachment: inputs.sendAttachment ?? true,
|
||||
...(toGivenAddress ? { otherRecipients: [inputs.email] } : {}),
|
||||
...(inputs.subject ? { emailSubject: inputs.subject } : {}),
|
||||
...(inputs.body ? { emailBody: inputs.body } : {}),
|
||||
};
|
||||
|
||||
// Nektere instance vraci 204 bez tela, jine 200 s potvrzenim. Obojí je uspech.
|
||||
const { status } = await ctx.http.post('/mail/issued-invoices/send', payload);
|
||||
|
||||
const recipient = toGivenAddress ? String(inputs.email) : 'odběratel z iDokladu';
|
||||
ctx.log(`Faktura ${inputs.invoiceId} odeslána (${recipient}), HTTP ${status}.`);
|
||||
|
||||
return { sent: true, recipient };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* iDoklad: najde odberatele, a kdyz neni, zalozi ho.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/idoklad
|
||||
* Endpointy: GET /contacts?filter=..., POST /contacts
|
||||
*
|
||||
* Proc jeden krok a ne dvojice "najdi" plus "zaloz" ve vetvich podminky:
|
||||
* vetev podminky **nepridava nic do sekvence za podminkou**, protoze nemusela
|
||||
* probehnout. ID odberatele by tedy za podminkou nebylo k dispozici, i kdyz
|
||||
* ho ve skutecnosti daji obe vetve. Sloucit to do jednoho kroku je poctivejsi
|
||||
* nez to obchazet - a navic je to presne ta operace, kterou clovek chce:
|
||||
* "chci mit jistotu, ze odberatel v iDokladu je".
|
||||
*
|
||||
* Vystup `created` rekne, co se stalo, takze se na to da navazat podminkou
|
||||
* (poslat uvitaci mail jen novemu zakaznikovi).
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'idoklad.upsert-contact',
|
||||
name: 'Najít nebo založit kontakt',
|
||||
description:
|
||||
'Dohledá odběratele podle IČO nebo e-mailu. Když neexistuje, založí ho ' +
|
||||
'a vrátí nové ID. Používá se před vystavením faktury.',
|
||||
timeoutMs: 25000,
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'identificationNumber',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[0-9]{6,12}$',
|
||||
hint: 'Jen číslice. Přesnější než e-mail, hledá se podle něj první.',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Použije se, když IČO není k dispozici.',
|
||||
},
|
||||
{
|
||||
id: 'companyName',
|
||||
label: 'Název odběratele',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'U firmy název, u fyzické osoby jméno. Bez něj kontakt založit nejde.',
|
||||
},
|
||||
{ id: 'vatNumber', label: 'DIČ', type: 'string', required: false },
|
||||
{ id: 'street', label: 'Ulice', type: 'string', required: false },
|
||||
{ id: 'city', label: 'Město', type: 'string', required: false },
|
||||
{ id: 'postalCode', label: 'PSČ', type: 'string', required: false },
|
||||
{
|
||||
id: 'countryCode',
|
||||
label: 'Země',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'CZ',
|
||||
hint: 'Dvoupísmenný kód. Nevyplněno = CZ.',
|
||||
},
|
||||
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
|
||||
{ id: 'created', label: 'Kontakt vznikl teď', type: 'boolean', required: true },
|
||||
{ id: 'companyName', label: 'Název odběratele', type: 'string', required: false },
|
||||
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, text, num, need } = ctx.util;
|
||||
|
||||
const ico = text(inputs.identificationNumber);
|
||||
const email = text(inputs.email);
|
||||
|
||||
if (!ico && !email) {
|
||||
ctx.fail('Vyplňte IČO nebo e-mail, jinak není podle čeho hledat ani zakládat.');
|
||||
}
|
||||
|
||||
/** Jedno hledani podle jednoho pole. Vraci kontakt, nebo null. */
|
||||
async function search(field, value) {
|
||||
const { body } = await ctx.http.get('/contacts', {
|
||||
query: { filter: `(${field}~eq~${value})`, filtertype: 'and', pageSize: 1 },
|
||||
});
|
||||
const items = unwrap(body);
|
||||
return Array.isArray(items) && items.length > 0 ? items[0] : null;
|
||||
}
|
||||
|
||||
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny.
|
||||
const attempts = [];
|
||||
if (ico) attempts.push(['IdentificationNumber', ico, 'IČO']);
|
||||
if (email) attempts.push(['Email', email, 'e-mail']);
|
||||
|
||||
for (const [field, value, label] of attempts) {
|
||||
const contact = await search(field, value);
|
||||
if (!contact) {
|
||||
ctx.log(`Podle ${label} ${value} se nic nenašlo.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.log(`Kontakt nalezen podle ${label}, zakládat se nebude.`);
|
||||
return {
|
||||
contactId: need(num(pick(contact, 'id')), 'ID nalezeného kontaktu'),
|
||||
created: false,
|
||||
companyName: text(pick(contact, 'companyName', 'name')),
|
||||
matchedBy: label,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Zaklada se az tady, kdyz uz je jiste, ze kontakt neexistuje. Poradi je
|
||||
* dulezite: zalozit napred a hledat potom by pri kazde objednavce vyrobilo
|
||||
* dalsiho odberatele se stejnym ICO.
|
||||
*/
|
||||
const companyName = text(inputs.companyName);
|
||||
if (!companyName) {
|
||||
ctx.fail('Kontakt v iDokladu neexistuje a bez názvu odběratele ho nejde založit.');
|
||||
}
|
||||
|
||||
const body = {
|
||||
CompanyName: companyName,
|
||||
...(ico ? { IdentificationNumber: ico } : {}),
|
||||
...(text(inputs.vatNumber) ? { VatIdentificationNumber: text(inputs.vatNumber) } : {}),
|
||||
...(email ? { Email: email } : {}),
|
||||
...(text(inputs.phone) ? { Mobile: text(inputs.phone) } : {}),
|
||||
...(text(inputs.street) ? { Street: text(inputs.street) } : {}),
|
||||
...(text(inputs.city) ? { City: text(inputs.city) } : {}),
|
||||
...(text(inputs.postalCode) ? { PostalCode: text(inputs.postalCode) } : {}),
|
||||
CountryId: null,
|
||||
...(text(inputs.countryCode) ? { CountryCode: text(inputs.countryCode) } : {}),
|
||||
};
|
||||
|
||||
const response = await ctx.http.post('/contacts', body);
|
||||
const created = unwrap(response.body);
|
||||
|
||||
ctx.log(`Kontakt ${companyName} založen.`);
|
||||
|
||||
return {
|
||||
contactId: need(num(pick(created, 'id')), 'ID založeného kontaktu'),
|
||||
created: true,
|
||||
companyName: text(pick(created, 'companyName', 'name')) || companyName,
|
||||
matchedBy: 'založeno',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Meta Ads: vykon reklam na Facebooku a Instagramu.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/meta
|
||||
* Endpoint: GET /ads/insights/{object_id}
|
||||
*
|
||||
* Meta pocita **velke reporty asynchronne**. Tenhle krok je synchronni verze
|
||||
* pro bezne obdobi a uroven; kdyz Meta odpovi, ze uloha bezi, rekne se to
|
||||
* nahlas misto toho, aby krok predstiral prazdny vysledek.
|
||||
*
|
||||
* Utrata chodi v mene uctu jako text. Prevadi se na cislo tady, aby se s ni
|
||||
* v dalsim kroku dalo pocitat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'meta-ads.insights',
|
||||
name: 'Načíst výkon reklam',
|
||||
description:
|
||||
'Stáhne útratu, zobrazení, prokliky a konverze za období. Objektem může být ' +
|
||||
'reklamní účet, kampaň, sestava i jednotlivá reklama.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'objectId',
|
||||
label: 'ID objektu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Reklamní účet jako act_123456789, nebo ID kampaně, sestavy či reklamy.',
|
||||
},
|
||||
{
|
||||
id: 'level',
|
||||
label: 'Úroveň',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'campaign',
|
||||
options: [
|
||||
{ value: 'account', label: 'Účet' },
|
||||
{ value: 'campaign', label: 'Kampaň' },
|
||||
{ value: 'adset', label: 'Sestava' },
|
||||
{ value: 'ad', label: 'Reklama' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'period',
|
||||
label: 'Období',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'last_7d',
|
||||
options: [
|
||||
{ value: 'today', label: 'Dnes' },
|
||||
{ value: 'yesterday', label: 'Včera' },
|
||||
{ value: 'last_7d', label: 'Posledních 7 dní' },
|
||||
{ value: 'last_30d', label: 'Posledních 30 dní' },
|
||||
{ value: 'this_month', label: 'Tento měsíc' },
|
||||
{ value: 'last_month', label: 'Minulý měsíc' },
|
||||
],
|
||||
hint: 'Použije se, když není vyplněné vlastní datum.',
|
||||
},
|
||||
{
|
||||
id: 'dateFrom',
|
||||
label: 'Od',
|
||||
type: 'date',
|
||||
required: false,
|
||||
hint: 'Vyplněné datum od i do přebije volbu období.',
|
||||
},
|
||||
{ id: 'dateTo', label: 'Do', type: 'date', required: false },
|
||||
{
|
||||
id: 'fields',
|
||||
label: 'Pole',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'campaign_name,impressions,clicks,spend,ctr,cpc,actions',
|
||||
hint: 'Oddělená čárkou, podle dokumentace Meta Marketing API.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'spend', label: 'Útrata celkem', type: 'number', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const query = { level: inputs.level, fields: inputs.fields };
|
||||
|
||||
if (inputs.dateFrom && inputs.dateTo) {
|
||||
// Meta chce rozsah jako JSON v query parametru, ne jako dva parametry.
|
||||
query.time_range = JSON.stringify({
|
||||
since: inputs.dateFrom.slice(0, 10),
|
||||
until: inputs.dateTo.slice(0, 10),
|
||||
});
|
||||
} else {
|
||||
if (inputs.dateFrom || inputs.dateTo) {
|
||||
ctx.log('Vyplněné je jen jedno datum, použije se zvolené období.');
|
||||
}
|
||||
query.date_preset = inputs.period;
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.get(`/ads/insights/${inputs.objectId}`, { query });
|
||||
|
||||
const raw = pick(body, 'data', 'rows');
|
||||
const items = Array.isArray(raw) ? raw : [];
|
||||
|
||||
if (items.length === 0 && pick(body, 'report_run_id') !== undefined) {
|
||||
ctx.fail(
|
||||
'Meta report počítá na pozadí, synchronní volání ho nestihlo. ' +
|
||||
'Zkraťte období nebo zvolte vyšší úroveň.',
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
let spend = 0;
|
||||
let clicks = 0;
|
||||
let impressions = 0;
|
||||
|
||||
const rows = items.map((row) => {
|
||||
// Meta vraci cisla jako text, vcetne utraty.
|
||||
const rowSpend = num(pick(row, 'spend'), 0);
|
||||
const rowClicks = num(pick(row, 'clicks'), 0);
|
||||
const rowImpressions = num(pick(row, 'impressions'), 0);
|
||||
|
||||
spend += rowSpend;
|
||||
clicks += rowClicks;
|
||||
impressions += rowImpressions;
|
||||
|
||||
return {
|
||||
...row,
|
||||
spend: rowSpend,
|
||||
clicks: rowClicks,
|
||||
impressions: rowImpressions,
|
||||
};
|
||||
});
|
||||
|
||||
if (ctx.util.bool(pick(body, 'truncated'))) {
|
||||
ctx.log('Meta vrátila jen část stránek, výsledek není úplný.');
|
||||
}
|
||||
|
||||
return { count: rows.length, rows, spend: round(spend, 2), clicks, impressions };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Microsoft 365: zalozeni schuzky v kalendari.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/microsoft-365-service
|
||||
* Endpoint: POST /users/{user_id}/calendar/events
|
||||
*
|
||||
* Casova zona se posila vzdy vyslovne. Graph bez ni bere cas jako UTC, takze
|
||||
* schuzka domluvena na desatou by v letnim case vysla na dvanactou - a nikdo
|
||||
* by nevedel proc.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'microsoft365.create-event',
|
||||
name: 'Vytvořit schůzku',
|
||||
description: 'Založí schůzku v kalendáři zvoleného uživatele a pozve účastníky.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'mailbox',
|
||||
label: 'Kalendář uživatele',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-mail nebo ID uživatele, jehož kalendář se použije.',
|
||||
},
|
||||
{ id: 'subject', label: 'Název schůzky', type: 'string', required: true },
|
||||
{
|
||||
id: 'start',
|
||||
label: 'Začátek',
|
||||
type: 'date',
|
||||
required: true,
|
||||
hint: 'Datum a čas začátku.',
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
label: 'Konec',
|
||||
type: 'date',
|
||||
required: true,
|
||||
hint: 'Musí být po začátku.',
|
||||
},
|
||||
{
|
||||
id: 'timeZone',
|
||||
label: 'Časová zóna',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'Central Europe Standard Time',
|
||||
hint: 'Název zóny podle Windows, jak ho očekává Graph.',
|
||||
},
|
||||
{
|
||||
id: 'attendees',
|
||||
label: 'Účastníci',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'E-maily oddělené čárkou.',
|
||||
},
|
||||
{ id: 'location', label: 'Místo', type: 'string', required: false },
|
||||
{
|
||||
id: 'body',
|
||||
label: 'Popis',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
id: 'onlineMeeting',
|
||||
label: 'Přidat schůzku v Teams',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'eventId', label: 'ID schůzky', type: 'string', required: true },
|
||||
{ id: 'webLink', label: 'Odkaz na schůzku', type: 'string', required: false },
|
||||
{ id: 'joinUrl', label: 'Odkaz do Teams', type: 'string', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
if (Date.parse(inputs.end) <= Date.parse(inputs.start)) {
|
||||
ctx.fail('Konec schůzky musí být po jejím začátku.');
|
||||
}
|
||||
|
||||
// Graph chce cas bez zonoveho posunu a zonu zvlast. ISO z validace parametru
|
||||
// konci na Z, takze se posun odrizne a zona se posle v `timeZone`.
|
||||
const local = (value) => value.replace(/(\.\d+)?Z$/, '');
|
||||
|
||||
const payload = {
|
||||
subject: inputs.subject,
|
||||
start: { dateTime: local(inputs.start), timeZone: inputs.timeZone },
|
||||
end: { dateTime: local(inputs.end), timeZone: inputs.timeZone },
|
||||
is_online_meeting: inputs.onlineMeeting,
|
||||
};
|
||||
if (inputs.body) payload.body = inputs.body;
|
||||
if (inputs.location) payload.location = inputs.location;
|
||||
if (inputs.onlineMeeting) payload.online_meeting_provider = 'teamsForBusiness';
|
||||
|
||||
const attendees = String(inputs.attendees ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((address) => ({ address }));
|
||||
if (attendees.length > 0) payload.attendees = attendees;
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
`/users/${encodeURIComponent(inputs.mailbox)}/calendar/events`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
eventId: need(text(pick(body, 'id')), 'ID schůzky'),
|
||||
webLink: text(pick(body, 'webLink')),
|
||||
joinUrl: text(pick(body, 'onlineMeetingUrl') ?? pick(pick(body, 'onlineMeeting'), 'joinUrl')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Microsoft 365: odeslani e-mailu pres Graph.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/microsoft-365-service
|
||||
* Endpoint: POST /users/{user_id}/mail/send
|
||||
*
|
||||
* Napojeni je **aplikacni**, ne uzivatelske: prihlasuje se registrovana
|
||||
* aplikace, ne clovek. Proto je schranka vzdy soucasti kroku - aplikace sama
|
||||
* o sobe zadnou nema a bez uvedeneho uzivatele by Graph nevedel, odkud poslat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'microsoft365.send-mail',
|
||||
name: 'Odeslat e-mail',
|
||||
description:
|
||||
'Odešle e-mail ze zvolené schránky Microsoft 365. Příjemců může být víc, ' +
|
||||
'oddělují se čárkou.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'mailbox',
|
||||
label: 'Schránka odesílatele',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-mail nebo ID uživatele, například servis@firma.cz.',
|
||||
},
|
||||
{
|
||||
id: 'to',
|
||||
label: 'Příjemci',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-maily oddělené čárkou.',
|
||||
},
|
||||
{ id: 'cc', label: 'Kopie', type: 'string', required: false },
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
|
||||
{
|
||||
id: 'body',
|
||||
label: 'Text zprávy',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
label: 'Text je HTML',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Vypnuté odešle zprávu jako čistý text.',
|
||||
},
|
||||
{
|
||||
id: 'replyTo',
|
||||
label: 'Adresa pro odpověď',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Když se má odpovídat jinam než do schránky odesílatele.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
|
||||
{ id: 'recipients', label: 'Počet příjemců', type: 'number', required: true },
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
// Graph chce pole objektu, clovek pise carky.
|
||||
const addresses = (value) => ctx.util.addresses(value).map((address) => ({ address }));
|
||||
|
||||
const to = addresses(inputs.to);
|
||||
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
|
||||
|
||||
const payload = {
|
||||
subject: inputs.subject,
|
||||
body: inputs.body,
|
||||
body_content_type: inputs.html ? 'HTML' : 'Text',
|
||||
to,
|
||||
};
|
||||
|
||||
const cc = addresses(inputs.cc);
|
||||
if (cc.length > 0) payload.cc = cc;
|
||||
if (inputs.replyTo) payload.reply_to = addresses(inputs.replyTo);
|
||||
|
||||
// Cesta obsahuje e-mail, ktery muze mit znaky mimo ASCII.
|
||||
await ctx.http.post(`/users/${encodeURIComponent(inputs.mailbox)}/mail/send`, payload);
|
||||
|
||||
ctx.log(`Odesláno ${to.length + cc.length} příjemcům ze schránky ${inputs.mailbox}.`);
|
||||
|
||||
// Graph na uspesne odeslani nevraci telo, vystup se sklada ze vstupu.
|
||||
return { sent: true, recipients: to.length + cc.length, subject: inputs.subject };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* OpenAI: otazka nad nahranym souborem.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: POST /responses
|
||||
*
|
||||
* Proc jiny endpoint nez `openai.chat`: soubor jako vstup umi az Responses API.
|
||||
* Chat Completions by prijalo jen text, takze by se obsah musel do dotazu
|
||||
* vlepit rucne - a u PDF nebo tabulky to nejde.
|
||||
*
|
||||
* Soubor se nejdriv nahraje krokem `openai.upload-file`, ktery vrati `fileId`.
|
||||
* Rozdeleni na dva kroky je zamer: jednoho souboru se casto pta vic dotazu
|
||||
* a nahravat ho pokazde znovu by stalo cas i penize.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.ask-about-file',
|
||||
name: 'Zeptat se na soubor',
|
||||
description:
|
||||
'Položí otázku nad souborem nahraným do OpenAI. Zvládne dokument i obrázek, ' +
|
||||
'takže se hodí na vytěžení faktury, smlouvy nebo fotky.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileId',
|
||||
label: 'ID souboru',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Vrací ho krok Nahrát soubor jako fileId.',
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
label: 'Druh souboru',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'document',
|
||||
options: [
|
||||
{ value: 'document', label: 'Dokument (PDF, text, tabulka)' },
|
||||
{ value: 'image', label: 'Obrázek' },
|
||||
],
|
||||
hint:
|
||||
'Obrázek se modelu předává jinak než dokument. Musí být nahraný ' +
|
||||
's účelem Obrázek k analýze.',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
label: 'Otázka',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Například: Vypiš číslo faktury, datum splatnosti a celkovou částku.',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'gpt-4o-mini',
|
||||
hint: 'Musí to být model, který umí číst soubory a obrázky.',
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
label: 'Instrukce pro model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
id: 'maxTokens',
|
||||
label: 'Strop na délku odpovědi',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'V tokenech. Bez vyplnění rozhoduje model.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Odpověď', type: 'string', required: true },
|
||||
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
|
||||
{ id: 'inputTokens', label: 'Tokeny na vstupu', type: 'number', required: false },
|
||||
{ id: 'outputTokens', label: 'Tokeny na výstupu', type: 'number', required: false },
|
||||
{ id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false },
|
||||
],
|
||||
|
||||
// Cteni dokumentu trva dele nez bezny dotaz.
|
||||
timeoutMs: 90000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Odpoved Responses API je strom, ne jedno pole.
|
||||
*
|
||||
* Prochazi se cely: model muze pred odpovedi vratit i jine polozky (napriklad
|
||||
* zaznam o uvaze) a brat naslepo prvni prvek by u nich vratilo prazdno.
|
||||
*/
|
||||
function answerText(output) {
|
||||
if (!Array.isArray(output)) return null;
|
||||
const parts = [];
|
||||
|
||||
for (const item of output) {
|
||||
const content = item && item.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const part of content) {
|
||||
if (part && part.type === 'output_text' && typeof part.text === 'string') {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
const attachment =
|
||||
inputs.kind === 'image'
|
||||
? { type: 'input_image', file_id: inputs.fileId }
|
||||
: { type: 'input_file', file_id: inputs.fileId };
|
||||
|
||||
const body = {
|
||||
model: inputs.model,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [attachment, { type: 'input_text', text: inputs.prompt }],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (inputs.instructions) body.instructions = inputs.instructions;
|
||||
if (inputs.maxTokens !== null) body.max_output_tokens = inputs.maxTokens;
|
||||
|
||||
const { body: answer } = await ctx.http.post('/responses', body);
|
||||
|
||||
if (text(pick(answer, 'status')) === 'incomplete') {
|
||||
ctx.log('Odpověď je neúplná, model narazil na strop délky.');
|
||||
}
|
||||
|
||||
const usage = pick(answer, 'usage') ?? {};
|
||||
|
||||
return {
|
||||
text: need(answerText(pick(answer, 'output')), 'odpověď modelu'),
|
||||
model: need(text(pick(answer, 'model')), 'název modelu'),
|
||||
inputTokens: num(pick(usage, 'input_tokens')),
|
||||
outputTokens: num(pick(usage, 'output_tokens')),
|
||||
totalTokens: num(pick(usage, 'total_tokens')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* OpenAI: dotaz na jazykovy model.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1 (nebezi u nas, adresa je v katalogu sluzeb)
|
||||
* Endpoint: POST /chat/completions
|
||||
*
|
||||
* Model je **volny text s vychozi hodnotou**, ne vyber z pevneho seznamu.
|
||||
* Seznam by zestarl pri kazdem vydani noveho modelu a krok stromu by pak
|
||||
* odmital hodnotu, kterou ucet umi. Co ucet umi, vrati `openai.list-models`.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.chat',
|
||||
name: 'Zeptat se modelu',
|
||||
description:
|
||||
'Pošle otázku vybranému modelu OpenAI a vrátí odpověď včetně spotřeby tokenů. ' +
|
||||
'Hodí se na shrnutí, klasifikaci i sepsání odpovědi zákazníkovi.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'gpt-4o-mini',
|
||||
hint: 'Například gpt-4o-mini, gpt-4o nebo gpt-4.1. Seznam vrátí operace Načíst seznam modelů.',
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
label: 'Instrukce pro model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Role a pravidla, například: Jsi zákaznická podpora, odpovídej česky a stručně.',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
label: 'Otázka',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Text dotazu. Může obsahovat data z předchozích kroků.',
|
||||
},
|
||||
{
|
||||
id: 'temperature',
|
||||
label: 'Teplota',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint:
|
||||
'Od 0 do 2. Nižší je předvídatelnější. Nevyplněné se neposílá vůbec, ' +
|
||||
'protože novější modely teplotu nepřijímají.',
|
||||
},
|
||||
{
|
||||
id: 'maxTokens',
|
||||
label: 'Strop na délku odpovědi',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'V tokenech. Bez vyplnění rozhoduje model.',
|
||||
},
|
||||
{
|
||||
id: 'jsonOutput',
|
||||
label: 'Vyžádat odpověď v JSONu',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Model pak vrátí platný JSON. V instrukcích popište, jaká pole má mít.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Odpověď', type: 'string', required: true },
|
||||
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
|
||||
{ id: 'finishReason', label: 'Důvod ukončení', type: 'string', required: false },
|
||||
{ id: 'promptTokens', label: 'Tokeny na vstupu', type: 'number', required: false },
|
||||
{ id: 'completionTokens', label: 'Tokeny na výstupu', type: 'number', required: false },
|
||||
{ id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false },
|
||||
],
|
||||
|
||||
// Delsi odpoved trva. Vychozich 15 s je na generovani textu malo.
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
const messages = [];
|
||||
if (inputs.instructions) messages.push({ role: 'system', content: inputs.instructions });
|
||||
messages.push({ role: 'user', content: inputs.prompt });
|
||||
|
||||
const body = { model: inputs.model, messages };
|
||||
// Nevyplnene se neposila. Novejsi modely `temperature` odmitaji uplne,
|
||||
// takze poslat vychozi hodnotu by krok rozbilo tam, kde o ni nikdo nestal.
|
||||
if (inputs.temperature !== null) body.temperature = inputs.temperature;
|
||||
if (inputs.maxTokens !== null) body.max_completion_tokens = inputs.maxTokens;
|
||||
if (inputs.jsonOutput) body.response_format = { type: 'json_object' };
|
||||
|
||||
const { body: answer } = await ctx.http.post('/chat/completions', body);
|
||||
|
||||
const choice = ctx.util.first(pick(answer, 'choices'));
|
||||
if (!choice) ctx.fail('OpenAI nevrátilo žádnou odpověď.', answer);
|
||||
|
||||
const usage = pick(answer, 'usage') ?? {};
|
||||
|
||||
return {
|
||||
text: need(text(pick(pick(choice, 'message'), 'content')), 'odpověď modelu'),
|
||||
model: need(text(pick(answer, 'model')), 'název modelu'),
|
||||
finishReason: text(pick(choice, 'finish_reason')),
|
||||
promptTokens: num(pick(usage, 'prompt_tokens')),
|
||||
completionTokens: num(pick(usage, 'completion_tokens')),
|
||||
totalTokens: num(pick(usage, 'total_tokens')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* OpenAI: seznam modelu, na ktere ucet dosahne.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: GET /models
|
||||
*
|
||||
* Proc to tu je: v kroku `openai.chat` je model volny text, aby seznam
|
||||
* v manifestu nezestarl. Tohle je zpusob, jak zjistit, co do nej napsat,
|
||||
* aniz by clovek odchazel z portalu.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.list-models',
|
||||
name: 'Načíst seznam modelů',
|
||||
description:
|
||||
'Vrátí modely, na které API klíč dosáhne. Nic nemění a nic nestojí. ' +
|
||||
'Použije se ke zjištění, co napsat do pole Model.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'contains',
|
||||
label: 'Obsahuje text',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Zúží seznam, například gpt nebo whisper. Bez vyplnění se vrátí vše.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet modelů', type: 'number', required: true },
|
||||
{ id: 'models', label: 'Seznam modelů', type: 'list', required: true },
|
||||
{ id: 'first', label: 'První model', type: 'string', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text } = ctx.util;
|
||||
|
||||
const { body } = await ctx.http.get('/models');
|
||||
const items = pick(body, 'data');
|
||||
if (!Array.isArray(items)) ctx.fail('OpenAI nevrátilo seznam modelů.', body);
|
||||
|
||||
const needle = (inputs.contains ?? '').toLowerCase();
|
||||
const models = items
|
||||
.map((item) => text(pick(item, 'id')))
|
||||
.filter((id) => id !== null && (needle === '' || id.toLowerCase().includes(needle)))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return { count: models.length, models, first: models[0] ?? null };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* OpenAI: prepis nahravky na text.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: POST /audio/transcriptions (multipart/form-data)
|
||||
*
|
||||
* Rozdil proti sluzbe Prepis hovoru: tam bezi dva enginy naraz a jejich
|
||||
* vysledky se slucuji, coz je presnejsi a drazsi. Tohle je jeden pruchod
|
||||
* pod vlastnim klicem, kdyz staci obycejny prepis.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.transcribe-audio',
|
||||
name: 'Přepsat zvuk',
|
||||
description: 'Přepíše nahrávku na text jedním přepisovacím modelem OpenAI.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileName',
|
||||
label: 'Název nahrávky',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Včetně přípony, například hovor.mp3. Podle ní se pozná formát.',
|
||||
},
|
||||
{
|
||||
id: 'contentBase64',
|
||||
label: 'Nahrávka (Base64)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Obsah zakódovaný v Base64. Formáty mp3, mp4, m4a, wav, webm, ogg a flac.',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'gpt-4o-mini-transcribe',
|
||||
hint: 'Například gpt-4o-mini-transcribe, gpt-4o-transcribe nebo whisper-1.',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Jazyk nahrávky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[a-zA-Z]{2}$',
|
||||
hint: 'Dvoupísmenný kód, například cs. Vyplněný jazyk přepis zpřesní i zrychlí.',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
label: 'Nápověda k přepisu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Vlastní jména, zkratky a názvy produktů, které se v nahrávce objeví.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Přepis', type: 'string', required: true },
|
||||
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
|
||||
{ id: 'length', label: 'Délka přepisu ve znacích', type: 'number', required: true },
|
||||
],
|
||||
|
||||
// Prepis je nejpomalejsi operace v katalogu, delsi zaznam trva minuty.
|
||||
timeoutMs: 120000,
|
||||
};
|
||||
|
||||
/** Typ obsahu podle pripony. Zaroven seznam toho, co OpenAI prijme. */
|
||||
const byExtension = {
|
||||
mp3: 'audio/mpeg',
|
||||
mpga: 'audio/mpeg',
|
||||
mpeg: 'audio/mpeg',
|
||||
mp4: 'audio/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const parts = inputs.fileName.split('.');
|
||||
const extension = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||
const contentType = byExtension[extension];
|
||||
if (!contentType) {
|
||||
ctx.fail(
|
||||
`Přípona "${extension}" není podporovaný formát zvuku. ` +
|
||||
'Použijte mp3, mp4, m4a, wav, webm, ogg nebo flac.',
|
||||
);
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.postForm('/audio/transcriptions', {
|
||||
model: inputs.model,
|
||||
language: inputs.language,
|
||||
prompt: inputs.prompt,
|
||||
response_format: 'json',
|
||||
file: {
|
||||
filename: inputs.fileName,
|
||||
base64: inputs.contentBase64.trim(),
|
||||
contentType,
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = need(text(pick(body, 'text')), 'přepis');
|
||||
|
||||
return { text: transcript, model: inputs.model, length: transcript.length };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* OpenAI: nahrani souboru.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: POST /files (multipart/form-data)
|
||||
*
|
||||
* Vzor **odeslani souboru**. Obsah prichazi jako Base64, protoze parametr kroku
|
||||
* je vzdy hodnota zapsatelna do JSONu - binarni data by se do stromu ani do
|
||||
* zaznamu behu jinak nevesla. Prevod na bajty a slozeni multipart tela dela
|
||||
* `ctx.http.postForm`, skript o hranicich ani o hlavickach nevi.
|
||||
*
|
||||
* Vraci ID souboru. Otazku nad nim polozi `openai.ask-about-file`.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.upload-file',
|
||||
name: 'Nahrát soubor',
|
||||
description:
|
||||
'Odešle soubor do OpenAI a vrátí jeho ID. To se pak použije jako vstup ' +
|
||||
'operace Zeptat se na soubor.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileName',
|
||||
label: 'Název souboru',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Včetně přípony, například smlouva.pdf. Podle ní se pozná typ obsahu.',
|
||||
},
|
||||
{
|
||||
id: 'contentBase64',
|
||||
label: 'Obsah souboru (Base64)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Obsah zakódovaný v Base64. Bez předpony data:.',
|
||||
},
|
||||
{
|
||||
id: 'contentType',
|
||||
label: 'Typ obsahu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Například application/pdf. Bez vyplnění se odvodí z přípony.',
|
||||
},
|
||||
{
|
||||
id: 'purpose',
|
||||
label: 'K čemu soubor je',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'user_data',
|
||||
options: [
|
||||
{ value: 'user_data', label: 'Podklad k dotazu' },
|
||||
{ value: 'assistants', label: 'Asistenti a vyhledávání v souborech' },
|
||||
{ value: 'vision', label: 'Obrázek k analýze' },
|
||||
{ value: 'batch', label: 'Dávkové zpracování' },
|
||||
],
|
||||
hint: 'OpenAI podle toho rozhoduje, kde se soubor smí použít.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'fileId', label: 'ID souboru', type: 'string', required: true },
|
||||
{ id: 'fileName', label: 'Název souboru', type: 'string', required: true },
|
||||
{ id: 'bytes', label: 'Velikost v bajtech', type: 'number', required: false },
|
||||
{ id: 'purpose', label: 'K čemu soubor je', type: 'string', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
/** Typ obsahu podle pripony. Jen to, co se v automatizaci opravdu posila. */
|
||||
const byExtension = {
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
html: 'text/html',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
const extension = inputs.fileName.split('.').pop()?.toLowerCase() ?? '';
|
||||
const contentType = inputs.contentType ?? byExtension[extension] ?? 'application/octet-stream';
|
||||
|
||||
// Prazdny obsah se pozna tady, ne az podle nicnerikajici chyby od OpenAI.
|
||||
if (inputs.contentBase64.trim() === '') ctx.fail('Obsah souboru je prázdný.');
|
||||
|
||||
const { body } = await ctx.http.postForm('/files', {
|
||||
purpose: inputs.purpose,
|
||||
file: {
|
||||
filename: inputs.fileName,
|
||||
base64: inputs.contentBase64.trim(),
|
||||
contentType,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.log(`Nahráno ${inputs.fileName} jako ${contentType}.`);
|
||||
|
||||
return {
|
||||
fileId: need(text(pick(body, 'id')), 'ID souboru'),
|
||||
fileName: need(text(pick(body, 'filename')) ?? inputs.fileName, 'název souboru'),
|
||||
bytes: num(pick(body, 'bytes')),
|
||||
purpose: text(pick(body, 'purpose')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* PPL CPL: zalozeni zasilky.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/pplcplapi
|
||||
* Endpoint: POST /shipments/create-and-wait
|
||||
*
|
||||
* CPL zpracovava zasilky **davkove**: zalozi se batch, pak se ceka na jeho
|
||||
* zpracovani a teprve pak je znamé cislo zasilky. Sluzba na to ma synchronni
|
||||
* obalku, ktera cely tok provede za nas - proto se vola ona a ne
|
||||
* `/shipments/batch`. Krok stromu, ktery by musel sam pollovat, by potreboval
|
||||
* runtime s cekanim, a ten zatim neni.
|
||||
*
|
||||
* Idempotence: hlavicku `Idempotency-Key` doplnuje runtime a je stabilni pres
|
||||
* vsechny pokusy tehoz kroku, takze druhy pokus po timeoutu neposle druhy balik.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'ppl.create-shipment',
|
||||
name: 'Vytvořit zásilku',
|
||||
description:
|
||||
'Založí zásilku v PPL a počká na její zpracování. Vrací číslo zásilky ' +
|
||||
'a volitelně i etiketu k tisku.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'referenceId',
|
||||
label: 'Reference',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Vaše číslo, podle kterého zásilku poznáte. Typicky číslo objednávky.',
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
label: 'Produkt',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'BUSS',
|
||||
hint: 'Kód produktu PPL, například BUSS (Business) nebo PRIV (Private). Číselník vrací /codelists/product.',
|
||||
},
|
||||
{ id: 'recipientName', label: 'Příjemce', type: 'string', required: true },
|
||||
{ id: 'recipientStreet', label: 'Ulice a číslo', type: 'string', required: true },
|
||||
{ id: 'recipientCity', label: 'Město', type: 'string', required: true },
|
||||
{
|
||||
id: 'recipientZip',
|
||||
label: 'PSČ',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Bez mezer, například 60200.',
|
||||
},
|
||||
{
|
||||
id: 'recipientCountry',
|
||||
label: 'Země',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'CZ',
|
||||
hint: 'Dvoupísmenný kód země.',
|
||||
},
|
||||
{ id: 'recipientPhone', label: 'Telefon příjemce', type: 'string', required: false },
|
||||
{ id: 'recipientEmail', label: 'E-mail příjemce', type: 'string', required: false },
|
||||
{
|
||||
id: 'note',
|
||||
label: 'Poznámka',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Text pro kurýra. Diakritiku odstraní služba sama, CPL ji nepřijímá.',
|
||||
},
|
||||
{
|
||||
id: 'withLabel',
|
||||
label: 'Vrátit i etiketu',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Etiketa je binární PDF, takže se vrací jen odkaz na ni, ne obsah.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'shipmentNumber', label: 'Číslo zásilky', type: 'string', required: false },
|
||||
{ id: 'batchId', label: 'ID dávky', type: 'string', required: false },
|
||||
{ id: 'completed', label: 'Zpracováno', type: 'boolean', required: true },
|
||||
{ id: 'result', label: 'Celá odpověď PPL', type: 'object', required: true },
|
||||
],
|
||||
|
||||
// Sluzba uvnitr ceka na zpracovani davky. Kratsi limit by utnul i uspech.
|
||||
timeoutMs: 90000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Cislo zasilky z odpovedi.
|
||||
*
|
||||
* CPL ho vraci na ruznych mistech podle toho, kolik zasilek davka mela.
|
||||
* Hledani do hloubky je levnejsi nez tri varianty `pick` za sebou, ktere by
|
||||
* stejne nepokryly dalsi tvar.
|
||||
*/
|
||||
function findShipmentNumber(value, depth = 0) {
|
||||
if (value === null || typeof value !== 'object' || depth > 5) return null;
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (/^shipmentnumber$/i.test(key) && typeof item === 'string' && item !== '') return item;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of Array.isArray(value) ? value : Object.values(value)) {
|
||||
const found = findShipmentNumber(item, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, bool } = ctx.util;
|
||||
|
||||
const recipient = {
|
||||
name: inputs.recipientName,
|
||||
street: inputs.recipientStreet,
|
||||
city: inputs.recipientCity,
|
||||
zipCode: inputs.recipientZip.replace(/\s+/g, ''),
|
||||
country: inputs.recipientCountry,
|
||||
};
|
||||
if (inputs.recipientPhone) recipient.phone = inputs.recipientPhone;
|
||||
if (inputs.recipientEmail) recipient.email = inputs.recipientEmail;
|
||||
|
||||
const shipment = {
|
||||
referenceId: inputs.referenceId,
|
||||
productType: inputs.productType,
|
||||
recipient,
|
||||
};
|
||||
if (inputs.note) shipment.note = inputs.note;
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
'/shipments/create-and-wait',
|
||||
{ shipments: [shipment] },
|
||||
{ query: { include_labels: inputs.withLabel } },
|
||||
);
|
||||
|
||||
const completed = bool(pick(body, 'completed'));
|
||||
const shipmentNumber = findShipmentNumber(body);
|
||||
|
||||
if (!completed) {
|
||||
// Neni to chyba: davka bezi dal a stav se da dotahnout pres batchId.
|
||||
// Vyhodit chybu by znamenalo, ze uz zalozena zasilka vypadne ze stromu.
|
||||
ctx.log('PPL dávku ještě nedokončil, stav zjistíte podle ID dávky.');
|
||||
}
|
||||
|
||||
return {
|
||||
shipmentNumber,
|
||||
batchId: text(pick(body, 'batchId', 'batch_id')),
|
||||
completed,
|
||||
result: body,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* PPL CPL: stav zasilky.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/pplcplapi
|
||||
* Endpoint: GET /shipments?shipmentNumbers=...
|
||||
*
|
||||
* Vzor **cteciho kroku, u ktereho nenalezeno neni chyba**. Zasilka, kterou PPL
|
||||
* jeste nenacetl, je bezny stav prvnich minut po zalozeni, ne selhani.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'ppl.track',
|
||||
name: 'Zjistit stav zásilky',
|
||||
description: 'Vrátí aktuální stav zásilky a historii jejích událostí. Nic nemění.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'shipmentNumber',
|
||||
label: 'Číslo zásilky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Vytvořit zásilku. Vyplňte tohle, nebo referenci.',
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
label: 'Reference',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Vaše číslo zadané při zakládání, typicky číslo objednávky.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'found', label: 'Zásilka nalezena', type: 'boolean', required: true },
|
||||
{ id: 'shipmentNumber', label: 'Číslo zásilky', type: 'string', required: false },
|
||||
{ id: 'state', label: 'Stav', type: 'string', required: false },
|
||||
{ id: 'stateDate', label: 'Datum stavu', type: 'string', required: false },
|
||||
{ id: 'delivered', label: 'Doručeno', type: 'boolean', required: true },
|
||||
{ id: 'shipment', label: 'Celý záznam zásilky', type: 'object', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
const notFound = {
|
||||
found: false,
|
||||
shipmentNumber: null,
|
||||
state: null,
|
||||
stateDate: null,
|
||||
delivered: false,
|
||||
shipment: null,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, first, text } = ctx.util;
|
||||
|
||||
if (!inputs.shipmentNumber && !inputs.reference) {
|
||||
ctx.fail('Vyplňte číslo zásilky nebo referenci, jinak není co hledat.');
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.get('/shipments', {
|
||||
query: {
|
||||
shipmentNumbers: inputs.shipmentNumber ?? undefined,
|
||||
customerReferences: inputs.reference ?? undefined,
|
||||
limit: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const items = Array.isArray(body) ? body : pick(body, 'shipments', 'data', 'items');
|
||||
const shipment = first(Array.isArray(items) ? items : []);
|
||||
if (!shipment) {
|
||||
ctx.log('PPL o zásilce zatím nic neví. Krátce po založení je to běžné.');
|
||||
return notFound;
|
||||
}
|
||||
|
||||
// Posledni udalost je aktualni stav. PPL vraci historii vzestupne.
|
||||
const events = pick(shipment, 'shipmentEvents', 'events', 'states');
|
||||
const last = Array.isArray(events) && events.length > 0 ? events[events.length - 1] : null;
|
||||
|
||||
const state = text(
|
||||
pick(last, 'code', 'name', 'statusCode') ?? pick(shipment, 'statusCode', 'state'),
|
||||
);
|
||||
const stateDate = text(pick(last, 'date', 'eventDate') ?? pick(shipment, 'statusDate'));
|
||||
|
||||
return {
|
||||
found: true,
|
||||
shipmentNumber: text(pick(shipment, 'shipmentNumber')),
|
||||
state,
|
||||
stateDate,
|
||||
// Kod doruceno je u PPL DELIVERED. Porovnava se bez ohledu na velikost pismen.
|
||||
delivered: (state ?? '').toUpperCase().includes('DELIVER'),
|
||||
shipment,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* RAYNET CRM: zalozeni poptavky (lead).
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/raynet
|
||||
* Endpoint: POST /lead
|
||||
*
|
||||
* Navazuje na `raynet.find-company`: kdyz firma existuje, poptavka se na ni
|
||||
* naveze, jinak vznikne bez vazby a doplni ji obchodnik. Zakladat firmu tady
|
||||
* zamerne neumime - to je rozhodnuti, ktere ma udelat clovek.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'raynet.create-lead',
|
||||
name: 'Založit poptávku',
|
||||
description:
|
||||
'Vytvoří v RAYNETu novou poptávku. Volitelně ji naváže na už dohledanou firmu ' +
|
||||
'a kontaktní osobu.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'topic',
|
||||
label: 'Předmět poptávky',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'To, co obchodník uvidí v seznamu. Například: Poptávka z webu, {{subject}}.',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
label: 'Popis',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Celý text poptávky, typicky obsah e-mailu nebo formuláře.',
|
||||
},
|
||||
{
|
||||
id: 'companyId',
|
||||
label: 'ID firmy',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Dohledat firmu.',
|
||||
},
|
||||
{
|
||||
id: 'personId',
|
||||
label: 'ID kontaktní osoby',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Založit nebo aktualizovat kontakt.',
|
||||
},
|
||||
{
|
||||
id: 'ownerId',
|
||||
label: 'ID vlastníka',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Uživatel RAYNETu, kterému poptávka připadne. Bez něj rozhodne RAYNET.',
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priorita',
|
||||
type: 'string',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'LOW', label: 'Nízká' },
|
||||
{ value: 'NORMAL', label: 'Normální' },
|
||||
{ value: 'HIGH', label: 'Vysoká' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'leadId', label: 'ID poptávky', type: 'number', required: true },
|
||||
{ id: 'topic', label: 'Předmět poptávky', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, num, need } = ctx.util;
|
||||
|
||||
const payload = { topic: inputs.topic };
|
||||
if (inputs.description) payload.description = inputs.description;
|
||||
if (inputs.companyId !== null) payload.company = { id: inputs.companyId };
|
||||
if (inputs.personId !== null) payload.person = { id: inputs.personId };
|
||||
if (inputs.ownerId !== null) payload.owner = { id: inputs.ownerId };
|
||||
if (inputs.priority) payload.priority = inputs.priority;
|
||||
|
||||
const { body } = await ctx.http.post('/lead', payload);
|
||||
const id = need(num(pick(unwrap(body), 'id')), 'ID poptávky');
|
||||
|
||||
ctx.log(`Založena poptávka ${id}.`);
|
||||
|
||||
return { leadId: id, topic: inputs.topic };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* RAYNET CRM: dohledani firmy.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/raynet
|
||||
* Endpoint: GET /company?fulltext=...
|
||||
*
|
||||
* Vzor **predvalidace**: nenalezeno tady neni chyba. Prave podle toho se strom
|
||||
* vetvi na "znameho zakaznika" a "noveho zajemce", takze chyba by rozhodnuti
|
||||
* zahodila misto aby ho umoznila.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'raynet.find-company',
|
||||
name: 'Dohledat firmu',
|
||||
description:
|
||||
'Zjistí, jestli je odesílatel v CRM. Hledá podle IČO, e-mailu nebo názvu. ' +
|
||||
'Nic nezakládá, výsledek slouží k větvení stromu.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'regNumber',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[0-9]{6,12}$',
|
||||
hint: 'Jen číslice. Nejpřesnější, hledá se podle něj první.',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Například {{from}} u e-mailu.',
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Název firmy',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Použije se jako poslední, název se píše pokaždé jinak.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'customerKnown', label: 'Firma nalezena', type: 'boolean', required: true },
|
||||
{ id: 'companyId', label: 'ID firmy', type: 'number', required: false },
|
||||
{ id: 'companyName', label: 'Název firmy', type: 'string', required: false },
|
||||
{ id: 'regNumber', label: 'IČO', type: 'string', required: false },
|
||||
{ id: 'ownerName', label: 'Vlastník záznamu', type: 'string', required: false },
|
||||
{ id: 'matchedBy', label: 'Podle čeho se našla', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
const notFound = {
|
||||
customerKnown: false,
|
||||
companyId: null,
|
||||
companyName: null,
|
||||
regNumber: null,
|
||||
ownerName: null,
|
||||
matchedBy: 'nenalezeno',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, first, text, num } = ctx.util;
|
||||
|
||||
if (!inputs.regNumber && !inputs.email && !inputs.name) {
|
||||
ctx.fail('Vyplňte IČO, e-mail nebo název firmy, jinak není podle čeho hledat.');
|
||||
}
|
||||
|
||||
/** Jedno hledani. RAYNET ma na vsechno jeden fulltext, filtr podle pole nema. */
|
||||
async function search(value) {
|
||||
const { body } = await ctx.http.get('/company', { query: { fulltext: value, limit: 5 } });
|
||||
const items = unwrap(body);
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny
|
||||
// a nazev se v kazdem systemu pise jinak.
|
||||
const attempts = [];
|
||||
if (inputs.regNumber) attempts.push([inputs.regNumber, 'IČO']);
|
||||
if (inputs.email) attempts.push([inputs.email, 'e-mail']);
|
||||
if (inputs.name) attempts.push([inputs.name, 'název']);
|
||||
|
||||
for (const [value, label] of attempts) {
|
||||
const items = await search(value);
|
||||
const company = first(items);
|
||||
if (!company) {
|
||||
ctx.log(`Podle ${label} se nic nenašlo.`);
|
||||
continue;
|
||||
}
|
||||
if (items.length > 1) {
|
||||
ctx.log(`Podle ${label} vyšlo ${items.length} záznamů, bere se první.`);
|
||||
}
|
||||
|
||||
return {
|
||||
customerKnown: true,
|
||||
companyId: num(pick(company, 'id')),
|
||||
companyName: text(pick(company, 'name', 'companyName')),
|
||||
regNumber: text(pick(company, 'regNumber', 'identificationNumber')),
|
||||
ownerName: text(pick(pick(company, 'owner'), 'fullName', 'name')),
|
||||
matchedBy: label,
|
||||
};
|
||||
}
|
||||
|
||||
return notFound;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* RAYNET CRM: zalozeni nebo doplneni kontaktni osoby.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/raynet
|
||||
* Endpointy: GET /person?fulltext=..., POST /person, PUT /person/{id}
|
||||
*
|
||||
* Vzor **upsert**: nejdriv se hleda, pak se rozhoduje. Zakladat naslepo by
|
||||
* po tydnu znamenalo tri kontakty s tymz e-mailem a obchodnik by nevedel,
|
||||
* do ktereho psat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'raynet.upsert-contact',
|
||||
name: 'Založit nebo aktualizovat kontakt',
|
||||
description:
|
||||
'Podle e-mailu kontakt najde a doplní chybějící údaje, jinak založí nový. ' +
|
||||
'Vrací ID kontaktu a to, jestli teprve vznikl.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Podle něj se pozná, jestli kontakt už existuje.',
|
||||
},
|
||||
{ id: 'firstName', label: 'Jméno', type: 'string', required: false },
|
||||
{
|
||||
id: 'lastName',
|
||||
label: 'Příjmení',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Bez příjmení se u nového kontaktu dosadí část e-mailu před zavináčem.',
|
||||
},
|
||||
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
|
||||
{
|
||||
id: 'companyId',
|
||||
label: 'ID firmy',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Dohledat firmu. Bez něj kontakt zůstane bez vazby.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
|
||||
{ id: 'created', label: 'Kontakt teprve vznikl', type: 'boolean', required: true },
|
||||
{ id: 'fullName', label: 'Jméno kontaktu', type: 'string', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, first, text, num, need } = ctx.util;
|
||||
|
||||
const { body: found } = await ctx.http.get('/person', {
|
||||
query: { fulltext: inputs.email, limit: 5 },
|
||||
});
|
||||
const existing = first(unwrap(found));
|
||||
|
||||
const contactInfo = {};
|
||||
if (inputs.email) contactInfo.email = inputs.email;
|
||||
if (inputs.phone) contactInfo.tel1 = inputs.phone;
|
||||
|
||||
if (existing) {
|
||||
const id = need(num(pick(existing, 'id')), 'ID kontaktu');
|
||||
|
||||
// Prepisuji se jen vyplnena pole. Prazdna hodnota v kroku stromu znamena
|
||||
// "nevim", ne "smaz" - jinak by jeden beh bez telefonu telefon vymazal.
|
||||
const patch = {};
|
||||
if (inputs.firstName) patch.firstName = inputs.firstName;
|
||||
if (inputs.lastName) patch.lastName = inputs.lastName;
|
||||
if (Object.keys(contactInfo).length > 0) patch.contactInfo = contactInfo;
|
||||
if (inputs.companyId !== null) patch.company = { id: inputs.companyId };
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.http.put(`/person/${id}`, patch);
|
||||
ctx.log(`Kontakt ${id} doplněn.`);
|
||||
} else {
|
||||
ctx.log(`Kontakt ${id} už má vše, nic se nemění.`);
|
||||
}
|
||||
|
||||
return {
|
||||
contactId: id,
|
||||
created: false,
|
||||
fullName: text(pick(existing, 'fullName', 'lastName')),
|
||||
};
|
||||
}
|
||||
|
||||
// Prijmeni je v RAYNETu povinne. Kdyz nedoslo, vezme se cast e-mailu -
|
||||
// kontakt bez jmena se v seznamu neda najit a nikdo ho uz neopravi.
|
||||
const lastName = inputs.lastName ?? inputs.email.split('@')[0];
|
||||
|
||||
const { body: created } = await ctx.http.post('/person', {
|
||||
...(inputs.firstName ? { firstName: inputs.firstName } : {}),
|
||||
lastName,
|
||||
contactInfo,
|
||||
...(inputs.companyId !== null ? { company: { id: inputs.companyId } } : {}),
|
||||
});
|
||||
|
||||
const id = need(num(pick(unwrap(created), 'id')), 'ID nového kontaktu');
|
||||
ctx.log(`Založen kontakt ${id}.`);
|
||||
|
||||
return {
|
||||
contactId: id,
|
||||
created: true,
|
||||
fullName: [inputs.firstName, lastName].filter(Boolean).join(' '),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SAP Business One: dohledani obchodniho partnera.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/sap-bo
|
||||
* Endpoint: GET /api/business-partners?$filter=...
|
||||
*
|
||||
* Vzor **predvalidace nad OData**. Service Layer filtruje jazykem OData, kde
|
||||
* se retezce uvozuji apostrofem - a apostrof v nazvu firmy by dotaz rozbil,
|
||||
* takze se zdvojuje. Nenalezeno neni chyba: podle toho se strom vetvi na
|
||||
* zalozeni noveho partnera.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'sap-bo.find-business-partner',
|
||||
name: 'Najít obchodního partnera',
|
||||
description:
|
||||
'Dohledá partnera v SAP Business One podle kódu, IČO nebo názvu. ' +
|
||||
'Nic nezakládá, výsledek slouží k větvení stromu.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'cardCode',
|
||||
label: 'Kód partnera',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Například C00123. Nejpřesnější, hledá se podle něj první.',
|
||||
},
|
||||
{
|
||||
id: 'taxId',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Porovnává se s polem FederalTaxID.',
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Název',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Stačí část názvu, hledá se podřetězec.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'found', label: 'Partner nalezen', type: 'boolean', required: true },
|
||||
{ id: 'cardCode', label: 'Kód partnera', type: 'string', required: false },
|
||||
{ id: 'cardName', label: 'Název partnera', type: 'string', required: false },
|
||||
{ id: 'taxId', label: 'IČO', type: 'string', required: false },
|
||||
{ id: 'currency', label: 'Měna', type: 'string', required: false },
|
||||
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
const notFound = {
|
||||
found: false,
|
||||
cardCode: null,
|
||||
cardName: null,
|
||||
taxId: null,
|
||||
currency: null,
|
||||
matchedBy: 'nenalezeno',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text } = ctx.util;
|
||||
|
||||
if (!inputs.cardCode && !inputs.taxId && !inputs.name) {
|
||||
ctx.fail('Vyplňte kód partnera, IČO nebo název, jinak není podle čeho hledat.');
|
||||
}
|
||||
|
||||
// Poradi je zamer: kod je klic, ICO je jednoznacne, nazev se pise pokazde jinak.
|
||||
const attempts = [];
|
||||
if (inputs.cardCode) attempts.push([`CardCode eq ${ctx.util.quote(inputs.cardCode)}`, 'kód']);
|
||||
if (inputs.taxId) attempts.push([`FederalTaxID eq ${ctx.util.quote(inputs.taxId)}`, 'IČO']);
|
||||
if (inputs.name) attempts.push([`contains(CardName,${ctx.util.quote(inputs.name)})`, 'název']);
|
||||
|
||||
for (const [filter, label] of attempts) {
|
||||
const { body } = await ctx.http.get('/api/business-partners', {
|
||||
query: {
|
||||
$filter: filter,
|
||||
$select: 'CardCode,CardName,FederalTaxID,Currency,CardType',
|
||||
$top: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const raw = pick(body, 'value', 'data', 'items');
|
||||
const items = Array.isArray(raw) ? raw : Array.isArray(body) ? body : [];
|
||||
if (items.length === 0) {
|
||||
ctx.log(`Podle ${label} se nic nenašlo.`);
|
||||
continue;
|
||||
}
|
||||
if (items.length > 1) ctx.log(`Podle ${label} vyšlo ${items.length} partnerů, bere se první.`);
|
||||
|
||||
const partner = items[0];
|
||||
return {
|
||||
found: true,
|
||||
cardCode: text(pick(partner, 'CardCode')),
|
||||
cardName: text(pick(partner, 'CardName')),
|
||||
taxId: text(pick(partner, 'FederalTaxID')),
|
||||
currency: text(pick(partner, 'Currency')),
|
||||
matchedBy: label,
|
||||
};
|
||||
}
|
||||
|
||||
return notFound;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* SAP Business One: nacteni prodejnich objednavek.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/sap-bo
|
||||
* Endpoint: GET /api/orders?$filter=...
|
||||
*
|
||||
* Filtr se sklada z toho, co je vyplnene. Poslat prazdny filtr by znamenalo
|
||||
* stahnout cely archiv objednavek - u SAPu jsou to desetitisice radku, ktere
|
||||
* by krok stromu stejne neunesl.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'sap-bo.list-orders',
|
||||
name: 'Načíst objednávky',
|
||||
description: 'Vrátí prodejní objednávky partnera nebo za období. Nic nemění, jen čte.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'cardCode',
|
||||
label: 'Kód partnera',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Najít obchodního partnera.',
|
||||
},
|
||||
{ id: 'dateFrom', label: 'Od data', type: 'date', required: false },
|
||||
{ id: 'dateTo', label: 'Do data', type: 'date', required: false },
|
||||
{
|
||||
id: 'openOnly',
|
||||
label: 'Jen otevřené',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Vypnuté vrátí i uzavřené a stornované objednávky.',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik objednávek',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet objednávek', type: 'number', required: true },
|
||||
{ id: 'orders', label: 'Objednávky', type: 'list', required: true },
|
||||
{ id: 'total', label: 'Součet částek', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, text, round, quote } = ctx.util;
|
||||
|
||||
// Apostrof v OData filtru se zdvojuje, to dela `quote`.
|
||||
const conditions = [];
|
||||
if (inputs.cardCode) conditions.push(`CardCode eq ${quote(inputs.cardCode)}`);
|
||||
if (inputs.dateFrom) conditions.push(`DocDate ge ${quote(inputs.dateFrom.slice(0, 10))}`);
|
||||
if (inputs.dateTo) conditions.push(`DocDate le ${quote(inputs.dateTo.slice(0, 10))}`);
|
||||
if (inputs.openOnly) conditions.push("DocumentStatus eq 'bost_Open'");
|
||||
|
||||
if (conditions.length === 0) {
|
||||
ctx.fail('Vyplňte partnera nebo období, jinak by se stahoval celý archiv objednávek.');
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.get('/api/orders', {
|
||||
query: {
|
||||
$filter: conditions.join(' and '),
|
||||
$select:
|
||||
'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus',
|
||||
$orderby: 'DocDate desc',
|
||||
$top: inputs.limit,
|
||||
},
|
||||
});
|
||||
|
||||
const raw = pick(body, 'value', 'data', 'items');
|
||||
const items = Array.isArray(raw) ? raw : Array.isArray(body) ? body : [];
|
||||
|
||||
let total = 0;
|
||||
const orders = items.map((order) => {
|
||||
const amount = num(pick(order, 'DocTotal'), 0);
|
||||
total += amount;
|
||||
return {
|
||||
docEntry: num(pick(order, 'DocEntry')),
|
||||
docNum: num(pick(order, 'DocNum')),
|
||||
cardCode: text(pick(order, 'CardCode')),
|
||||
cardName: text(pick(order, 'CardName')),
|
||||
date: text(pick(order, 'DocDate')),
|
||||
dueDate: text(pick(order, 'DocDueDate')),
|
||||
total: amount,
|
||||
currency: text(pick(order, 'DocCurrency')),
|
||||
status: text(pick(order, 'DocumentStatus')),
|
||||
};
|
||||
});
|
||||
|
||||
return { count: orders.length, orders, total: round(total, 2) };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Google Search Console: vykon ve vyhledavani.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /gsc/searchAnalytics/query?siteUrl=...
|
||||
*
|
||||
* Search Console vraci dimenze jako pole `keys` bez nazvu - poradi odpovida
|
||||
* tomu, co jsme si vyzadali. Skript je proto pojmenuje zpatky, jinak by ve
|
||||
* strome zbyly hodnoty, u kterych nikdo nevi, co znamenaji.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'search-console.run-report',
|
||||
name: 'Načíst výkon ve vyhledávání',
|
||||
description: 'Vrátí dotazy, prokliky, zobrazení, CTR a průměrnou pozici za období. Nic nemění.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'siteUrl',
|
||||
label: 'Web',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Buď https://example.com/ (property s prefixem), nebo sc-domain:example.com (doménová).',
|
||||
},
|
||||
{
|
||||
id: 'startDate',
|
||||
label: 'Od',
|
||||
type: 'date',
|
||||
required: true,
|
||||
hint: 'Search Console má data se zpožděním dvou až tří dnů.',
|
||||
},
|
||||
{ id: 'endDate', label: 'Do', type: 'date', required: true },
|
||||
{
|
||||
id: 'dimensions',
|
||||
label: 'Dimenze',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'query',
|
||||
hint: 'Oddělené čárkou: query, page, country, device, date.',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik řádků',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 45000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const dimensions = String(inputs.dimensions ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
'/gsc/searchAnalytics/query',
|
||||
{
|
||||
startDate: inputs.startDate.slice(0, 10),
|
||||
endDate: inputs.endDate.slice(0, 10),
|
||||
...(dimensions.length > 0 ? { dimensions } : {}),
|
||||
rowLimit: inputs.limit,
|
||||
},
|
||||
{ query: { siteUrl: inputs.siteUrl } },
|
||||
);
|
||||
|
||||
const raw = pick(body, 'rows');
|
||||
let clicks = 0;
|
||||
let impressions = 0;
|
||||
|
||||
const rows = (Array.isArray(raw) ? raw : []).map((row) => {
|
||||
const keys = pick(row, 'keys');
|
||||
const flat = {};
|
||||
dimensions.forEach((name, index) => {
|
||||
flat[name] = Array.isArray(keys) ? (keys[index] ?? null) : null;
|
||||
});
|
||||
|
||||
flat.clicks = num(pick(row, 'clicks'), 0);
|
||||
flat.impressions = num(pick(row, 'impressions'), 0);
|
||||
// CTR chodi jako podil, lidi ho ctou v procentech.
|
||||
flat.ctr = round(num(pick(row, 'ctr'), 0) * 100, 2);
|
||||
flat.position = round(num(pick(row, 'position'), 0), 1);
|
||||
|
||||
clicks += flat.clicks;
|
||||
impressions += flat.impressions;
|
||||
return flat;
|
||||
});
|
||||
|
||||
return { count: rows.length, rows, clicks, impressions };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Sklik: statistiky kampani.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /sklik/report/campaigns
|
||||
*
|
||||
* Sklik prijima parametry jako **pole argumentu**, ne jako objekt - je to
|
||||
* zbytek jeho RPC rozhrani. Prvni prvek je filtr obdobi, druhy nastaveni
|
||||
* vypisu. Skript to sklada za uzivatele, aby se do kroku stromu nemusel psat
|
||||
* cizi tvar volani.
|
||||
*
|
||||
* Castky jsou u Skliku v halerich. Prepocitavaji se tady, at se v dalsim kroku
|
||||
* nedeli stem znovu a nekde spatne.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'sklik.campaign-report',
|
||||
name: 'Načíst výkon kampaní',
|
||||
description: 'Stáhne statistiky kampaní ze Skliku za období. Nic nemění.',
|
||||
|
||||
inputs: [
|
||||
{ id: 'dateFrom', label: 'Od', type: 'date', required: true },
|
||||
{ id: 'dateTo', label: 'Do', type: 'date', required: true },
|
||||
{
|
||||
id: 'granularity',
|
||||
label: 'Podrobnost',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'total',
|
||||
options: [
|
||||
{ value: 'total', label: 'Souhrn za období' },
|
||||
{ value: 'daily', label: 'Po dnech' },
|
||||
{ value: 'weekly', label: 'Po týdnech' },
|
||||
{ value: 'monthly', label: 'Po měsících' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'cost', label: 'Útrata celkem', type: 'number', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Statistiky z odpovedi Skliku.
|
||||
*
|
||||
* Report chodi jako `report[].stats[]`, souhrn jako `stats[]` a nekdy jen
|
||||
* jako pole. Vsechny tri tvary vraci tatáž metoda podle podrobnosti, takze
|
||||
* hadat jeden by znamenalo, ze pri prepnuti na denni report vyjde nula.
|
||||
*/
|
||||
function statRows(ctx, body) {
|
||||
const { pick } = ctx.util;
|
||||
if (Array.isArray(body)) return body;
|
||||
|
||||
const report = pick(body, 'report');
|
||||
if (Array.isArray(report)) {
|
||||
return report.flatMap((item) => {
|
||||
const stats = pick(item, 'stats');
|
||||
return Array.isArray(stats) ? stats : [item];
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.util.list(body, 'stats') ?? [];
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const { body } = await ctx.http.post('/sklik/report/campaigns', [
|
||||
{
|
||||
dateFrom: inputs.dateFrom.slice(0, 10),
|
||||
dateTo: inputs.dateTo.slice(0, 10),
|
||||
statGranularity: inputs.granularity,
|
||||
},
|
||||
{ statGranularity: inputs.granularity },
|
||||
]);
|
||||
|
||||
let cost = 0;
|
||||
let clicks = 0;
|
||||
let impressions = 0;
|
||||
|
||||
const rows = statRows(ctx, body).map((row) => {
|
||||
// Halere na koruny.
|
||||
const rowCost = round(num(pick(row, 'money', 'price', 'cost'), 0) / 100, 2);
|
||||
const rowClicks = num(pick(row, 'clicks'), 0);
|
||||
const rowImpressions = num(pick(row, 'impressions'), 0);
|
||||
|
||||
cost += rowCost;
|
||||
clicks += rowClicks;
|
||||
impressions += rowImpressions;
|
||||
|
||||
return {
|
||||
date: pick(row, 'date') ?? null,
|
||||
campaignId: pick(row, 'campaignId', 'id') ?? null,
|
||||
campaignName: pick(row, 'name', 'campaignName') ?? null,
|
||||
impressions: rowImpressions,
|
||||
clicks: rowClicks,
|
||||
cost: rowCost,
|
||||
conversions: num(pick(row, 'conversions'), 0),
|
||||
};
|
||||
});
|
||||
|
||||
return { count: rows.length, rows, cost: round(cost, 2), clicks, impressions };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Prepis hovoru: dva enginy naraz a sloucení jejich vysledku.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/audio-transcription
|
||||
* Endpoint: POST /dual-with-merge (multipart/form-data)
|
||||
*
|
||||
* Vzor **volani, ktere vraci tri verze teze veci**. Sluzba prepise nahravku
|
||||
* Deepgramem i Whisperem a treti odpoved slozi z obou. Vraci se vsechny tri,
|
||||
* protoze kdyz jeden engine vypadne, druhy porad neco rekne - a strom se pak
|
||||
* ma podle ceho rozhodnout, misto aby cely krok spadl.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'transcription.transcribe',
|
||||
name: 'Přepsat nahrávku',
|
||||
description:
|
||||
'Přepíše nahrávku Deepgramem i Whisperem a oba přepisy sloučí do jednoho. ' +
|
||||
'Sloučený výsledek rozlišuje mluvčí a je přesnější než každý engine zvlášť.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileName',
|
||||
label: 'Název nahrávky',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Včetně přípony, například hovor.mp3.',
|
||||
},
|
||||
{
|
||||
id: 'contentBase64',
|
||||
label: 'Nahrávka (Base64)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Obsah zakódovaný v Base64.',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Jazyk nahrávky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'cs',
|
||||
hint: 'ISO kód jazyka, například cs.',
|
||||
},
|
||||
{
|
||||
id: 'diarize',
|
||||
label: 'Rozlišovat mluvčí',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Zapnuté přidá do přepisu štítky Mluvčí 0, Mluvčí 1.',
|
||||
},
|
||||
{
|
||||
id: 'combinePrompt',
|
||||
label: 'Instrukce pro sloučení',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Bez vyplnění se použije výchozí instrukce služby, která je psaná pro české hovory.',
|
||||
},
|
||||
{
|
||||
id: 'chatModel',
|
||||
label: 'Model pro sloučení',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Bez vyplnění rozhoduje služba. Slučování dělá OpenAI chat model.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Sloučený přepis', type: 'string', required: true },
|
||||
{ id: 'deepgram', label: 'Přepis z Deepgramu', type: 'string', required: false },
|
||||
{ id: 'whisper', label: 'Přepis z Whisperu', type: 'string', required: false },
|
||||
{ id: 'partial', label: 'Část přepisu selhala', type: 'boolean', required: true },
|
||||
{ id: 'problems', label: 'Co selhalo', type: 'string', required: false },
|
||||
],
|
||||
|
||||
// Dva prepisy a sloucení za sebou. Kratsi limit by utnul i kratkou nahravku.
|
||||
timeoutMs: 120000,
|
||||
};
|
||||
|
||||
const byExtension = {
|
||||
mp3: 'audio/mpeg',
|
||||
mpga: 'audio/mpeg',
|
||||
mp4: 'audio/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text } = ctx.util;
|
||||
|
||||
const parts = inputs.fileName.split('.');
|
||||
const extension = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||
|
||||
const { body } = await ctx.http.postForm('/dual-with-merge', {
|
||||
language: inputs.language,
|
||||
diarize: inputs.diarize,
|
||||
combine_prompt: inputs.combinePrompt,
|
||||
chat_model: inputs.chatModel,
|
||||
file: {
|
||||
filename: inputs.fileName,
|
||||
base64: inputs.contentBase64.trim(),
|
||||
contentType: byExtension[extension] ?? 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
const merged = text(pick(body, 'merge'), '');
|
||||
const deepgram = text(pick(body, 'deepgram'), '');
|
||||
const whisper = text(pick(body, 'gpt'), '');
|
||||
const errors = pick(body, 'errors');
|
||||
const problems = Array.isArray(errors) && errors.length > 0 ? errors.join(' | ') : null;
|
||||
|
||||
if (problems) ctx.log(`Část přepisu selhala: ${problems}`);
|
||||
|
||||
// Kdyz sloucení selhalo, prazdny vystup by strom poslal dal jako platny text.
|
||||
// Radsi se sahne po tom, co prislo - a nahlas se rekne, ze je to nahradnik.
|
||||
const fallback = deepgram !== '' ? deepgram : whisper;
|
||||
if (merged === '' && fallback === '') {
|
||||
ctx.fail('Služba nevrátila žádný přepis.', problems ?? body);
|
||||
}
|
||||
if (merged === '') ctx.log('Sloučení nevyšlo, vrací se přepis z jednoho enginu.');
|
||||
|
||||
return {
|
||||
text: merged !== '' ? merged : fallback,
|
||||
deepgram: deepgram !== '' ? deepgram : null,
|
||||
whisper: whisper !== '' ? whisper : null,
|
||||
partial: problems !== null || merged === '',
|
||||
problems,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Transformace dat: prevod na XML.
|
||||
*
|
||||
* Cizi systemy si XML porad rikaji casto - ISDOC, EDI, banky, statni sluzby.
|
||||
* Bez tohohle by se muselo sestavovat retezcem v HTTP kroku, a tam se na
|
||||
* escapovani zapomene pokazde.
|
||||
*
|
||||
* Vstupem je **objekt nebo seznam**, ne sablona. Tvar se resi krokem pred tim
|
||||
* (`transform.map-fields` nebo `transform.to-json`), tady uz jde jen o zapis.
|
||||
* Jedna vec na krok: jinak by tenhle skript musel umet i premapovani a byly by
|
||||
* dva zpusoby, jak udelat totez.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'transform.to-xml',
|
||||
name: 'Převést na XML',
|
||||
description:
|
||||
'Zapíše objekt nebo seznam jako XML. Tvar dat se řeší předchozím krokem, ' +
|
||||
'tady jde jen o zápis včetně escapování.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'source',
|
||||
label: 'Zdrojová data',
|
||||
type: 'object',
|
||||
required: true,
|
||||
hint: 'Objekt z předchozího kroku. Seznam zabalte do objektu, například {"polozky": [...]}.',
|
||||
},
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Kořenový prvek',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'data',
|
||||
pattern: '^[A-Za-z_][A-Za-z0-9._-]*$',
|
||||
},
|
||||
{
|
||||
id: 'itemElement',
|
||||
label: 'Název prvku pro položky seznamu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'item',
|
||||
hint: 'XML nezná pole, takže každá položka potřebuje svůj prvek.',
|
||||
pattern: '^[A-Za-z_][A-Za-z0-9._-]*$',
|
||||
},
|
||||
{
|
||||
id: 'declaration',
|
||||
label: 'Přidat hlavičku XML',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Řádek s verzí a kódováním. Některé služby ho vyžadují.',
|
||||
},
|
||||
{
|
||||
id: 'indent',
|
||||
label: 'Odsazovat',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Vypněte u velkých dávek, odsazení jen zvětšuje přenos.',
|
||||
},
|
||||
{
|
||||
id: 'skipEmpty',
|
||||
label: 'Vynechat prázdné hodnoty',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Zapnuté nezapíše pole, která jsou null nebo prázdná.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'xml', label: 'XML', type: 'string', required: true },
|
||||
{ id: 'elements', label: 'Počet zapsaných prvků', type: 'number', required: true },
|
||||
{ id: 'renamed', label: 'Přejmenované klíče', type: 'string', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
/** Znaky, ktere v textu meni vyznam. Bez nahrady vznikne nevalidni XML. */
|
||||
function escapeText(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nazev prvku podle pravidel XML.
|
||||
*
|
||||
* Klice z ciziho JSONu casto zacinaji cislem nebo maji mezeru (`order number`,
|
||||
* `2024`). Takovy nazev by udelal nevalidni dokument, ktery protistrana odmitne
|
||||
* az na svem parseru - tedy pozde. Radsi se prejmenuje a rekne se to nahlas.
|
||||
*/
|
||||
function elementName(key, renamed) {
|
||||
let name = String(key)
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
|
||||
/*
|
||||
* Nazev nesmi zacinat cislici. Odriznout ji nejde: `2024` i `2025` by pak
|
||||
* byly totez a v dokumentu by se srazily. Podtrzitko je platny prvni znak,
|
||||
* takze se jen predsadi a klic zustane rozpoznatelny.
|
||||
*/
|
||||
if (/^[^A-Za-z_]/.test(name)) name = `_${name}`;
|
||||
if (name === '' || name === '_') name = 'pole';
|
||||
if (name !== String(key)) renamed.set(String(key), name);
|
||||
return name;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const renamed = new Map();
|
||||
let elements = 0;
|
||||
|
||||
const nl = inputs.indent ? '\n' : '';
|
||||
const pad = (depth) => (inputs.indent ? ' '.repeat(depth) : '');
|
||||
|
||||
/** Je hodnota prazdna? `0` a `false` prazdne nejsou, ty nesou informaci. */
|
||||
const empty = (value) =>
|
||||
value === null || value === undefined || (typeof value === 'string' && value.trim() === '');
|
||||
|
||||
function render(key, value, depth) {
|
||||
if (inputs.skipEmpty && empty(value)) return '';
|
||||
|
||||
// Seznam: kazda polozka dostane svuj prvek, obalene do prvku podle klice.
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map((item) => render(inputs.itemElement, item, depth + 1))
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
elements += 1;
|
||||
return `${pad(depth)}<${key}>${nl}${items}${pad(depth)}</${key}>${nl}`;
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const inner = Object.entries(value)
|
||||
.map(([childKey, childValue]) =>
|
||||
render(elementName(childKey, renamed), childValue, depth + 1),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
elements += 1;
|
||||
// Prazdny objekt se zapise jako samouzaviraci prvek, ne jako dvojice.
|
||||
if (inner === '') return `${pad(depth)}<${key} />${nl}`;
|
||||
return `${pad(depth)}<${key}>${nl}${inner}${pad(depth)}</${key}>${nl}`;
|
||||
}
|
||||
|
||||
elements += 1;
|
||||
if (value === null || value === undefined) return `${pad(depth)}<${key} />${nl}`;
|
||||
return `${pad(depth)}<${key}>${escapeText(value)}</${key}>${nl}`;
|
||||
}
|
||||
|
||||
const root = elementName(inputs.root, renamed);
|
||||
const body = render(root, inputs.source, 0);
|
||||
|
||||
const header = inputs.declaration ? `<?xml version="1.0" encoding="UTF-8"?>${nl}` : '';
|
||||
const xml = (header + body).trimEnd();
|
||||
|
||||
if (renamed.size > 0) {
|
||||
const list = [...renamed.entries()].map(([from, to]) => `${from} -> ${to}`);
|
||||
ctx.log(`Přejmenováno kvůli pravidlům XML: ${list.join(', ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
xml,
|
||||
elements,
|
||||
renamed: renamed.size > 0 ? [...renamed.keys()].join(', ') : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user