From e3dccc46d3d53e4b91a701179d422240af063d6b Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:59:55 +0200 Subject: [PATCH] 3 --- app/main.py | 304 +++++++++++++++++------- app/models.py | 373 ++++++++++++++++++++++++++++-- documentation/raynet-connector.md | 92 ++++++-- 3 files changed, 656 insertions(+), 113 deletions(-) diff --git a/app/main.py b/app/main.py index 1a583b5..15f6f3a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,33 @@ import logging import os -from typing import Any +from typing import Any, Optional, Type from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request +from pydantic import BaseModel, ConfigDict, Field, create_model -from app.models import CompanyData, CreateCompanyResponse +from app.models import ( + BusinessCaseData, + CompanyData, + CreateResponse, + DetailResponse, + EmailData, + EventData, + InvoiceData, + LeadData, + LetterData, + ListResponse, + MeetingData, + OfferData, + PersonData, + PhoneCallData, + PriceListData, + ProductData, + ProjectData, + SalesOrderData, + SimpleResponse, + TaskData, + WebhookData, +) from app.raynet_client import ( RaynetAuthError, RaynetClient, @@ -19,8 +42,7 @@ APP_NAME = os.getenv("APP_NAME", "raynet") APP_VERSION = os.getenv("APP_VERSION", "1.0.0") ROOT_PATH = os.getenv("ROOT_PATH", "") -# Popis se zobrazí nahoře ve Swaggeru (/docs) – přihlašovací údaje jsou -# společné pro celé připojení a posílají se v hlavičkách u KAŽDÉHO requestu. +# Zobrazí se nahoře ve Swaggeru (/docs). API_DESCRIPTION = """ Stateless proxy nad **RAYNET CRM API v2**. @@ -28,20 +50,25 @@ Stateless proxy nad **RAYNET CRM API v2**. Každý endpoint vyžaduje tři hlavičky. Jsou stejné pro všechna volání: -| Hlavička | Co to je | Kde to vzít | -|-------------------|--------------------|-------------| -| **`X-Api-Key`** | API klíč (secret) | V RAYNET CRM: **Nastavení → Klíč k API** (vygeneruj / zkopíruj). | -| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email do RAYNET (tvoří dvojici s API klíčem pro Basic Auth). | -| **`X-Instance-Name`** | Název instance | Identifikátor tvé RAYNET instance (subdoména účtu). | +| Hlavička | Co to je | Kde to vzít | +|-----------------------|--------------------|-------------| +| **`X-Api-Key`** | API klíč (secret) | V RAYNET CRM: **Nastavení → Klíč k API**. | +| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email (tvoří s API klíčem Basic Auth). | +| **`X-Instance-Name`** | Název instance | Identifikátor RAYNET instance (subdoména účtu). | `X-Api-Key` je **secret** – nikdy se neloguje ani nevrací z endpointů. ## Endpointy -- Typované zkratky pro firmy: `POST/GET/PUT/DELETE /company...` -- Generický průchod na celé API: `/api/{resource}` – podporuje libovolnou - RAYNET entitu (`company`, `person`, `lead`, `businessCase`, `activity`, - `product`, `offer`, `order`, `project`, ...). +Typované entity (vidíš strukturu objektu i odpovědi ve Swaggeru) – pro každou +platí `POST` (vytvořit), `GET` (seznam), `GET /{id}` (detail), `PUT /{id}` +(úprava), `DELETE /{id}`: + +`company`, `person`, `lead`, `businessCase`, `task`, `product`, `offer`, +`salesOrder`, `invoice`. + +Generický průchod na **zbytek API** (např. `userAccount`, `project`, +`priceList`, `email`, `file`, číselníky): `/api/{resource}`. Plná dokumentace RAYNET: """ @@ -58,15 +85,9 @@ app = FastAPI( # Sdílená credentials dependency – 3 hlavičky pro celé připojení. # --------------------------------------------------------------------------- # def get_client( - x_api_key: str = Header( - ..., alias="X-Api-Key", description="RAYNET API klíč (secret)." - ), - x_raynet_email: str = Header( - ..., alias="X-Raynet-Email", description="Email uživatele RAYNET." - ), - x_instance_name: str = Header( - ..., alias="X-Instance-Name", description="Název RAYNET instance." - ), + x_api_key: str = Header(..., alias="X-Api-Key", description="RAYNET API klíč (secret)."), + x_raynet_email: str = Header(..., alias="X-Raynet-Email", description="Email uživatele RAYNET."), + x_instance_name: str = Header(..., alias="X-Instance-Name", description="Název RAYNET instance."), ): """Vytvoří RaynetClient z hlaviček a po dokončení requestu uvolní session.""" client = RaynetClient( @@ -113,76 +134,138 @@ def version(): # --------------------------------------------------------------------------- # -# Typované zkratky pro firmy (company) +# Generátor typovaných CRUD endpointů pro entitu # --------------------------------------------------------------------------- # -@app.post("/company", response_model=CreateCompanyResponse, tags=["company"]) -def create_company( - company: CompanyData, - client: RaynetClient = Depends(get_client), -): - """Vytvoří firmu v RAYNET CRM (validovaná data dle CompanyInsertDto).""" - data = company.model_dump(exclude_none=True) - result = _run(client.create_company, data) - return CreateCompanyResponse(id=result.get("id"), success=True, raw=result) +def _make_patch_model(model: Type[BaseModel]) -> Type[BaseModel]: + """Z Insert modelu vyrobí model pro částečnou úpravu (vše volitelné).""" + fields = { + fname: (Optional[finfo.annotation], None) + for fname, finfo in model.model_fields.items() + } + return create_model( + f"{model.__name__}Patch", + __config__=ConfigDict(extra="allow"), + **fields, + ) -@app.get("/company", tags=["company"]) -def list_companies( - client: RaynetClient = Depends(get_client), - offset: int = Query(0, ge=0), - limit: int = Query(20, ge=1, le=1000), - fulltext: str | None = Query(None, description="Fulltextové hledání"), -): - """Vrátí seznam firem s podporou stránkování a fulltextu.""" - params = {"offset": offset, "limit": limit} - if fulltext: - params["fulltext"] = fulltext - return _run(client.list_companies, **params) +def register_crud(path: str, model: Type[BaseModel], tag: str) -> None: + """Zaregistruje typované CRUD endpointy pro danou RAYNET entitu.""" + patch_model = _make_patch_model(model) + + @app.post( + f"/{path}", + response_model=CreateResponse, + tags=[tag], + operation_id=f"{path}_create", + summary=f"Vytvořit ({path})", + ) + def _create(payload: model, client: RaynetClient = Depends(get_client)): # type: ignore[valid-type] + data = payload.model_dump(mode="json", exclude_none=True) + res = _run(client.create_record, path, data) + return CreateResponse(success=res.get("success", True), id=res.get("id"), data=res.get("data")) + + @app.get( + f"/{path}", + response_model=ListResponse, + tags=[tag], + operation_id=f"{path}_list", + summary=f"Seznam ({path})", + ) + def _list( + request: Request, + client: RaynetClient = Depends(get_client), + offset: int = Query(0, ge=0, description="Posun ve výsledcích"), + limit: int = Query(50, ge=1, le=1000, description="Počet záznamů"), + fulltext: Optional[str] = Query(None, description="Fulltextové hledání"), + ): + # Předáme všechny query parametry do RAYNET; doplníme výchozí offset/limit. + params = dict(request.query_params) + params.setdefault("offset", offset) + params.setdefault("limit", limit) + res = _run(client.list_records, path, params) + return ListResponse( + success=res.get("success", True), + totalCount=res.get("totalCount"), + data=res.get("data") or [], + ) + + @app.get( + f"/{path}/{{record_id}}", + response_model=DetailResponse, + tags=[tag], + operation_id=f"{path}_detail", + summary=f"Detail ({path})", + ) + def _detail(record_id: int, client: RaynetClient = Depends(get_client)): + res = _run(client.get_record, path, record_id) + return DetailResponse(success=res.get("success", True), data=res.get("data")) + + @app.put( + f"/{path}/{{record_id}}", + response_model=SimpleResponse, + tags=[tag], + operation_id=f"{path}_update", + summary=f"Upravit ({path})", + ) + def _update( + record_id: int, + payload: patch_model, # type: ignore[valid-type] + client: RaynetClient = Depends(get_client), + ): + data = payload.model_dump(mode="json", exclude_none=True) + res = _run(client.update_record, path, record_id, data) + return SimpleResponse(success=res.get("success", True)) + + @app.delete( + f"/{path}/{{record_id}}", + response_model=SimpleResponse, + tags=[tag], + operation_id=f"{path}_delete", + summary=f"Smazat ({path})", + ) + def _delete(record_id: int, client: RaynetClient = Depends(get_client)): + res = _run(client.delete_record, path, record_id) + return SimpleResponse(success=res.get("success", True)) -@app.get("/company/{company_id}", tags=["company"]) -def get_company(company_id: int, client: RaynetClient = Depends(get_client)): - """Vrátí detail firmy.""" - return _run(client.get_company, company_id) - - -@app.put("/company/{company_id}", tags=["company"]) -def update_company( - company_id: int, - data: dict = Body(..., description="Pole firmy ke změně"), - client: RaynetClient = Depends(get_client), -): - """Upraví firmu.""" - return _run(client.update_company, company_id, data) - - -@app.delete("/company/{company_id}", tags=["company"]) -def delete_company(company_id: int, client: RaynetClient = Depends(get_client)): - """Smaže firmu.""" - return _run(client.delete_company, company_id) +# Registrace typovaných entit (resource path = tag). +ENTITIES: list[tuple[str, Type[BaseModel]]] = [ + ("company", CompanyData), + ("person", PersonData), + ("lead", LeadData), + ("businessCase", BusinessCaseData), + ("offer", OfferData), + ("salesOrder", SalesOrderData), + ("invoice", InvoiceData), + ("product", ProductData), + ("priceList", PriceListData), + ("project", ProjectData), + ("task", TaskData), + ("email", EmailData), + ("event", EventData), + ("meeting", MeetingData), + ("phoneCall", PhoneCallData), + ("letter", LetterData), + ("webhook", WebhookData), +] +for _path, _model in ENTITIES: + register_crud(_path, _model, _path) # --------------------------------------------------------------------------- # -# Generický průchod na CELÉ RAYNET API -# resource = libovolná entita: company, person, lead, businessCase, activity, -# product, offer, order, project, ... +# Generický průchod na zbytek RAYNET API (netypované entity) +# resource = libovolná entita: userAccount, project, priceList, email, file, ... # --------------------------------------------------------------------------- # @app.get("/api/{resource}", tags=["generic"]) -def api_list( - resource: str, - request: Request, - client: RaynetClient = Depends(get_client), -): - """Seznam záznamů entity. Všechny query parametry se předávají do RAYNET - (např. `offset`, `limit`, `fulltext`, `name`, ...).""" +def api_list(resource: str, request: Request, client: RaynetClient = Depends(get_client)): + """Seznam záznamů entity. Query parametry se předávají do RAYNET.""" params = dict(request.query_params) return _run(client.list_records, resource, params or None) @app.get("/api/{resource}/{record_id}", tags=["generic"]) -def api_detail( - resource: str, record_id: str, client: RaynetClient = Depends(get_client) -): +def api_detail(resource: str, record_id: str, client: RaynetClient = Depends(get_client)): """Detail jednoho záznamu.""" return _run(client.get_record, resource, record_id) @@ -209,8 +292,69 @@ def api_update( @app.delete("/api/{resource}/{record_id}", tags=["generic"]) -def api_delete( - resource: str, record_id: str, client: RaynetClient = Depends(get_client) -): +def api_delete(resource: str, record_id: str, client: RaynetClient = Depends(get_client)): """Smaže záznam.""" return _run(client.delete_record, resource, record_id) + + +# --------------------------------------------------------------------------- # +# Vnořené zdroje a speciální akce +# Pokrývá např.: /company/{id}/address/, /company/{id}/lock, +# /company/{id}/merge/{srcId}/, /invoice/{id}/cancel, /invoice/{id}/pdfExport, +# /invoice/{id}/payment/, /offer/{id}/item/ atd. +# `sub` je zbytek cesty za /{resource}/{id}/ – uveď přesně jak chce RAYNET +# (vč. koncového lomítka u kolekcí, např. "address/" nebo "payment/5/"). +# --------------------------------------------------------------------------- # +@app.get("/api/{resource}/{record_id}/{sub:path}", tags=["generic"]) +def api_sub_get( + resource: str, record_id: str, sub: str, request: Request, + client: RaynetClient = Depends(get_client), +): + """GET vnořeného zdroje / akce (např. pdfExport, seznam adres).""" + return _run(client.call, "GET", f"/{resource}/{record_id}/{sub}", dict(request.query_params) or None) + + +@app.post("/api/{resource}/{record_id}/{sub:path}", tags=["generic"]) +def api_sub_post( + resource: str, record_id: str, sub: str, + data: Optional[dict] = Body(None, description="Volitelné tělo (akce nemusí mít žádné)"), + client: RaynetClient = Depends(get_client), +): + """POST vnořeného zdroje / akce (např. lock, cancel, setPrimary, update).""" + return _run(client.call, "POST", f"/{resource}/{record_id}/{sub}", None, data) + + +@app.put("/api/{resource}/{record_id}/{sub:path}", tags=["generic"]) +def api_sub_put( + resource: str, record_id: str, sub: str, + data: Optional[dict] = Body(None, description="Tělo nového vnořeného záznamu"), + client: RaynetClient = Depends(get_client), +): + """PUT vnořeného zdroje (create v kolekci, např. address/, payment/, item/).""" + return _run(client.call, "PUT", f"/{resource}/{record_id}/{sub}", None, data) + + +@app.delete("/api/{resource}/{record_id}/{sub:path}", tags=["generic"]) +def api_sub_delete( + resource: str, record_id: str, sub: str, + client: RaynetClient = Depends(get_client), +): + """DELETE vnořeného zdroje (např. address/5/, payment/3/).""" + return _run(client.call, "DELETE", f"/{resource}/{record_id}/{sub}") + + +# --------------------------------------------------------------------------- # +# Raw escape-hatch – dosáhne na JAKOUKOLI cestu RAYNET API libovolnou metodou. +# Pro speciální případy mimo výše uvedené vzory (např. PUT /invoice/creditNote). +# --------------------------------------------------------------------------- # +class RawRequest(BaseModel): + method: str = Field(..., examples=["GET", "POST", "PUT", "DELETE"]) + path: str = Field(..., description="Cesta za base URL, např. /invoice/creditNote", examples=["/company/"]) + params: Optional[dict] = Field(default=None, description="Query parametry") + data: Optional[dict] = Field(default=None, description="JSON tělo") + + +@app.post("/raw", tags=["generic"], summary="Raw volání libovolného RAYNET endpointu") +def raw_call(req: RawRequest, client: RaynetClient = Depends(get_client)): + """Univerzální volání – pokrývá 100 % RAYNET API včetně netypických cest.""" + return _run(client.call, req.method, req.path, req.params, req.data) diff --git a/app/models.py b/app/models.py index de60463..827f20e 100644 --- a/app/models.py +++ b/app/models.py @@ -1,7 +1,12 @@ """Pydantic modely pro RAYNET endpointy. -Modely odpovídají RAYNET CRM API v2 (CompanyInsertDto). Slouží zároveň jako -zdroj pro Swagger/OpenAPI dokumentaci. +Modely odpovídají RAYNET CRM API v2 (Insert DTO jednotlivých entit) a slouží +zároveň jako zdroj pro Swagger/OpenAPI dokumentaci – ve Swaggeru je tak vidět +přesná struktura objektů, povinná pole i tvar odpovědí. + +POZN.: Názvy polí jsou v ``camelCase`` (tak je očekává RAYNET JSON tělo). +Nevyjmenovaná pole jsou povolená (``extra="allow"``), takže lze poslat i pole, +která zde nejsou explicitně uvedená. """ from enum import Enum @@ -10,6 +15,9 @@ from typing import Optional from pydantic import BaseModel, Field +# --------------------------------------------------------------------------- # +# Číselníkové enumy (company) +# --------------------------------------------------------------------------- # class Rating(str, Enum): A = "A" B = "B" @@ -35,6 +43,9 @@ class TaxPayer(str, Enum): NO = "NO" +# --------------------------------------------------------------------------- # +# Sdílené pod-objekty +# --------------------------------------------------------------------------- # class Address(BaseModel): name: Optional[str] = Field(default=None, examples=["Sídlo klienta"]) street: Optional[str] = Field(default=None, examples=["Francouzská 6167/5"]) @@ -64,21 +75,21 @@ class CompanyAddress(BaseModel): contactInfo: Optional[ContactInfo] = None +# --------------------------------------------------------------------------- # +# Entitní modely (request body pro create) – CamelCase dle RAYNET API +# --------------------------------------------------------------------------- # class CompanyData(BaseModel): - """Tělo požadavku pro vytvoření firmy (CompanyInsertDto). + """Firma / klient (RAYNET CompanyInsertDto). Povinná: name, rating, state, role.""" - Povinná pole: ``name``, ``rating``, ``state``, ``role``. - """ + model_config = {"extra": "allow"} - # Povinná pole name: str = Field(..., examples=["ACME s.r.o."]) rating: Rating = Field(..., examples=[Rating.A]) state: CompanyState = Field(..., examples=[CompanyState.A_POTENTIAL]) role: CompanyRole = Field(..., examples=[CompanyRole.A_SUBSCRIBER]) - # Volitelná pole - regNumber: Optional[str] = Field(default=None, examples=["12345678"]) - taxNumber: Optional[str] = Field(default=None, examples=["CZ12345678"]) + regNumber: Optional[str] = Field(default=None, examples=["12345678"], description="IČO") + taxNumber: Optional[str] = Field(default=None, examples=["CZ12345678"], description="DIČ") taxNumber2: Optional[str] = None taxPayer: Optional[TaxPayer] = None bankAccount: Optional[str] = None @@ -95,11 +106,347 @@ class CompanyData(BaseModel): tags: Optional[list[str]] = None customFields: Optional[dict] = None - # Umožní i pole, která zde nejsou explicitně vyjmenovaná (RAYNET jich má víc). + +class PersonData(BaseModel): + """Kontaktní osoba (RAYNET PersonInsertDto). Povinné: lastName.""" + model_config = {"extra": "allow"} + lastName: str = Field(..., description="Příjmení") + titleBefore: Optional[str] = None + firstName: Optional[str] = None + titleAfter: Optional[str] = None + securityLevel: Optional[int] = None + owner: Optional[int] = None + category: Optional[int] = None + personClassification1: Optional[int] = None + personClassification2: Optional[int] = None + personClassification3: Optional[int] = None + salutation: Optional[str] = None + birthday: Optional[str] = None + language: Optional[int] = None + maritalStatus: Optional[int] = None + gender: Optional[str] = None + contactInfo: Optional[dict] = None + socialNetworkContact: Optional[dict] = None + privateAddress: Optional[dict] = None + notice: Optional[str] = None + relationship: Optional[dict] = None + tags: Optional[list[str]] = None + keyman: Optional[bool] = None + originLead: Optional[int] = None -class CreateCompanyResponse(BaseModel): - id: Optional[int] = Field(default=None, description="ID nově vytvořené firmy") + +class LeadData(BaseModel): + """Lead / poptávka (RAYNET LeadInsertDto). Povinné: topic, priority.""" + + model_config = {"extra": "allow"} + + topic: str = Field(..., description="Předmět") + priority: str = Field(..., description="Priorita (číselník)") + companyName: Optional[str] = None + regNumber: Optional[str] = None + firstName: Optional[str] = None + lastName: Optional[str] = None + titleBefore: Optional[str] = None + titleAfter: Optional[str] = None + securityLevel: Optional[int] = None + owner: Optional[int] = None + contactSource: Optional[int] = None + category: Optional[int] = None + notice: Optional[str] = None + leadPhase: Optional[int] = None + tags: Optional[str] = None + territory: Optional[int] = None + leadPerson: Optional[bool] = None + contactInfo: Optional[dict] = None + address: Optional[dict] = None + socialNetworkContact: Optional[dict] = None + customFields: Optional[dict] = None + notificationMessage: Optional[str] = None + notificationEmailAddresses: Optional[list[str]] = None + + +class BusinessCaseData(BaseModel): + """Obchodní případ (RAYNET BusinessCaseInsertDto). Povinné: name, company.""" + + model_config = {"extra": "allow"} + + name: str = Field(..., description="Předmět") + company: int = Field(..., description="ID klienta") + securityLevel: Optional[int] = None + owner: Optional[int] = None + person: Optional[int] = None + project: Optional[int] = None + totalAmount: Optional[float] = None + estimatedValue: Optional[float] = None + probability: Optional[int] = None + validFrom: Optional[str] = None + description: Optional[str] = None + currency: Optional[int] = None + exchangeRate: Optional[float] = None + category: Optional[int] = None + source: Optional[int] = None + businessCaseClassification1: Optional[int] = None + businessCaseClassification2: Optional[int] = None + businessCaseClassification3: Optional[int] = None + businessCasePhase: Optional[int] = None + originalLead: Optional[int] = None + tags: Optional[list[str]] = None + customFields: Optional[dict] = None + + +class TaskData(BaseModel): + """Úkol = aktivita typu task (RAYNET TaskInsertDto, endpoint /task/). + + Povinné: title, priority, owner, resolver, deadline. + """ + + model_config = {"extra": "allow"} + + title: str = Field(..., description="Předmět") + priority: str = Field(..., description="Priorita") + owner: int = Field(..., description="ID kontaktní osoby – vlastník úkolu") + resolver: int = Field(..., description="ID kontaktní osoby – řešitel úkolu") + deadline: str = Field(..., description="Deadline (datetime)") + category: Optional[int] = None + person: Optional[int] = None + company: Optional[int] = None + businessCase: Optional[int] = None + offer: Optional[int] = None + salesOrder: Optional[int] = None + project: Optional[int] = None + activity: Optional[int] = None + lead: Optional[int] = None + securityLevel: Optional[int] = None + scheduledFrom: Optional[str] = None + scheduledTill: Optional[str] = None + completed: Optional[str] = None + description: Optional[str] = None + solution: Optional[str] = None + tags: Optional[list[str]] = None + + +class ProductData(BaseModel): + """Produkt (RAYNET ProductInsertDto). Povinné: code, name.""" + + model_config = {"extra": "allow"} + + code: str = Field(..., description="Kód") + name: str = Field(..., description="Název") + unit: Optional[str] = None + description: Optional[str] = None + taxRate: Optional[float] = None + category: Optional[int] = Field(default=None, description="ID z číselníku ProductCategory") + productLine: Optional[int] = Field(default=None, description="ID z číselníku ProductLine") + cost: Optional[float] = None + price: Optional[float] = None + tags: Optional[list[str]] = None + + +class OfferData(BaseModel): + """Nabídka (RAYNET OfferInsertDto). Povinné: name, company, businessCase.""" + + model_config = {"extra": "allow"} + + name: str = Field(..., description="Předmět") + company: int = Field(..., description="ID klienta") + businessCase: int = Field(..., description="ID obchodního případu") + securityLevel: Optional[int] = None + owner: Optional[int] = None + person: Optional[int] = None + totalAmount: Optional[float] = None + estimatedValue: Optional[float] = None + validFrom: Optional[str] = None + validTill: Optional[str] = None + expirationDate: Optional[str] = None + description: Optional[str] = None + category: Optional[int] = None + offerStatus: Optional[int] = None + + +class SalesOrderData(BaseModel): + """Objednávka (RAYNET SalesOrderInsertDto, endpoint /salesOrder/). + + Povinné: name, company, businessCase. + """ + + model_config = {"extra": "allow"} + + name: str = Field(..., description="Předmět") + company: int = Field(..., description="ID klienta") + businessCase: int = Field(..., description="ID obchodního případu") + securityLevel: Optional[int] = None + owner: Optional[int] = None + person: Optional[int] = None + offer: Optional[int] = None + totalAmount: Optional[float] = None + estimatedValue: Optional[float] = None + validFrom: Optional[str] = None + validTill: Optional[str] = None + expirationDate: Optional[str] = None + requestDeliveryDate: Optional[str] = None + description: Optional[str] = None + category: Optional[int] = None + salesOrderStatus: Optional[int] = None + deliveryAddress: Optional[dict] = None + + +class InvoiceData(BaseModel): + """Faktura (RAYNET InvoiceInsertDto, endpoint /invoice/). + + Povinné: company, currency, dueDate, issueDate, invoiceType, paymentType, + taxableSupplyDate. + """ + + model_config = {"extra": "allow"} + + company: int = Field(..., description="ID klienta, kterému se fakturuje") + currency: int = Field(..., description="ID měny (číselník Currency)") + dueDate: str = Field(..., description="Datum splatnosti") + issueDate: str = Field(..., description="Datum vystavení") + invoiceType: str = Field(..., description="Typ faktury") + paymentType: str = Field(..., description="Způsob úhrady") + taxableSupplyDate: str = Field(..., description="Datum zdanitelného plnění") + securityLevel: Optional[int] = None + title: Optional[str] = None + constantSymbol: Optional[str] = None + specificSymbol: Optional[str] = None + paymentTermDays: Optional[int] = None + businessCase: Optional[int] = None + note: Optional[str] = None + deliveryNote: Optional[str] = None + privateNote: Optional[str] = None + + +class ProjectData(BaseModel): + """Projekt (RAYNET ProjectInsertDto). Povinné: name, company.""" + + model_config = {"extra": "allow"} + + name: str = Field(..., description="Předmět") + company: int = Field(..., description="ID klienta") + securityLevel: Optional[int] = None + owner: Optional[int] = None + person: Optional[int] = None + totalAmount: Optional[int] = None + avgValueTotalAmount: Optional[float] = None + minValueTotalAmount: Optional[float] = None + maxValueTotalAmount: Optional[float] = None + validFrom: Optional[str] = None + validTill: Optional[str] = None + scheduledEnd: Optional[str] = None + description: Optional[str] = None + category: Optional[int] = None + projectStatus: Optional[int] = None + tags: Optional[list[str]] = None + customFields: Optional[dict] = None + + +class PriceListData(BaseModel): + """Ceník (RAYNET PriceListInsertDto). Povinné: name, code, currency, validFrom.""" + + model_config = {"extra": "allow"} + + name: str = Field(..., description="Název ceníku") + code: str = Field(..., description="Kód ceníku") + currency: int = Field(..., description="ID měny (číselník Currency)") + validFrom: str = Field(..., description="Platnost od") + securityLevel: Optional[int] = None + owner: Optional[int] = None + category: Optional[int] = None + validTill: Optional[str] = None + description: Optional[str] = None + + +# --- Aktivity (sdílí strukturu: title, priority, owner povinné) ----------- # +class _ActivityBase(BaseModel): + """Společná pole aktivit RAYNET (task/email/event/meeting/phoneCall/letter).""" + + model_config = {"extra": "allow"} + + title: str = Field(..., description="Předmět") + priority: str = Field(..., description="Priorita") + owner: int = Field(..., description="ID kontaktní osoby – vlastník") + category: Optional[int] = None + person: Optional[int] = None + company: Optional[int] = None + businessCase: Optional[int] = None + offer: Optional[int] = None + salesOrder: Optional[int] = None + project: Optional[int] = None + activity: Optional[int] = None + lead: Optional[int] = None + securityLevel: Optional[int] = None + scheduledFrom: Optional[str] = None + scheduledTill: Optional[str] = None + completed: Optional[str] = None + description: Optional[str] = None + tags: Optional[list[str]] = None + + +class EmailData(_ActivityBase): + """E-mailová aktivita (RAYNET EmailInsertDto).""" + + +class EventData(_ActivityBase): + """Událost (RAYNET EventInsertDto).""" + + +class MeetingData(_ActivityBase): + """Schůzka (RAYNET MeetingInsertDto).""" + + solution: Optional[str] = None + + +class PhoneCallData(_ActivityBase): + """Telefonát (RAYNET PhonecallInsertDto).""" + + solution: Optional[str] = None + + +class LetterData(_ActivityBase): + """Dopis (RAYNET LetterInsertDto).""" + + +class WebhookData(BaseModel): + """Webhook (RAYNET WebhookInsertDto). Povinné: url, events.""" + + model_config = {"extra": "allow"} + + url: str = Field(..., description="Cílová adresa (http/https)") + events: list[str] = Field( + ..., + description="Typy událostí: record.created, record.updated, record.deleted", + ) + secretToken: Optional[str] = Field( + default=None, + description="Token zaslaný v hlavičce X-RAYNETCRM-Token", + ) + + +# --------------------------------------------------------------------------- # +# Obálky odpovědí (RAYNET vrací data zabalená do success/data/totalCount) +# --------------------------------------------------------------------------- # +class CreateResponse(BaseModel): + success: bool = True + id: Optional[int] = Field(default=None, description="ID nově vytvořeného záznamu") + data: Optional[dict] = Field(default=None, description="Surová data z RAYNET, pokud byla vrácena") + + +class DetailResponse(BaseModel): + success: bool = True + data: Optional[dict] = Field( + default=None, + description="Objekt záznamu vč. serverových polí (id, rowInfo, ...)", + ) + + +class ListResponse(BaseModel): + success: bool = True + totalCount: Optional[int] = Field(default=None, description="Celkový počet záznamů") + data: list[dict] = Field(default_factory=list, description="Pole záznamů") + + +class SimpleResponse(BaseModel): success: bool = True - raw: Optional[dict] = Field(default=None, description="Surová odpověď RAYNET API") diff --git a/documentation/raynet-connector.md b/documentation/raynet-connector.md index 85bf8c5..8f3815b 100644 --- a/documentation/raynet-connector.md +++ b/documentation/raynet-connector.md @@ -36,17 +36,55 @@ Příklady entit: `company`, `person`, `lead`, `businessCase`, `activity`, Veřejně přes AppFactory reverse proxy: `https://services.csbot.cz/apps//...` -### Typované zkratky pro firmy +### Typované entity -| Metoda | Cesta | Popis | -|--------|-------------------------|-------------------------------| -| POST | `/company` | Vytvoří firmu (validace) | -| GET | `/company` | Seznam firem (offset/limit/fulltext) | -| GET | `/company/{id}` | Detail firmy | -| PUT | `/company/{id}` | Úprava firmy | -| DELETE | `/company/{id}` | Smazání firmy | +Pro každou z těchto entit existuje plné CRUD se **strukturou objektu i odpovědí +viditelnou ve Swaggeru** (request modely, povinná pole, validace): -### Generický průchod na celé API +| Cesta | Entita | Povinná pole | +|---------------------------|--------------------|--------------| +| `/company` | Firma / klient | `name`, `rating`, `state`, `role` | +| `/person` | Kontaktní osoba | `lastName` | +| `/lead` | Lead / poptávka | `topic`, `priority` | +| `/businessCase` | Obchodní případ | `name`, `company` | +| `/offer` | Nabídka | `name`, `company`, `businessCase` | +| `/salesOrder` | Objednávka | `name`, `company`, `businessCase` | +| `/invoice` | Faktura | `company`, `currency`, `dueDate`, `issueDate`, `invoiceType`, `paymentType`, `taxableSupplyDate` | +| `/product` | Produkt | `code`, `name` | +| `/priceList` | Ceník | `name`, `code`, `currency`, `validFrom` | +| `/project` | Projekt | `name`, `company` | +| `/task` | Úkol | `title`, `priority`, `owner`, `resolver`, `deadline` | +| `/email` | E-mail (aktivita) | `title`, `priority`, `owner` | +| `/event` | Událost | `title`, `priority`, `owner` | +| `/meeting` | Schůzka | `title`, `priority`, `owner` | +| `/phoneCall` | Telefonát | `title`, `priority`, `owner` | +| `/letter` | Dopis | `title`, `priority`, `owner` | +| `/webhook` | Webhook | `url`, `events` | + +Operace u každé entity: + +| Metoda | Cesta | Popis | +|--------|--------------------|--------------------------------------| +| POST | `/{entita}` | Vytvoří záznam (validovaný objekt) | +| GET | `/{entita}` | Seznam (`offset`, `limit`, `fulltext` + libovolné RAYNET filtry) | +| GET | `/{entita}/{id}` | Detail | +| PUT | `/{entita}/{id}` | Částečná úprava (všechna pole volitelná) | +| DELETE | `/{entita}/{id}` | Smazání | + +Enumy firmy: `rating` = `A`/`B`/`C`; `state` = `A_POTENTIAL`/`B_ACTUAL`/`C_DEFERRED`/`D_UNATTRACTIVE`; +`role` = `A_SUBSCRIBER`/`B_PARTNER`/`C_SUPPLIER`/`D_RIVAL`; `taxPayer` = `YES`/`NO`. + +> Pole jsou v `camelCase` (tak je očekává RAYNET). Nevyjmenovaná pole jsou +> povolená (`extra="allow"`), takže lze poslat i pole, která model neuvádí. + +### Generický průchod na zbytek API + +Pro entity bez typovaného modelu – zejména **uživatelé** (`userAccount`), +soubory (`file`), DMS dokumenty/složky, GDPR, hromadné e-maly a všechny +**číselníky** (`currency`, `legalForm`, `paymentTerm`, `taxRate`, `territory`, +`contactSource`, `companyCategory`, `productCategory`, `leadPhase`, +`businessCasePhase`, `offerStatus`, `salesOrderStatus`, `projectStatus`, +`maritalStatus`, `employeesNumber`, `economyActivity`, klasifikace 1/2/3, …): | Metoda | Cesta | Mapuje se na RAYNET | |--------|-----------------------------|-------------------------------| @@ -56,19 +94,33 @@ Veřejně přes AppFactory reverse proxy: `https://services.csbot.cz/apps/