Skripty konektoru: vykonna cast s manifestem a kontrolou parametru
Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv vedel. Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru i novy soubor ve slozce funguji stejne a bez restartu. Pridano: - scripts/ se skripty konektoru, nazev souboru je zaroven ID operace - kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany. Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom veril parametru, ktery nikdy nedosel - ctx predavany skriptu: http nad adresou napojeni, util, log, config, idempotencyKey, fail a retry. Skript nedostane pristupove udaje - rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku, vzdy vraci vysledek vcetne retryable - redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty token v chybove zprave a log ticketu vidi klient - napojeni z environment variables vcetne iDokladu - sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby services.csbot.cz/apps/idoklad, kazdy na jiny vzor - stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni. Formular testu se sklada z manifestu, nepise se pro kazdy skript - endpointy /api/dashboard/scripts vcetne Swaggeru Zmeneno: - katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript - ConnectorOperation ma implementation a scriptId - ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues - Dockerfile kopiruje scripts/ do vysledneho image Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho souboru, ten se nacte a overi, a az pak prepise puvodni. K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava, 10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru. Overeno: npm run typecheck prochazi na serveru i webu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbc2236c0d
commit
6f6b287d7e
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Sablona noveho skriptu. Soubory od podtrzitka se nenacitaji, takze tenhle
|
||||
* nikde nevznikne jako operace - je tu jen ke zkopirovani.
|
||||
*
|
||||
* Postup:
|
||||
* 1. zkopirovat na `<konektor>.<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. */
|
||||
id: 'konektor.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 },
|
||||
* util: { unwrap: Function, pick: Function, first: Function, text: Function,
|
||||
* num: Function, bool: Function, date: Function, round: Function, need: Function },
|
||||
* log: Function, config: Record<string, string>, idempotencyKey: string,
|
||||
* fail: Function, retry: Function,
|
||||
* }} ctx
|
||||
*/
|
||||
export async function run(inputs, ctx) {
|
||||
// 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,155 @@
|
||||
/**
|
||||
* 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 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Datum ve tvaru, ktery iDoklad ceka. */
|
||||
function isoDay(value) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(value, days) {
|
||||
const date = new Date(value);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, text, num, date, need } = 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: isoDay(issuedAt),
|
||||
dateOfTaxing: isoDay(issuedAt),
|
||||
dateOfMaturity: isoDay(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,98 @@
|
||||
/**
|
||||
* 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 },
|
||||
],
|
||||
};
|
||||
|
||||
function isoDay(value) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, num, date, need } = 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: isoDay(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 };
|
||||
}
|
||||
Reference in New Issue
Block a user