first
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# SAP credentials jsou vnitrni secrets (AppFactory portal -> runtime .env).
|
||||||
|
# Nikdy se neposilaji v requestech, nelogujou a necommituji.
|
||||||
|
SAP_B1_BASE_URL=https://ws.polstrin.cz:50000
|
||||||
|
SAP_B1_COMPANY_DB=
|
||||||
|
SAP_B1_USERNAME=
|
||||||
|
SAP_B1_PASSWORD=
|
||||||
|
SAP_B1_LANGUAGE=
|
||||||
|
SAP_B1_TIMEOUT_MS=30000
|
||||||
|
SAP_B1_REJECT_UNAUTHORIZED=true
|
||||||
|
SAP_B1_RETRY_COUNT=2
|
||||||
|
SAP_B1_RETRY_DELAY_MS=250
|
||||||
|
|
||||||
|
# Volitelny sdileny klic pro /api routy; kdyz je nastaveny, kazdy request
|
||||||
|
# musi poslat hlavicku X-Api-Key se stejnou hodnotou.
|
||||||
|
API_KEY=
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
|
ROOT_PATH=/apps/polstrin-sap
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
coverage/
|
||||||
@@ -1,8 +1,195 @@
|
|||||||
# Polstrin SAP BO1
|
# POLSTRIN SAP Business One Service Layer Connector
|
||||||
|
|
||||||
Node.js TypeScript služba vytvořená přes CSBot Services Portal.
|
Node.js + TypeScript connector pro SAP Business One Service Layer REST/OData API, dedikovaný instalaci **POLSTRIN DESIGN s.r.o.** (`https://ws.polstrin.cz:50000`, SAP B1 10.0, verze `1000310`). Používá pouze Service Layer (`/b1s/v1`), ne SAP DI API. Vznikl jako kopie obecné služby `sap-bo`; hlavní rozdíly:
|
||||||
|
|
||||||
## Endpointy
|
- SAP credentials se **nepředávají v hlavičkách**, ale čtou se z environment variables (AppFactory secrets, viz AGENTS.md „Secrets v parametrech“).
|
||||||
|
- Jedna sdílená Service Layer session přežívá mezi requesty (re-login při expiraci/401 řeší `SessionManager`).
|
||||||
|
- Volitelná ochrana `/api` rout sdíleným klíčem: když je nastavený secret `API_KEY`, každý request musí poslat hlavičku `X-Api-Key`.
|
||||||
|
- Generické endpointy `/api/entities/...` pro práci s libovolným entity setem — hlavně pro UDO tabulky POLSTRIN addonů (`U_ADN_*`, `U_DFX_*`, `U_PVT_*`, `U_VCZ_*`, `VYROBNI_PLAN`, `VYROBNI_DAVKA`). Detaily v [documentation/polstrin-specifika.md](documentation/polstrin-specifika.md).
|
||||||
|
|
||||||
- GET /
|
Součástí repozitáře je AppFactory HTTP wrapper s endpointy:
|
||||||
- GET /health
|
|
||||||
|
- `GET /`
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /docs`
|
||||||
|
- `GET /openapi.json`
|
||||||
|
- `POST /api/session/login` (ověří spojení, vrací verzi Service Layeru a session timeout)
|
||||||
|
- `POST /api/session/logout`
|
||||||
|
- `GET /api/system/info` (verze SAP, dostupné entity sety, UDF/UDT/UDO, admin info)
|
||||||
|
- `GET /api/entities` (seznam všech entity setů)
|
||||||
|
- `GET|POST /api/entities/{entitySet}`, `GET /api/entities/{entitySet}/all`
|
||||||
|
- `GET|PATCH|DELETE /api/entities/{entitySet}/{id}` (`?idType=number` pro číselné klíče)
|
||||||
|
- `GET /api/<resource>`
|
||||||
|
- `GET /api/<resource>/all`
|
||||||
|
- `GET /api/<resource>/{id}`
|
||||||
|
- `POST /api/<resource>`
|
||||||
|
- `PATCH /api/<resource>/{id}`
|
||||||
|
- `DELETE /api/<resource>/{id}` pouze pro obecně mazatelné resource
|
||||||
|
|
||||||
|
## Instalace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfigurace (secrets přes environment variables)
|
||||||
|
|
||||||
|
Všechny SAP credentials se nastavují přes AppFactory portál jako variables/secrets; aplikace je čte z environment variables. Nikdy se nepředávají v requestech, nelogují se a necommitují. Lokálně vytvoř `.env` podle `.env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SAP_B1_BASE_URL=https://ws.polstrin.cz:50000
|
||||||
|
SAP_B1_COMPANY_DB=<secret>
|
||||||
|
SAP_B1_USERNAME=<secret>
|
||||||
|
SAP_B1_PASSWORD=<secret>
|
||||||
|
SAP_B1_LANGUAGE=
|
||||||
|
SAP_B1_TIMEOUT_MS=30000
|
||||||
|
SAP_B1_REJECT_UNAUTHORIZED=true
|
||||||
|
SAP_B1_RETRY_COUNT=2
|
||||||
|
SAP_B1_RETRY_DELAY_MS=250
|
||||||
|
API_KEY=<volitelny secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
`SAP_B1_BASE_URL` může být buď root Service Layer hostu, nebo přímo URL končící `/b1s/v1`. Pro self-signed certifikáty lze v interním prostředí nastavit `SAP_B1_REJECT_UNAUTHORIZED=false`; v produkci preferuj důvěryhodný certifikát a ponech `true`.
|
||||||
|
|
||||||
|
## Autentizace HTTP API
|
||||||
|
|
||||||
|
SAP credentials jsou vnitřní secret služby — klienti je neposílají. Když je nastavený secret `API_KEY`, musí každý request na `/api/...` obsahovat hlavičku:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Api-Key: <hodnota API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
Bez nastaveného `API_KEY` jsou `/api` routy otevřené (vhodné jen pro interní síť). Ve Swaggeru (`/docs`) se klíč vyplňuje přes `Authorize`.
|
||||||
|
|
||||||
|
## Použití
|
||||||
|
|
||||||
|
### Login a logout
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SapBusinessOneServiceLayer, loadSapB1ConfigFromEnv } from "./src";
|
||||||
|
|
||||||
|
const sap = new SapBusinessOneServiceLayer(loadSapB1ConfigFromEnv());
|
||||||
|
|
||||||
|
await sap.login();
|
||||||
|
await sap.logout();
|
||||||
|
```
|
||||||
|
|
||||||
|
Login volá `POST /b1s/v1/Login`, uloží cookies `B1SESSION` a `ROUTEID` a posílá je v dalších requestech. Při expiraci session a odpovědi `401` connector jednou provede re-login a request zopakuje.
|
||||||
|
|
||||||
|
### Vypsání Business Partners
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const partners = await sap.businessPartners.list({
|
||||||
|
select: ["CardCode", "CardName", "CardType"],
|
||||||
|
filter: "CardType eq 'cCustomer'",
|
||||||
|
top: 50,
|
||||||
|
orderby: "CardName asc"
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(partners.value);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Načtení všech záznamů přes nextLink
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const allItems = await sap.items.listAll({
|
||||||
|
select: ["ItemCode", "ItemName"],
|
||||||
|
top: 100
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Connector podporuje starší `odata.nextLink` i novější `@odata.nextLink`.
|
||||||
|
|
||||||
|
### Vytvoření objednávky
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const order = await sap.orders.create({
|
||||||
|
CardCode: "C001",
|
||||||
|
DocDueDate: "2026-07-15",
|
||||||
|
DocumentLines: [
|
||||||
|
{
|
||||||
|
ItemCode: "A00001",
|
||||||
|
Quantity: 2,
|
||||||
|
UnitPrice: 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aktualizace položky
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await sap.items.update("A00001", {
|
||||||
|
ItemName: "Updated item name"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resource moduly
|
||||||
|
|
||||||
|
Implementované resource moduly:
|
||||||
|
|
||||||
|
- `businessPartners`
|
||||||
|
- `items`
|
||||||
|
- `orders`
|
||||||
|
- `invoices`
|
||||||
|
- `purchaseOrders`
|
||||||
|
- `deliveryNotes`
|
||||||
|
- `stockTransfers`
|
||||||
|
|
||||||
|
Každý modul má:
|
||||||
|
|
||||||
|
- `list(query?)`
|
||||||
|
- `listAll(query?)`
|
||||||
|
- `get(id)`
|
||||||
|
- `create(data)`
|
||||||
|
- `update(id, data)`
|
||||||
|
- `delete(id)` pouze tam, kde je povolené mazání
|
||||||
|
|
||||||
|
U marketing dokumentů (`Orders`, `Invoices`, `PurchaseOrders`, `DeliveryNotes`) a skladových převodek je delete v connectoru záměrně blokovaný. SAP Business One obvykle řeší rušení dokumentů storno/cancel operacemi podle typu dokladu a nastavení firmy.
|
||||||
|
|
||||||
|
## Zpracování chyb
|
||||||
|
|
||||||
|
Connector převádí chyby na `SapB1Error`:
|
||||||
|
|
||||||
|
- `status` HTTP status
|
||||||
|
- `code` SAP error code, pokud jej Service Layer vrátí
|
||||||
|
- `message` bezpečná chybová zpráva
|
||||||
|
- `retryable` příznak pro dočasné chyby
|
||||||
|
|
||||||
|
Retry se používá pro `408`, `429` a `5xx`. Citlivé hodnoty jako heslo, cookies a session tokeny se při logování redigují.
|
||||||
|
|
||||||
|
## Testy
|
||||||
|
|
||||||
|
Testy používají mockované HTTP odpovědi přes axios adapter nebo fake klienty. Nevolají reálný SAP.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Pokryté oblasti:
|
||||||
|
|
||||||
|
- autentizace a cookies
|
||||||
|
- logout
|
||||||
|
- automatický re-login po `401`
|
||||||
|
- OData query parametry
|
||||||
|
- resource URL builder
|
||||||
|
|
||||||
|
## AppFactory
|
||||||
|
|
||||||
|
Aplikace poslouchá na `0.0.0.0` a portu `PORT` s výchozí hodnotou `3000`. `ROOT_PATH` se používá pro dokumentaci a testovací requesty za reverse proxy, například `/apps/polstrin-sap`.
|
||||||
|
|
||||||
|
`/docs` je Swagger UI servírované přímo jako HTML (stejně jako sousední `google-service`),
|
||||||
|
bez statického middleware – proto nevzniká redirect `/docs` → `/docs/`, který by za proxy
|
||||||
|
zahodil prefix. Assety se načítají z CDN, spec URL je `ROOT_PATH + /openapi.json`. V Swaggeru
|
||||||
|
použij `Authorize` pro vyplnění `X-Api-Key` (pokud je `API_KEY` nastavený) a potom `Try it out`
|
||||||
|
u konkrétní operace. OpenAPI `servers` se nastaví z `ROOT_PATH` (jinak `/`), takže za proxy
|
||||||
|
volá například `/apps/polstrin-sap/api/business-partners`, ne root doménu.
|
||||||
|
|
||||||
|
## TODO ověřit v konkrétní instalaci SAP Business One
|
||||||
|
|
||||||
|
- Přesné enum hodnoty a povinná pole pro jednotlivé entity se mohou lišit podle lokalizace, add-onů a verze SAP Business One.
|
||||||
|
- U rušení dokladů ověř konkrétní Service Layer akce dostupné pro daný typ dokladu a firemní nastavení.
|
||||||
|
- U velkých datasetů ověř server-side limity stránkování a maximální povolené `$top`.
|
||||||
|
- Ověř, zda konkrétní instalace vrací OData metadata ve starším formátu `odata.*` nebo novějším `@odata.*`.
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Specifika instalace POLSTRIN DESIGN s.r.o.
|
||||||
|
|
||||||
|
Zjištěno z `GET /api/system/info` (10. 7. 2026, testovací databáze).
|
||||||
|
|
||||||
|
## Základní údaje
|
||||||
|
|
||||||
|
- Service Layer: `https://ws.polstrin.cz:50000/b1s/v1`
|
||||||
|
- Verze SAP Business One: `1000310` (SAP B1 10.0), session timeout 30 minut
|
||||||
|
- Firma: POLSTRIN DESIGN s.r.o., Hradec Králové, DIČ `CZ49283120`
|
||||||
|
- Firemní měna `CZK`, systémová měna `EUR`, účtová osnova šablona `U`
|
||||||
|
- Průběžné vedení skladu metodou **Moving Average**, výchozí sklad `01.100`
|
||||||
|
- Výchozí kódy DPH: prodej `UP21`, nákup `PP21`
|
||||||
|
- **Pozor:** dodaný výpis pochází z databáze pojmenované „TEST 10.7.2026 POLSTRIN DESIGN, s.r.o.“ — před produkčním nasazením ověř, že secret `SAP_B1_COMPANY_DB` míří na správnou (produkční) databázi.
|
||||||
|
|
||||||
|
## Nainstalované addony a jejich prefixy
|
||||||
|
|
||||||
|
Instalace obsahuje rozsáhlé customizace. UDO tabulky jsou v Service Layeru dostupné jako entity sety `U_<prefix>_*` (přes `/api/entities/...`), UDF se stejnými prefixy jsou přidané na standardních objektech (artikly `OITM`, obchodní partneři `OCRD`, všechny marketingové doklady, montážní zakázky `OWOR`, kusovníky `OITT` atd.) a vrací se v běžných odpovědích — stačí je uvést v `$select`.
|
||||||
|
|
||||||
|
| Prefix | Oblast |
|
||||||
|
|---|---|
|
||||||
|
| `ADN_` / `U_ADN_*` | Evidence majetku (karty majetku, odpisy daňové/účetní, pohyby, inventury, ceniny, pronájmy) |
|
||||||
|
| `DFX_` / `U_DFX_*` | Intrastat a evidence majetku CZ (hlášení, nomenklatury, číselníky, verze addonu) |
|
||||||
|
| `PVT_` / `U_PVT_*` | Intrastat (KN8 kódy, kurzy, statistické kódy, tisková data) |
|
||||||
|
| `VCZ_` / `U_VCZ_*` | Versino CZ – lokalizace a výroba: EET, elektronická banka, platební příkazy (KS/SS/VS), QR platba, kontrolní/souhrnné hlášení DPH, kalkulace, plánování výroby (`MPK*`), pracoviště (`MWPL*`), trasování výroby (`MTTRC`), pick listy, tiskové reporty |
|
||||||
|
| `B1SYS_` | Systémová CZ lokalizace (EET pole PKP/BKP na dokladech) |
|
||||||
|
| `BOE*`, `BOO*`, `BOQUOT`, `BOSETTINGS` | Outlook/Office integrace (sync kalendáře, kontaktů, šablony Word/Excel) |
|
||||||
|
| `VYROBNI_PLAN`, `VYROBNI_DAVKA` | Vlastní UDO pro výrobní plánování |
|
||||||
|
|
||||||
|
Často užitečná pole na dokladech: `VCZ_5030` (číslo dokladu), `VCZ_7042` (variabilní symbol), `VCZ_6502`/`VCZ_7141` (KS/SS), `VCZ_9522`/`VCZ_9192` (vazba na zakázku odběratele a její řádek), `VCZ_5EE*` (EET), `DFX_*`/`PVT_*` (Intrastat), na artiklu `VCZ_5315/5316/5317` (popisy CZ/EN/RU) a `PVT_KINT` (kód Intrastat).
|
||||||
|
|
||||||
|
## Oprávnění SAP uživatele
|
||||||
|
|
||||||
|
Aktuální SAP uživatel **nemá oprávnění** číst `UserTablesMD` a `UserObjectsMD` (v `/api/system/info` se vrací `{ "error": "The logged-on user does not have permission..." }`). Data UDO tabulek jsou přesto dostupná přes jejich `U_*` entity sety; pro čtení metadat UDT/UDO je potřeba uživateli doplnit oprávnění v SAP (General → User-Defined Objects/Tables).
|
||||||
|
|
||||||
|
## Práce s customizacemi přes API
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/entities # seznam všech entity setů
|
||||||
|
GET /api/entities/U_VCZ_MKONF?$top=10 # konfigurátor výroby
|
||||||
|
GET /api/entities/U_ADN_MEM/all # všechny karty majetku
|
||||||
|
GET /api/entities/VYROBNI_PLAN/'001' # klíč Code (string, default)
|
||||||
|
GET /api/entities/ProductionOrders/123?idType=number
|
||||||
|
GET /api/items/A00001 # UDF pole jsou součástí odpovědi
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# SAP Business One Service Layer Connector (POLSTRIN)
|
||||||
|
|
||||||
|
Connector komunikuje výhradně přes SAP Business One Service Layer `/b1s/v1` instalace POLSTRIN
|
||||||
|
(`https://ws.polstrin.cz:50000`). Specifika instalace jsou v [polstrin-specifika.md](polstrin-specifika.md).
|
||||||
|
|
||||||
|
Hlavní vlastnosti:
|
||||||
|
|
||||||
|
- login/logout přes Service Layer
|
||||||
|
- správa `B1SESSION` a `ROUTEID`, jedna sdílená session mezi requesty
|
||||||
|
- automatický re-login po expiraci session
|
||||||
|
- SAP credentials výhradně z environment variables (AppFactory secrets), ne z requestů
|
||||||
|
- volitelná ochrana `/api` rout hlavičkou `X-Api-Key` (secret `API_KEY`)
|
||||||
|
- OData parametry `$select`, `$filter`, `$top`, `$skip`, `$orderby`
|
||||||
|
- stránkování přes `odata.nextLink` a `@odata.nextLink`
|
||||||
|
- generický přístup k libovolnému entity setu přes `/api/entities/...` (POLSTRIN UDO tabulky `U_*`)
|
||||||
|
- retry pro dočasné chyby
|
||||||
|
- zod validace konfigurace a hlavních response tvarů
|
||||||
|
|
||||||
|
Bezpečnostní pravidla:
|
||||||
|
|
||||||
|
- konfigurace se čte výhradně z environment variables (`SAP_B1_*`, `API_KEY`)
|
||||||
|
- SAP credentials se nikdy nepřijímají v requestech, nelogují a nevrací v response
|
||||||
|
- hesla, cookies a session tokeny se nelogují
|
||||||
|
- testy nepoužívají reálný SAP přístup
|
||||||
|
- pro self-signed certifikáty je dostupné `rejectUnauthorized`, ale produkčně se doporučuje důvěryhodný certifikát
|
||||||
|
|
||||||
|
## Detaily komunikace se Service Layer
|
||||||
|
|
||||||
|
- **Login** – `POST /b1s/v1/Login` s `{ CompanyDB, UserName, Password, Language? }`.
|
||||||
|
`Language` se posílá jako celé číslo (Service Layer očekává `Edm.Int32`); nenumerická
|
||||||
|
hodnota se vynechá. Z odpovědi se čtou cookies `B1SESSION` a `ROUTEID` (s fallbackem na
|
||||||
|
`SessionId` v body) a `SessionTimeout` (minuty) pro výpočet expirace.
|
||||||
|
- **Autentizované requesty** – posílají `Cookie: B1SESSION=…; ROUTEID=…`. Při `401`
|
||||||
|
connector jednou provede re-login a request zopakuje; re-login nesnižuje retry budget,
|
||||||
|
takže funguje i při `SAP_B1_RETRY_COUNT=0`.
|
||||||
|
- **Logout** – `POST /b1s/v1/Logout` se posílá **s aktivní session cookie**, jinak by
|
||||||
|
Service Layer nevěděl, kterou session ukončit, a nechal by ji běžet až do timeoutu.
|
||||||
|
- **Verze a system info** – login response obsahuje `Version` (např. `1000230` = SAP B1
|
||||||
|
10.0 PL 23) a `SessionTimeout`; connector si je ukládá (`client.loginInfo`, přežije
|
||||||
|
logout) a HTTP endpoint `POST /api/session/login` je vrací v odpovědi.
|
||||||
|
`GET /api/system/info` navíc vrátí přehled instalace: service document (`GET /b1s/v1/`
|
||||||
|
→ dostupné entity sety), `UserFieldsMD` (UDF), `UserTablesMD` (UDT), `UserObjectsMD`
|
||||||
|
(UDO) a `CompanyService_GetAdminInfo`. Sekce, na kterou SAP uživatel nemá práva, se
|
||||||
|
vrátí jako `{ "error": "…" }`, aby jedno chybějící oprávnění neshodilo celý přehled
|
||||||
|
(chyba je v odpovědi vidět, nejde o tiché selhání).
|
||||||
|
- **Stránkování** – následuje `odata.nextLink` i `@odata.nextLink`. U absolutních
|
||||||
|
nextLinků se zachová i query string (`$skip` apod.), takže `listAll` nezacyklí.
|
||||||
|
|
||||||
|
## Reverse proxy a Swagger
|
||||||
|
|
||||||
|
Aplikace běží za AppFactory proxy na `/apps/<app-id>` (Caddy `handle_path` prefix před
|
||||||
|
předáním do containeru odstraní, takže container vidí routy bez prefixu a z requestu
|
||||||
|
veřejnou cestu nelze odvodit). Stejný přístup jako sousední služba `google-service`:
|
||||||
|
|
||||||
|
- `/docs` se servíruje **přímo jako HTML** (`GET /docs`), bez `swagger-ui-express` a bez
|
||||||
|
statického middleware – proto **nevzniká žádný redirect `/docs` → `/docs/`**, který by za
|
||||||
|
proxy zahodil prefix `/apps/<app-id>` (to byla příčina bílé stránky).
|
||||||
|
- Swagger UI assety se načítají z CDN (`unpkg.com/swagger-ui-dist@5`).
|
||||||
|
- Spec URL je `ROOT_PATH + /openapi.json`, takže odkazuje na veřejné
|
||||||
|
`/apps/<app-id>/openapi.json`.
|
||||||
|
- `servers[0].url` se nastaví z `ROOT_PATH` (jinak `/`), takže Swagger `Try it out` volá
|
||||||
|
`…/apps/<app-id>/api/<resource>`.
|
||||||
|
|
||||||
|
| Kontrola | Lokálně (bez ROOT_PATH) | S `ROOT_PATH=/apps/sap-bo` |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /health` | 200 | 200 |
|
||||||
|
| `GET /docs` (Swagger UI) | 200, žádný redirect | 200, žádný redirect |
|
||||||
|
| `GET /openapi.json` | 200 | 200 |
|
||||||
|
| spec URL v `/docs` | `/openapi.json` | `/apps/sap-bo/openapi.json` |
|
||||||
|
| `servers[0].url` v OpenAPI | `/` | `/apps/sap-bo` |
|
||||||
Generated
+2431
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -1,16 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "polstrin-sap",
|
"name": "polstrin-sap",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"description": "SAP Business One Service Layer connector dedicated to the POLSTRIN DESIGN installation (credentials via environment secrets).",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.3"
|
"axios": "^1.7.9",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.30",
|
"@types/node": "^20.11.30",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { SapB1LoginInfo } from "./auth/SessionManager";
|
||||||
|
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
|
||||||
|
import { SapB1Config } from "./config";
|
||||||
|
import { BusinessPartnersResource } from "./resources/BusinessPartners";
|
||||||
|
import { DeliveryNotesResource, InvoicesResource, OrdersResource, PurchaseOrdersResource } from "./resources/Documents";
|
||||||
|
import { ItemsResource } from "./resources/Items";
|
||||||
|
import { StockTransfersResource } from "./resources/StockTransfers";
|
||||||
|
import { ODataResponse } from "./types/odata";
|
||||||
|
|
||||||
|
export interface SapB1SectionError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SapB1SystemInfo {
|
||||||
|
serviceLayer: SapB1LoginInfo;
|
||||||
|
entitySets: string[] | SapB1SectionError;
|
||||||
|
userFields: unknown[] | SapB1SectionError;
|
||||||
|
userTables: unknown[] | SapB1SectionError;
|
||||||
|
userObjects: unknown[] | SapB1SectionError;
|
||||||
|
adminInfo: unknown | SapB1SectionError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SapBusinessOneServiceLayer {
|
||||||
|
public readonly client: SapB1Client;
|
||||||
|
public readonly businessPartners: BusinessPartnersResource;
|
||||||
|
public readonly items: ItemsResource;
|
||||||
|
public readonly orders: OrdersResource;
|
||||||
|
public readonly invoices: InvoicesResource;
|
||||||
|
public readonly purchaseOrders: PurchaseOrdersResource;
|
||||||
|
public readonly deliveryNotes: DeliveryNotesResource;
|
||||||
|
public readonly stockTransfers: StockTransfersResource;
|
||||||
|
|
||||||
|
public constructor(config: SapB1Config, options: SapB1ClientOptions = {}) {
|
||||||
|
this.client = new SapB1Client(config, options);
|
||||||
|
this.businessPartners = new BusinessPartnersResource(this.client);
|
||||||
|
this.items = new ItemsResource(this.client);
|
||||||
|
this.orders = new OrdersResource(this.client);
|
||||||
|
this.invoices = new InvoicesResource(this.client);
|
||||||
|
this.purchaseOrders = new PurchaseOrdersResource(this.client);
|
||||||
|
this.deliveryNotes = new DeliveryNotesResource(this.client);
|
||||||
|
this.stockTransfers = new StockTransfersResource(this.client);
|
||||||
|
}
|
||||||
|
|
||||||
|
public login(): Promise<unknown> {
|
||||||
|
return this.client.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout(): Promise<void> {
|
||||||
|
return this.client.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot overview of the connected SAP Business One installation: Service Layer
|
||||||
|
* version (from the login response), available entity sets (service document) and
|
||||||
|
* the customization metadata (UserFieldsMD/UserTablesMD/UserObjectsMD) plus company
|
||||||
|
* admin info. Sections the SAP user cannot read come back as { error } so one missing
|
||||||
|
* permission does not fail the whole overview.
|
||||||
|
*/
|
||||||
|
public async getSystemInfo(): Promise<SapB1SystemInfo> {
|
||||||
|
await this.client.login();
|
||||||
|
|
||||||
|
const [entitySets, userFields, userTables, userObjects, adminInfo] = await Promise.all([
|
||||||
|
this.loadSection(() =>
|
||||||
|
this.client
|
||||||
|
.get<ODataResponse<{ name: string }>>("/")
|
||||||
|
.then((document) => document.value.map((entitySet) => entitySet.name).sort())
|
||||||
|
),
|
||||||
|
this.loadSection(() =>
|
||||||
|
this.client.getAll<unknown>("UserFieldsMD", {
|
||||||
|
select: ["TableName", "FieldID", "Name", "Type", "SubType", "Description"]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
this.loadSection(() =>
|
||||||
|
this.client.getAll<unknown>("UserTablesMD", {
|
||||||
|
select: ["TableName", "TableDescription", "TableType"]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
this.loadSection(() =>
|
||||||
|
this.client.getAll<unknown>("UserObjectsMD", {
|
||||||
|
select: ["Code", "Name", "TableName", "ObjectType"]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
this.loadSection(() => this.client.post<unknown>("CompanyService_GetAdminInfo"))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceLayer: this.client.loginInfo ?? {},
|
||||||
|
entitySets,
|
||||||
|
userFields,
|
||||||
|
userTables,
|
||||||
|
userObjects,
|
||||||
|
adminInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSection<T>(load: () => Promise<T>): Promise<T | SapB1SectionError> {
|
||||||
|
try {
|
||||||
|
return await load();
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import "../client/axiosTypes";
|
||||||
|
import { AxiosInstance } from "axios";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SapB1Config } from "../config";
|
||||||
|
import { SapB1Error, toSapB1Error } from "../errors/SapB1Error";
|
||||||
|
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
||||||
|
|
||||||
|
const LoginResponseSchema = z.object({
|
||||||
|
SessionId: z.string().optional(),
|
||||||
|
Version: z.string().optional(),
|
||||||
|
SessionTimeout: z.number().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SapB1Session {
|
||||||
|
b1session: string;
|
||||||
|
routeId?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SapB1LoginInfo {
|
||||||
|
version?: string;
|
||||||
|
sessionTimeoutMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
private session?: SapB1Session;
|
||||||
|
private loginPromise?: Promise<SapB1Session>;
|
||||||
|
private lastLoginInfo?: SapB1LoginInfo;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly http: AxiosInstance,
|
||||||
|
private readonly config: SapB1Config,
|
||||||
|
private readonly logger: SapB1Logger = noopLogger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public get currentSession(): SapB1Session | undefined {
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Survives logout on purpose: the login/logout roundtrip in the HTTP layer still
|
||||||
|
// needs to report which Service Layer version answered.
|
||||||
|
public get loginInfo(): SapB1LoginInfo | undefined {
|
||||||
|
return this.lastLoginInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSession(forceRefresh = false): Promise<SapB1Session> {
|
||||||
|
if (!forceRefresh && this.session && !this.isExpired(this.session)) {
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.loginPromise) {
|
||||||
|
this.loginPromise = this.login().finally(() => {
|
||||||
|
this.loginPromise = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login(): Promise<SapB1Session> {
|
||||||
|
this.logger.info("SAP B1 login request", sanitizeLogMeta({ baseUrl: this.config.baseUrl, companyDB: this.config.companyDB }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.http.post(
|
||||||
|
"/Login",
|
||||||
|
{
|
||||||
|
CompanyDB: this.config.companyDB,
|
||||||
|
UserName: this.config.username,
|
||||||
|
Password: this.config.password,
|
||||||
|
// Service Layer expects Language as an integer language code (Edm.Int32).
|
||||||
|
Language: this.normalizeLanguage(this.config.language)
|
||||||
|
},
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginData = LoginResponseSchema.parse(response.data);
|
||||||
|
const cookies = this.extractCookies(response.headers["set-cookie"]);
|
||||||
|
const b1session = cookies.B1SESSION || loginData.SessionId;
|
||||||
|
|
||||||
|
if (!b1session) {
|
||||||
|
throw new SapB1Error({ message: "SAP B1 login did not return B1SESSION cookie or SessionId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMinutes = typeof loginData.SessionTimeout === "number" ? loginData.SessionTimeout : 30;
|
||||||
|
this.lastLoginInfo = {
|
||||||
|
version: loginData.Version,
|
||||||
|
sessionTimeoutMinutes: loginData.SessionTimeout
|
||||||
|
};
|
||||||
|
this.session = {
|
||||||
|
b1session,
|
||||||
|
routeId: cookies.ROUTEID,
|
||||||
|
expiresAt: Date.now() + Math.max(timeoutMinutes - 1, 1) * 60_000
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.session;
|
||||||
|
} catch (error) {
|
||||||
|
// Route login failures through the shared normalizer so the SAP status/code and
|
||||||
|
// rejection message survive into the response. That is what lets the caller tell a
|
||||||
|
// SAP-side rejection (wrong CompanyDB/user/password -> HTTP 401/400 with a SAP body)
|
||||||
|
// apart from a transport problem (wrong BaseUrl/port, TLS, timeout -> no HTTP status).
|
||||||
|
const sapError = toSapB1Error(error, "POST", "/Login", "SAP B1 login failed");
|
||||||
|
this.logger.error(
|
||||||
|
"SAP B1 login failed",
|
||||||
|
sanitizeLogMeta({
|
||||||
|
baseUrl: this.config.baseUrl,
|
||||||
|
companyDB: this.config.companyDB,
|
||||||
|
status: sapError.status,
|
||||||
|
code: sapError.code,
|
||||||
|
message: sapError.message
|
||||||
|
})
|
||||||
|
);
|
||||||
|
throw sapError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async logout(): Promise<void> {
|
||||||
|
if (!this.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieHeader = this.buildCookieHeader(this.session);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Logout must carry the active session cookie, otherwise the Service Layer
|
||||||
|
// cannot identify which session to terminate and leaves it open until timeout.
|
||||||
|
await this.http.post("/Logout", undefined, {
|
||||||
|
skipAuth: true,
|
||||||
|
headers: { Cookie: cookieHeader }
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.session = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildCookieHeader(session: SapB1Session): string {
|
||||||
|
const parts = [`B1SESSION=${session.b1session}`];
|
||||||
|
|
||||||
|
if (session.routeId) {
|
||||||
|
parts.push(`ROUTEID=${session.routeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSession(): void {
|
||||||
|
this.session = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(language: string | undefined): number | undefined {
|
||||||
|
if (language === undefined || language === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(language);
|
||||||
|
return Number.isInteger(numeric) ? numeric : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isExpired(session: SapB1Session): boolean {
|
||||||
|
return session.expiresAt !== undefined && session.expiresAt <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractCookies(setCookieHeader: string[] | string | undefined): Record<string, string> {
|
||||||
|
const headers = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : [];
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const header of headers) {
|
||||||
|
const [nameValue] = header.split(";");
|
||||||
|
const separatorIndex = nameValue.indexOf("=");
|
||||||
|
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
cookies[nameValue.slice(0, separatorIndex)] = nameValue.slice(separatorIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import "./axiosTypes";
|
||||||
|
import https from "node:https";
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SapB1LoginInfo, SessionManager } from "../auth/SessionManager";
|
||||||
|
import { SapB1Config, SapB1ConfigSchema } from "../config";
|
||||||
|
import { SapB1Error, toSapB1Error } from "../errors/SapB1Error";
|
||||||
|
import { ODataQuery, ODataResponse } from "../types/odata";
|
||||||
|
import { buildODataParams, extractNextLink } from "./odata";
|
||||||
|
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
||||||
|
import { sleep } from "../utils/sleep";
|
||||||
|
|
||||||
|
export interface SapB1ClientOptions {
|
||||||
|
logger?: SapB1Logger;
|
||||||
|
http?: AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SapB1Client {
|
||||||
|
private readonly http: AxiosInstance;
|
||||||
|
private readonly logger: SapB1Logger;
|
||||||
|
public readonly sessionManager: SessionManager;
|
||||||
|
public readonly config: SapB1Config;
|
||||||
|
|
||||||
|
public constructor(config: SapB1Config, options: SapB1ClientOptions = {}) {
|
||||||
|
this.config = SapB1ConfigSchema.parse(config);
|
||||||
|
this.logger = options.logger ?? noopLogger;
|
||||||
|
this.http =
|
||||||
|
options.http ??
|
||||||
|
axios.create({
|
||||||
|
baseURL: this.buildServiceLayerBaseUrl(this.config.baseUrl),
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
httpsAgent: new https.Agent({ rejectUnauthorized: this.config.rejectUnauthorized })
|
||||||
|
});
|
||||||
|
this.sessionManager = new SessionManager(this.http, this.config, this.logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public login(): Promise<unknown> {
|
||||||
|
return this.sessionManager.getSession(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get loginInfo(): SapB1LoginInfo | undefined {
|
||||||
|
return this.sessionManager.loginInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout(): Promise<void> {
|
||||||
|
return this.sessionManager.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get<T>(path: string, query?: ODataQuery, schema?: z.ZodType<T>): Promise<T> {
|
||||||
|
return this.request<T>("GET", path, undefined, { params: buildODataParams(query), schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
public post<T>(path: string, data?: unknown, schema?: z.ZodType<T>): Promise<T> {
|
||||||
|
return this.request<T>("POST", path, data, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
public patch<T>(path: string, data?: unknown, schema?: z.ZodType<T>): Promise<T> {
|
||||||
|
return this.request<T>("PATCH", path, data, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete<T>(path: string, schema?: z.ZodType<T>): Promise<T> {
|
||||||
|
return this.request<T>("DELETE", path, undefined, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll<T>(path: string, query: ODataQuery = {}, schema?: z.ZodType<T>): Promise<T[]> {
|
||||||
|
const items: T[] = [];
|
||||||
|
let nextPath: string | undefined = path;
|
||||||
|
let nextQuery: ODataQuery | undefined = query;
|
||||||
|
|
||||||
|
while (nextPath) {
|
||||||
|
const response: ODataResponse<T> = await this.get<ODataResponse<T>>(nextPath, nextQuery);
|
||||||
|
const value = schema ? z.array(schema).parse(response.value) : response.value;
|
||||||
|
items.push(...value);
|
||||||
|
const nextLink: string | undefined = extractNextLink(response);
|
||||||
|
nextPath = nextLink ? this.normalizeNextLink(nextLink) : undefined;
|
||||||
|
nextQuery = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
method: Method,
|
||||||
|
path: string,
|
||||||
|
data?: unknown,
|
||||||
|
options: AxiosRequestConfig & { schema?: z.ZodType<T> } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const requestConfig: AxiosRequestConfig = {
|
||||||
|
...options,
|
||||||
|
method,
|
||||||
|
url: path,
|
||||||
|
data,
|
||||||
|
headers: {
|
||||||
|
...(options.headers || {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let reloginAttempted = false;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= this.config.retryCount; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if (!requestConfig.skipAuth) {
|
||||||
|
const session = await this.sessionManager.getSession();
|
||||||
|
requestConfig.headers = {
|
||||||
|
...(requestConfig.headers || {}),
|
||||||
|
Cookie: this.sessionManager.buildCookieHeader(session)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("SAP B1 request", sanitizeLogMeta({ method, url: path, attempt }));
|
||||||
|
const response = await this.http.request(requestConfig);
|
||||||
|
return options.schema ? options.schema.parse(response.data) : (response.data as T);
|
||||||
|
} catch (error) {
|
||||||
|
const sapError = toSapB1Error(error, method, path);
|
||||||
|
const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && !reloginAttempted;
|
||||||
|
|
||||||
|
if (canRelogin) {
|
||||||
|
// A single re-login on session expiry must not eat into the retry budget,
|
||||||
|
// so step the counter back and retry the request with a fresh session.
|
||||||
|
reloginAttempted = true;
|
||||||
|
this.sessionManager.clearSession();
|
||||||
|
await this.sessionManager.getSession(true);
|
||||||
|
attempt -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestConfig.skipRetry && sapError.retryable && attempt < this.config.retryCount) {
|
||||||
|
await sleep(this.config.retryDelayMs * (attempt + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw sapError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SapB1Error({ message: "SAP B1 request failed after retries", method, url: path });
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildServiceLayerBaseUrl(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||||
|
return trimmed.endsWith("/b1s/v1") ? trimmed : `${trimmed}/b1s/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeNextLink(nextLink: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(nextLink);
|
||||||
|
return parsed.pathname.replace(/^.*\/b1s\/v1\/?/, "") + parsed.search;
|
||||||
|
} catch {
|
||||||
|
return nextLink.replace(/^\/?b1s\/v1\/?/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import "axios";
|
||||||
|
|
||||||
|
declare module "axios" {
|
||||||
|
export interface AxiosRequestConfig {
|
||||||
|
skipAuth?: boolean;
|
||||||
|
skipRetry?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ODataQuery, ODataQuerySchema } from "../types/odata";
|
||||||
|
|
||||||
|
export function buildODataParams(query: ODataQuery = {}): Record<string, string | number> {
|
||||||
|
const parsed = ODataQuerySchema.parse(query);
|
||||||
|
const params: Record<string, string | number> = {};
|
||||||
|
|
||||||
|
if (parsed.select?.length) {
|
||||||
|
params.$select = parsed.select.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.filter) {
|
||||||
|
params.$filter = parsed.filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.top !== undefined) {
|
||||||
|
params.$top = parsed.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.skip !== undefined) {
|
||||||
|
params.$skip = parsed.skip;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.orderby) {
|
||||||
|
params.$orderby = Array.isArray(parsed.orderby) ? parsed.orderby.join(",") : parsed.orderby;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractNextLink<T>(response: {
|
||||||
|
"odata.nextLink"?: string;
|
||||||
|
"@odata.nextLink"?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
return response["@odata.nextLink"] || response["odata.nextLink"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const booleanFromEnv = z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SapB1ConfigSchema = z.object({
|
||||||
|
baseUrl: z.string().url(),
|
||||||
|
companyDB: z.string().min(1),
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
language: z.string().min(1).optional(),
|
||||||
|
timeout: z.number().int().positive().default(30_000),
|
||||||
|
rejectUnauthorized: z.boolean().default(true),
|
||||||
|
retryCount: z.number().int().min(0).default(2),
|
||||||
|
retryDelayMs: z.number().int().min(0).default(250)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SapB1Config = z.infer<typeof SapB1ConfigSchema>;
|
||||||
|
|
||||||
|
export function loadSapB1ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): SapB1Config {
|
||||||
|
const rejectUnauthorized = booleanFromEnv.parse(env.SAP_B1_REJECT_UNAUTHORIZED);
|
||||||
|
|
||||||
|
return SapB1ConfigSchema.parse({
|
||||||
|
baseUrl: env.SAP_B1_BASE_URL,
|
||||||
|
companyDB: env.SAP_B1_COMPANY_DB,
|
||||||
|
username: env.SAP_B1_USERNAME,
|
||||||
|
password: env.SAP_B1_PASSWORD,
|
||||||
|
language: env.SAP_B1_LANGUAGE || undefined,
|
||||||
|
timeout: env.SAP_B1_TIMEOUT_MS ? Number(env.SAP_B1_TIMEOUT_MS) : undefined,
|
||||||
|
rejectUnauthorized,
|
||||||
|
retryCount: env.SAP_B1_RETRY_COUNT ? Number(env.SAP_B1_RETRY_COUNT) : undefined,
|
||||||
|
retryDelayMs: env.SAP_B1_RETRY_DELAY_MS ? Number(env.SAP_B1_RETRY_DELAY_MS) : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import axios, { AxiosError, Method } from "axios";
|
||||||
|
|
||||||
|
export interface SapB1ErrorDetails {
|
||||||
|
status?: number;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
retryable?: boolean;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SapB1Error extends Error {
|
||||||
|
public readonly status?: number;
|
||||||
|
public readonly code?: string;
|
||||||
|
public readonly method?: string;
|
||||||
|
public readonly url?: string;
|
||||||
|
public readonly retryable: boolean;
|
||||||
|
public override readonly cause?: unknown;
|
||||||
|
|
||||||
|
public constructor(details: SapB1ErrorDetails) {
|
||||||
|
super(details.message || "SAP Business One Service Layer request failed");
|
||||||
|
this.name = "SapB1Error";
|
||||||
|
this.status = details.status;
|
||||||
|
this.code = details.code;
|
||||||
|
this.method = details.method;
|
||||||
|
this.url = details.url;
|
||||||
|
this.retryable = details.retryable ?? false;
|
||||||
|
this.cause = details.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared normalizer so every SAP call (including login) surfaces the SAP status/code
|
||||||
|
// and message when the Service Layer actively rejected the request, and falls back to
|
||||||
|
// the transport-level message (ECONNREFUSED, timeout, TLS, ...) otherwise. This is what
|
||||||
|
// lets a caller tell "SAP rejected it" apart from "we never reached SAP".
|
||||||
|
export function toSapB1Error(
|
||||||
|
error: unknown,
|
||||||
|
method?: Method,
|
||||||
|
path?: string,
|
||||||
|
fallbackMessage = "SAP B1 request failed"
|
||||||
|
): SapB1Error {
|
||||||
|
if (error instanceof SapB1Error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const axiosError = error as AxiosError<{ error?: { code?: string; message?: { value?: string } | string } }>;
|
||||||
|
const status = axiosError.response?.status;
|
||||||
|
const sapError = axiosError.response?.data?.error;
|
||||||
|
const message =
|
||||||
|
typeof sapError?.message === "string"
|
||||||
|
? sapError.message
|
||||||
|
: sapError?.message?.value || axiosError.message || fallbackMessage;
|
||||||
|
|
||||||
|
return new SapB1Error({
|
||||||
|
status,
|
||||||
|
code: sapError?.code,
|
||||||
|
message,
|
||||||
|
method,
|
||||||
|
url: path,
|
||||||
|
retryable: status === 408 || status === 429 || (status !== undefined && status >= 500),
|
||||||
|
cause: error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SapB1Error({ message: fallbackMessage, method, url: path, cause: error });
|
||||||
|
}
|
||||||
+833
-20
@@ -1,35 +1,848 @@
|
|||||||
import express from "express";
|
import express, { Request, Response } from "express";
|
||||||
|
import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
|
||||||
|
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
|
||||||
|
import { buildODataParams, extractNextLink } from "./client/odata";
|
||||||
|
import { loadSapB1ConfigFromEnv } from "./config";
|
||||||
|
import { SapB1Error } from "./errors/SapB1Error";
|
||||||
|
import { ODataQuery } from "./types/odata";
|
||||||
|
|
||||||
|
export { SessionManager, SapB1Session, SapB1LoginInfo } from "./auth/SessionManager";
|
||||||
|
export { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
|
||||||
|
export { buildODataParams, extractNextLink } from "./client/odata";
|
||||||
|
export { loadSapB1ConfigFromEnv, SapB1Config, SapB1ConfigSchema } from "./config";
|
||||||
|
export { SapB1Error } from "./errors/SapB1Error";
|
||||||
|
export { SapBusinessOneServiceLayer, SapB1SystemInfo, SapB1SectionError } from "./SapBusinessOneServiceLayer";
|
||||||
|
export * from "./types/entities";
|
||||||
|
export * from "./types/odata";
|
||||||
|
|
||||||
|
type ResourceName =
|
||||||
|
| "businessPartners"
|
||||||
|
| "items"
|
||||||
|
| "orders"
|
||||||
|
| "invoices"
|
||||||
|
| "purchaseOrders"
|
||||||
|
| "deliveryNotes"
|
||||||
|
| "stockTransfers";
|
||||||
|
|
||||||
|
interface ResourceRoute {
|
||||||
|
slug: string;
|
||||||
|
tag: string;
|
||||||
|
property: ResourceName;
|
||||||
|
sapEntitySet: string;
|
||||||
|
idName: string;
|
||||||
|
idType: "string" | "number";
|
||||||
|
deleteSupported: boolean;
|
||||||
|
sampleCreate: Record<string, unknown>;
|
||||||
|
sampleUpdate: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", true);
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
const rootPath = process.env.ROOT_PATH || "";
|
const rootPath = normalizeRootPath(process.env.ROOT_PATH || "");
|
||||||
|
const serviceName = "SAP Business One (POLSTRIN)";
|
||||||
|
const serviceId = "polstrin-sap";
|
||||||
|
const companyName = "POLSTRIN DESIGN s.r.o.";
|
||||||
|
|
||||||
|
function normalizeRootPath(value: string): string {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const withLeadingSlash = value.startsWith("/") ? value : "/" + value;
|
||||||
|
return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRootPath(path: string): string {
|
||||||
|
return rootPath + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceRoutes: ResourceRoute[] = [
|
||||||
|
{
|
||||||
|
slug: "business-partners",
|
||||||
|
tag: "BusinessPartners",
|
||||||
|
property: "businessPartners",
|
||||||
|
sapEntitySet: "BusinessPartners",
|
||||||
|
idName: "CardCode",
|
||||||
|
idType: "string",
|
||||||
|
deleteSupported: true,
|
||||||
|
sampleCreate: { CardCode: "C001", CardName: "Example customer", CardType: "cCustomer" },
|
||||||
|
sampleUpdate: { CardName: "Updated customer name" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "items",
|
||||||
|
tag: "Items",
|
||||||
|
property: "items",
|
||||||
|
sapEntitySet: "Items",
|
||||||
|
idName: "ItemCode",
|
||||||
|
idType: "string",
|
||||||
|
deleteSupported: true,
|
||||||
|
sampleCreate: { ItemCode: "A00001", ItemName: "Example item", InventoryItem: "tYES" },
|
||||||
|
sampleUpdate: { ItemName: "Updated item name" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "orders",
|
||||||
|
tag: "Orders",
|
||||||
|
property: "orders",
|
||||||
|
sapEntitySet: "Orders",
|
||||||
|
idName: "DocEntry",
|
||||||
|
idType: "number",
|
||||||
|
deleteSupported: false,
|
||||||
|
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
||||||
|
sampleUpdate: { Comments: "Updated by connector" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "invoices",
|
||||||
|
tag: "Invoices",
|
||||||
|
property: "invoices",
|
||||||
|
sapEntitySet: "Invoices",
|
||||||
|
idName: "DocEntry",
|
||||||
|
idType: "number",
|
||||||
|
deleteSupported: false,
|
||||||
|
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
||||||
|
sampleUpdate: { Comments: "Updated by connector" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "purchase-orders",
|
||||||
|
tag: "PurchaseOrders",
|
||||||
|
property: "purchaseOrders",
|
||||||
|
sapEntitySet: "PurchaseOrders",
|
||||||
|
idName: "DocEntry",
|
||||||
|
idType: "number",
|
||||||
|
deleteSupported: false,
|
||||||
|
sampleCreate: { CardCode: "V001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
||||||
|
sampleUpdate: { Comments: "Updated by connector" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "delivery-notes",
|
||||||
|
tag: "DeliveryNotes",
|
||||||
|
property: "deliveryNotes",
|
||||||
|
sapEntitySet: "DeliveryNotes",
|
||||||
|
idName: "DocEntry",
|
||||||
|
idType: "number",
|
||||||
|
deleteSupported: false,
|
||||||
|
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
||||||
|
sampleUpdate: { Comments: "Updated by connector" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "stock-transfers",
|
||||||
|
tag: "StockTransfers",
|
||||||
|
property: "stockTransfers",
|
||||||
|
sapEntitySet: "StockTransfers",
|
||||||
|
idName: "DocEntry",
|
||||||
|
idType: "number",
|
||||||
|
deleteSupported: false,
|
||||||
|
sampleCreate: { StockTransferLines: [{ ItemCode: "A00001", Quantity: 1, FromWarehouseCode: "01", WarehouseCode: "02" }] },
|
||||||
|
sampleUpdate: { Comments: "Updated by connector" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
|
||||||
|
// Optional shared-secret protection of the /api routes: when the API_KEY secret is
|
||||||
|
// configured through the AppFactory portal, every /api request must send the same
|
||||||
|
// value in the X-Api-Key header. Docs, health and metadata stay public.
|
||||||
|
app.use((req: Request, res: Response, next: () => void) => {
|
||||||
|
const apiKey = process.env.API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey || !req.path.startsWith("/api/")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.get("X-Api-Key") === apiKey) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ error: { type: "AuthError", message: "Missing or invalid X-Api-Key header" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// SAP credentials for the POLSTRIN company database come exclusively from
|
||||||
|
// environment variables (AppFactory secrets, see AGENTS.md "Secrets v parametrech"),
|
||||||
|
// never from request headers or bodies. One shared instance keeps the Service Layer
|
||||||
|
// session alive between requests; SessionManager re-logins on expiry or 401.
|
||||||
|
let sharedSap: SapBusinessOneServiceLayer | undefined;
|
||||||
|
|
||||||
|
function getSap(): SapBusinessOneServiceLayer {
|
||||||
|
if (!sharedSap) {
|
||||||
|
sharedSap = new SapBusinessOneServiceLayer(loadSapB1ConfigFromEnv());
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharedSap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSap<T>(action: (sap: SapBusinessOneServiceLayer) => Promise<T>): Promise<T> {
|
||||||
|
return action(getSap());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseODataQuery(req: Request): ODataQuery {
|
||||||
|
const query: ODataQuery = {};
|
||||||
|
const select = req.query.$select ?? req.query.select;
|
||||||
|
const filter = req.query.$filter ?? req.query.filter;
|
||||||
|
const top = req.query.$top ?? req.query.top;
|
||||||
|
const skip = req.query.$skip ?? req.query.skip;
|
||||||
|
const orderby = req.query.$orderby ?? req.query.orderby;
|
||||||
|
|
||||||
|
if (typeof select === "string" && select.trim()) {
|
||||||
|
query.select = select.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter === "string" && filter.trim()) {
|
||||||
|
query.filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof top === "string" && top.trim()) {
|
||||||
|
query.top = Number(top);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof skip === "string" && skip.trim()) {
|
||||||
|
query.skip = Number(skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof orderby === "string" && orderby.trim()) {
|
||||||
|
query.orderby = orderby;
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(route: ResourceRoute, rawId: string): string | number {
|
||||||
|
return route.idType === "number" ? Number(rawId) : rawId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeResource(sap: SapBusinessOneServiceLayer, route: ResourceRoute) {
|
||||||
|
return sap[route.property];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asyncHandler(handler: (req: Request, res: Response) => Promise<void>) {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
handler(req, res).catch((error) => sendError(res, error));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(res: Response, error: unknown): void {
|
||||||
|
if (error instanceof SapB1Error) {
|
||||||
|
res.status(error.status || 502).json({
|
||||||
|
error: {
|
||||||
|
type: error.name,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
retryable: error.retryable
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
res.status(400).json({ error: { type: error.name, message: error.message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: { type: "Error", message: "Unexpected error" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceMetadata() {
|
||||||
|
return {
|
||||||
|
name: serviceName,
|
||||||
|
service: serviceId,
|
||||||
|
company: companyName,
|
||||||
|
status: "ok",
|
||||||
|
resources: resourceRoutes.map((route) => route.slug)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic access to any Service Layer entity set. Primarily meant for the POLSTRIN
|
||||||
|
// user-defined objects exposed as U_* entity sets (U_ADN_* assets add-on, U_DFX_*
|
||||||
|
// Intrastat, U_PVT_* Intrastat/PVT, U_VCZ_* Versino CZ localization and production,
|
||||||
|
// VYROBNI_PLAN, VYROBNI_DAVKA) that have no dedicated resource module.
|
||||||
|
const ENTITY_SET_PATTERN = /^[A-Za-z0-9_.]+$/;
|
||||||
|
|
||||||
|
function requireEntitySet(req: Request): string {
|
||||||
|
const entitySet = req.params.entitySet;
|
||||||
|
|
||||||
|
if (!ENTITY_SET_PATTERN.test(entitySet)) {
|
||||||
|
throw new Error(`Invalid entity set name: ${entitySet}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entitySet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntityKey(req: Request): string {
|
||||||
|
const raw = req.params.id;
|
||||||
|
|
||||||
|
if (req.query.idType === "number") {
|
||||||
|
return String(Number(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `'${raw.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addResourceEndpoints(prefix = "") {
|
||||||
|
app.post(
|
||||||
|
`${prefix}/api/session/login`,
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const serviceLayer = await withSap(async (sap) => {
|
||||||
|
await sap.login();
|
||||||
|
return sap.client.loginInfo ?? {};
|
||||||
|
});
|
||||||
|
res.json({ status: "ok", ...serviceLayer });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
`${prefix}/api/session/logout`,
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
await getSap().logout();
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${prefix}/api/system/info`,
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const result = await withSap((sap) => sap.getSystemInfo());
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${prefix}/api/entities`,
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const document = await withSap((sap) => sap.client.get<{ value: Array<{ name: string }> }>("/"));
|
||||||
|
res.json({ value: document.value.map((entitySet) => entitySet.name).sort() });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${prefix}/api/entities/:entitySet`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entitySet = requireEntitySet(req);
|
||||||
|
const result = await withSap((sap) => sap.client.get<unknown>(entitySet, parseODataQuery(req)));
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${prefix}/api/entities/:entitySet/all`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entitySet = requireEntitySet(req);
|
||||||
|
const result = await withSap((sap) => sap.client.getAll<unknown>(entitySet, parseODataQuery(req)));
|
||||||
|
res.json({ value: result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${prefix}/api/entities/:entitySet/:id`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entitySet = requireEntitySet(req);
|
||||||
|
const result = await withSap((sap) => sap.client.get<unknown>(`${entitySet}(${formatEntityKey(req)})`));
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
`${prefix}/api/entities/:entitySet`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entitySet = requireEntitySet(req);
|
||||||
|
const result = await withSap((sap) => sap.client.post<unknown>(entitySet, req.body));
|
||||||
|
res.status(201).json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
`${prefix}/api/entities/:entitySet/:id`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entitySet = requireEntitySet(req);
|
||||||
|
await withSap((sap) => sap.client.patch(`${entitySet}(${formatEntityKey(req)})`, req.body));
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
`${prefix}/api/entities/:entitySet/:id`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entitySet = requireEntitySet(req);
|
||||||
|
await withSap((sap) => sap.client.delete(`${entitySet}(${formatEntityKey(req)})`));
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const route of resourceRoutes) {
|
||||||
|
const base = `${prefix}/api/${route.slug}`;
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
base,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = await withSap<unknown>((sap) => routeResource(sap, route).list(parseODataQuery(req)));
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${base}/all`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = await withSap<unknown[]>((sap) => routeResource(sap, route).listAll(parseODataQuery(req)));
|
||||||
|
res.json({ value: result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
`${base}/:id`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = await withSap<unknown>((sap) => routeResource(sap, route).get(parseId(route, req.params.id) as never));
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
base,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = await withSap<unknown>((sap) => routeResource(sap, route).create(req.body as never));
|
||||||
|
res.status(201).json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
`${base}/:id`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await withSap((sap) => routeResource(sap, route).update(parseId(route, req.params.id) as never, req.body as never));
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (route.deleteSupported) {
|
||||||
|
app.delete(
|
||||||
|
`${base}/:id`,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await withSap((sap) => routeResource(sap, route).delete(parseId(route, req.params.id) as never));
|
||||||
|
res.status(204).send();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonParameters(route: ResourceRoute) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: route.idType },
|
||||||
|
description: `${route.idName} in SAP Business One ${route.sapEntitySet}.`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityParameters() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "X-Api-Key",
|
||||||
|
in: "header",
|
||||||
|
required: false,
|
||||||
|
schema: { type: "string", format: "password" },
|
||||||
|
description:
|
||||||
|
"Shared service key. Required only when the API_KEY secret is configured for this service. SAP credentials themselves are internal secrets (environment variables) and are never sent in requests."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitySetParameter() {
|
||||||
|
return {
|
||||||
|
name: "entitySet",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", pattern: "^[A-Za-z0-9_.]+$" },
|
||||||
|
description: "Service Layer entity set name, e.g. Quotations, ProductionOrders or U_VCZ_MKONF."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityIdParameters() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string" },
|
||||||
|
description: "Entity key. Strings are quoted automatically; UDO tables typically use Code."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "idType",
|
||||||
|
in: "query",
|
||||||
|
required: false,
|
||||||
|
schema: { type: "string", enum: ["string", "number"], default: "string" },
|
||||||
|
description: "Set to number for numeric keys such as DocEntry or AbsEntry."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function odataParameters() {
|
||||||
|
return [
|
||||||
|
{ name: "$select", in: "query", required: false, schema: { type: "string" }, description: "Comma separated OData field list." },
|
||||||
|
{ name: "$filter", in: "query", required: false, schema: { type: "string" }, description: "OData filter expression." },
|
||||||
|
{ name: "$top", in: "query", required: false, schema: { type: "integer", minimum: 1 }, description: "Maximum records to return." },
|
||||||
|
{ name: "$skip", in: "query", required: false, schema: { type: "integer", minimum: 0 }, description: "Records to skip." },
|
||||||
|
{ name: "$orderby", in: "query", required: false, schema: { type: "string" }, description: "OData order by expression." }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonBody(example: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { type: "object", additionalProperties: true },
|
||||||
|
example
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApiDocument(basePath = "") {
|
||||||
|
const paths: Record<string, unknown> = {
|
||||||
|
"/": {
|
||||||
|
get: {
|
||||||
|
tags: ["Service"],
|
||||||
|
summary: "Service metadata",
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Service status" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
get: {
|
||||||
|
tags: ["Service"],
|
||||||
|
summary: "Health check",
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Service is ready to accept traffic" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.json": {
|
||||||
|
get: {
|
||||||
|
tags: ["Service"],
|
||||||
|
summary: "OpenAPI schema",
|
||||||
|
responses: {
|
||||||
|
"200": { description: "OpenAPI document" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/session/login": {
|
||||||
|
post: {
|
||||||
|
tags: ["Session"],
|
||||||
|
summary: "Open SAP Business One Service Layer session",
|
||||||
|
description:
|
||||||
|
"Verifies connectivity to the POLSTRIN Service Layer. SAP credentials are configured as internal secrets (environment variables) via the AppFactory portal and are never accepted in requests, logged, or returned.",
|
||||||
|
parameters: securityParameters(),
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Session is available; returns SAP Service Layer version and session timeout",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
example: { status: "ok", version: "1000230", sessionTimeoutMinutes: 30 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": { description: "SAP login failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/session/logout": {
|
||||||
|
post: {
|
||||||
|
tags: ["Session"],
|
||||||
|
summary: "Logout from SAP Business One Service Layer",
|
||||||
|
parameters: securityParameters(),
|
||||||
|
responses: {
|
||||||
|
"204": { description: "Session closed or was not active" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/system/info": {
|
||||||
|
get: {
|
||||||
|
tags: ["System"],
|
||||||
|
summary: "SAP Business One system and customization overview",
|
||||||
|
description:
|
||||||
|
"Logs in and returns the Service Layer version (from the login response), the list of available entity sets (service document), user-defined fields (UserFieldsMD), user-defined tables (UserTablesMD), user-defined objects (UserObjectsMD) and company admin info (CompanyService_GetAdminInfo). Sections the SAP user cannot read are returned as { \"error\": \"...\" } instead of failing the whole request.",
|
||||||
|
parameters: securityParameters(),
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "System overview",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
example: {
|
||||||
|
serviceLayer: { version: "1000230", sessionTimeoutMinutes: 30 },
|
||||||
|
entitySets: ["BusinessPartners", "Items", "Orders"],
|
||||||
|
userFields: [{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha", Description: "Custom field" }],
|
||||||
|
userTables: [{ TableName: "MY_TABLE", TableDescription: "Custom table", TableType: "bott_NoObject" }],
|
||||||
|
userObjects: [{ Code: "MY_UDO", Name: "My UDO", TableName: "MY_TABLE", ObjectType: "boud_Document" }],
|
||||||
|
adminInfo: { error: "No permission (example of a section the SAP user cannot read)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": { description: "SAP login failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/entities": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "List all entity sets available in the POLSTRIN installation",
|
||||||
|
description: "Returns the Service Layer service document, including the U_* entity sets created by the installed add-ons.",
|
||||||
|
parameters: securityParameters(),
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Sorted list of entity set names" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/entities/{entitySet}": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "List records of any entity set",
|
||||||
|
description:
|
||||||
|
"Generic passthrough to GET /b1s/v1/{entitySet}. Meant mainly for the POLSTRIN user-defined objects: U_ADN_* (evidence majetku), U_DFX_* (Intrastat), U_PVT_* (Intrastat/PVT), U_VCZ_* (Versino CZ lokalizace a vyroba), VYROBNI_PLAN, VYROBNI_DAVKA.",
|
||||||
|
parameters: [...securityParameters(), entitySetParameter(), ...odataParameters()],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "OData response with value array and optional nextLink" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Create a record in any entity set",
|
||||||
|
parameters: [...securityParameters(), entitySetParameter()],
|
||||||
|
requestBody: jsonBody({ Code: "001", Name: "Example", U_SomeField: "value" }),
|
||||||
|
responses: {
|
||||||
|
"201": { description: "Created SAP object" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/entities/{entitySet}/all": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Load all pages of any entity set",
|
||||||
|
parameters: [...securityParameters(), entitySetParameter(), ...odataParameters()],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Object with value array containing all loaded records" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/entities/{entitySet}/{id}": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Get a record by key",
|
||||||
|
parameters: [...securityParameters(), entitySetParameter(), ...entityIdParameters()],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "SAP object" },
|
||||||
|
"404": { description: "Object was not found by SAP Service Layer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
patch: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Update a record by key",
|
||||||
|
parameters: [...securityParameters(), entitySetParameter(), ...entityIdParameters()],
|
||||||
|
requestBody: jsonBody({ Name: "Updated value" }),
|
||||||
|
responses: {
|
||||||
|
"204": { description: "Updated" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Delete a record by key",
|
||||||
|
parameters: [...securityParameters(), entitySetParameter(), ...entityIdParameters()],
|
||||||
|
responses: {
|
||||||
|
"204": { description: "Deleted" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const route of resourceRoutes) {
|
||||||
|
const base = `/api/${route.slug}`;
|
||||||
|
paths[base] = {
|
||||||
|
get: {
|
||||||
|
tags: [route.tag],
|
||||||
|
summary: `List ${route.sapEntitySet}`,
|
||||||
|
description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}.`,
|
||||||
|
parameters: [...securityParameters(), ...odataParameters()],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "OData response with value array and optional nextLink" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: [route.tag],
|
||||||
|
summary: `Create ${route.sapEntitySet} record`,
|
||||||
|
description: `Maps to SAP Service Layer POST /b1s/v1/${route.sapEntitySet}.`,
|
||||||
|
parameters: securityParameters(),
|
||||||
|
requestBody: jsonBody(route.sampleCreate),
|
||||||
|
responses: {
|
||||||
|
"201": { description: "Created SAP object" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
paths[`${base}/all`] = {
|
||||||
|
get: {
|
||||||
|
tags: [route.tag],
|
||||||
|
summary: `Load all ${route.sapEntitySet} pages`,
|
||||||
|
description: "Follows odata.nextLink/@odata.nextLink until all pages are loaded. Use carefully for large datasets.",
|
||||||
|
parameters: [...securityParameters(), ...odataParameters()],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Object with value array containing all loaded records" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemOperations: Record<string, unknown> = {
|
||||||
|
get: {
|
||||||
|
tags: [route.tag],
|
||||||
|
summary: `Get ${route.sapEntitySet} by ${route.idName}`,
|
||||||
|
description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}(<id>).`,
|
||||||
|
parameters: [...securityParameters(), ...commonParameters(route)],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "SAP object" },
|
||||||
|
"404": { description: "Object was not found by SAP Service Layer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
patch: {
|
||||||
|
tags: [route.tag],
|
||||||
|
summary: `Update ${route.sapEntitySet} by ${route.idName}`,
|
||||||
|
description: `Maps to SAP Service Layer PATCH /b1s/v1/${route.sapEntitySet}(<id>).`,
|
||||||
|
parameters: [...securityParameters(), ...commonParameters(route)],
|
||||||
|
requestBody: jsonBody(route.sampleUpdate),
|
||||||
|
responses: {
|
||||||
|
"204": { description: "Updated" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (route.deleteSupported) {
|
||||||
|
itemOperations.delete = {
|
||||||
|
tags: [route.tag],
|
||||||
|
summary: `Delete ${route.sapEntitySet} by ${route.idName}`,
|
||||||
|
description: `Maps to SAP Service Layer DELETE /b1s/v1/${route.sapEntitySet}(<id>). Availability still depends on SAP object state and permissions.`,
|
||||||
|
parameters: [...securityParameters(), ...commonParameters(route)],
|
||||||
|
responses: {
|
||||||
|
"204": { description: "Deleted" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
paths[`${base}/{id}`] = itemOperations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "SAP Business One connector - POLSTRIN DESIGN s.r.o.",
|
||||||
|
version: "1.0.0",
|
||||||
|
description:
|
||||||
|
"HTTP API over the POLSTRIN SAP Business One Service Layer (10.0, version 1000310). SAP credentials are internal secrets configured as environment variables via the AppFactory portal; requests optionally authenticate with the X-Api-Key header when the API_KEY secret is set. Custom add-ons in this installation expose U_ADN_*, U_DFX_*, U_PVT_* and U_VCZ_* entity sets and UDFs with the same prefixes on standard objects - use /api/entities and $select to work with them."
|
||||||
|
},
|
||||||
|
servers: [{ url: basePath || "/" }],
|
||||||
|
security: [{ ApiKey: [] }],
|
||||||
|
tags: [
|
||||||
|
{ name: "Service" },
|
||||||
|
{ name: "Session" },
|
||||||
|
{ name: "System", description: "SAP version and customization overview" },
|
||||||
|
{ name: "Entities", description: "Generic access to any entity set incl. POLSTRIN UDO tables (U_*)" },
|
||||||
|
...resourceRoutes.map((route) => ({ name: route.tag, description: `SAP Service Layer ${route.sapEntitySet}` }))
|
||||||
|
],
|
||||||
|
paths,
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
ApiKey: { type: "apiKey", in: "header", name: "X-Api-Key" }
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
ODataResponse: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
"odata.nextLink": { type: "string" },
|
||||||
|
"@odata.nextLink": { type: "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SapB1Error: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string" },
|
||||||
|
code: { type: "string" },
|
||||||
|
message: { type: "string" },
|
||||||
|
retryable: { type: "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocsHtml(): string {
|
||||||
|
// Same approach as the sibling google-service: serve the Swagger UI page directly at /docs
|
||||||
|
// (no static-file middleware, so no /docs -> /docs/ redirect that would drop the proxy prefix),
|
||||||
|
// load the UI assets from CDN, and point the spec URL at ROOT_PATH + /openapi.json so it resolves
|
||||||
|
// to the public /apps/<app-id>/openapi.json behind the AppFactory reverse proxy.
|
||||||
|
const specUrl = withRootPath("/openapi.json");
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>POLSTRIN SAP Business One connector API</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #f7f7f7; }
|
||||||
|
.topbar { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: ${JSON.stringify(specUrl)},
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
deepLinking: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
filter: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
tagsSorter: "alpha",
|
||||||
|
operationsSorter: "method"
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
app.get("/", (_req, res) => {
|
app.get("/", (_req, res) => {
|
||||||
res.json({
|
res.json(serviceMetadata());
|
||||||
name: "Polstrin SAP BO1",
|
|
||||||
service: "polstrin-sap",
|
|
||||||
status: "ok"
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rootPath) {
|
app.get("/docs", (_req, res) => {
|
||||||
app.get(rootPath, (_req, res) => {
|
res.type("html").send(renderDocsHtml());
|
||||||
res.json({
|
});
|
||||||
name: "Polstrin SAP BO1",
|
|
||||||
service: "polstrin-sap",
|
|
||||||
status: "ok"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get(rootPath + "/health", (_req, res) => {
|
// servers[0].url advertises the public prefix (ROOT_PATH) so Swagger UI "Try it out" targets
|
||||||
res.json({ status: "ok" });
|
// {prefix}/api/..., not the host root.
|
||||||
|
app.get("/openapi.json", (_req, res) => {
|
||||||
|
res.json(openApiDocument(rootPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
addResourceEndpoints();
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
app.listen(port, "0.0.0.0", () => {
|
||||||
|
console.log(serviceId + " listening on port " + port);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.listen(port, "0.0.0.0", () => {
|
export { app };
|
||||||
console.log("polstrin-sap listening on port " + port);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SapB1Client } from "../client/SapB1Client";
|
||||||
|
import { BusinessPartner, BusinessPartnerCreateDto, BusinessPartnerUpdateDto } from "../types/entities";
|
||||||
|
import { BusinessPartnerSchema } from "../types/schemas";
|
||||||
|
import { Resource } from "./Resource";
|
||||||
|
|
||||||
|
export class BusinessPartnersResource extends Resource<
|
||||||
|
BusinessPartner,
|
||||||
|
BusinessPartnerCreateDto,
|
||||||
|
BusinessPartnerUpdateDto,
|
||||||
|
string
|
||||||
|
> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "BusinessPartners", BusinessPartnerSchema);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { SapB1Client } from "../client/SapB1Client";
|
||||||
|
import { MarketingDocument, MarketingDocumentCreateDto, MarketingDocumentUpdateDto } from "../types/entities";
|
||||||
|
import { MarketingDocumentSchema } from "../types/schemas";
|
||||||
|
import { Resource } from "./Resource";
|
||||||
|
|
||||||
|
export class OrdersResource extends Resource<MarketingDocument, MarketingDocumentCreateDto, MarketingDocumentUpdateDto, number> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "Orders", MarketingDocumentSchema, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvoicesResource extends Resource<MarketingDocument, MarketingDocumentCreateDto, MarketingDocumentUpdateDto, number> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "Invoices", MarketingDocumentSchema, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PurchaseOrdersResource extends Resource<
|
||||||
|
MarketingDocument,
|
||||||
|
MarketingDocumentCreateDto,
|
||||||
|
MarketingDocumentUpdateDto,
|
||||||
|
number
|
||||||
|
> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "PurchaseOrders", MarketingDocumentSchema, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeliveryNotesResource extends Resource<
|
||||||
|
MarketingDocument,
|
||||||
|
MarketingDocumentCreateDto,
|
||||||
|
MarketingDocumentUpdateDto,
|
||||||
|
number
|
||||||
|
> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "DeliveryNotes", MarketingDocumentSchema, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { SapB1Client } from "../client/SapB1Client";
|
||||||
|
import { Item, ItemCreateDto, ItemUpdateDto } from "../types/entities";
|
||||||
|
import { ItemSchema } from "../types/schemas";
|
||||||
|
import { Resource } from "./Resource";
|
||||||
|
|
||||||
|
export class ItemsResource extends Resource<Item, ItemCreateDto, ItemUpdateDto, string> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "Items", ItemSchema);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { SapB1Client } from "../client/SapB1Client";
|
||||||
|
import { ODataQuery, ODataResponse } from "../types/odata";
|
||||||
|
|
||||||
|
export class Resource<TEntity, TCreate, TUpdate, TKey extends string | number = string | number> {
|
||||||
|
public constructor(
|
||||||
|
protected readonly client: SapB1Client,
|
||||||
|
protected readonly path: string,
|
||||||
|
protected readonly schema: z.ZodType<TEntity>,
|
||||||
|
private readonly supportsDelete = true
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public list(query: ODataQuery = {}): Promise<ODataResponse<TEntity>> {
|
||||||
|
return this.client.get<ODataResponse<TEntity>>(this.path, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public listAll(query: ODataQuery = {}): Promise<TEntity[]> {
|
||||||
|
return this.client.getAll<TEntity>(this.path, query, this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(id: TKey): Promise<TEntity> {
|
||||||
|
return this.client.get<TEntity>(`${this.path}(${this.formatKey(id)})`, undefined, this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public create(data: TCreate): Promise<TEntity> {
|
||||||
|
return this.client.post<TEntity>(this.path, data, this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(id: TKey, data: TUpdate): Promise<void> {
|
||||||
|
return this.client.patch<void>(`${this.path}(${this.formatKey(id)})`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(id: TKey): Promise<void> {
|
||||||
|
if (!this.supportsDelete) {
|
||||||
|
throw new Error(`${this.path} does not support delete through this connector`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.delete<void>(`${this.path}(${this.formatKey(id)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatKey(id: TKey): string {
|
||||||
|
return typeof id === "number" ? String(id) : `'${String(id).replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { SapB1Client } from "../client/SapB1Client";
|
||||||
|
import { StockTransfer, StockTransferCreateDto, StockTransferUpdateDto } from "../types/entities";
|
||||||
|
import { StockTransferSchema } from "../types/schemas";
|
||||||
|
import { Resource } from "./Resource";
|
||||||
|
|
||||||
|
export class StockTransfersResource extends Resource<StockTransfer, StockTransferCreateDto, StockTransferUpdateDto, number> {
|
||||||
|
public constructor(client: SapB1Client) {
|
||||||
|
super(client, "StockTransfers", StockTransferSchema, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export interface BusinessPartner {
|
||||||
|
CardCode: string;
|
||||||
|
CardName?: string;
|
||||||
|
CardType?: "cCustomer" | "cSupplier" | "cLid";
|
||||||
|
Phone1?: string;
|
||||||
|
EmailAddress?: string;
|
||||||
|
Currency?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessPartnerCreateDto = Pick<BusinessPartner, "CardCode"> & Partial<BusinessPartner>;
|
||||||
|
export type BusinessPartnerUpdateDto = Partial<Omit<BusinessPartner, "CardCode">>;
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
ItemCode: string;
|
||||||
|
ItemName?: string;
|
||||||
|
ItemsGroupCode?: number;
|
||||||
|
InventoryItem?: "tYES" | "tNO";
|
||||||
|
SalesItem?: "tYES" | "tNO";
|
||||||
|
PurchaseItem?: "tYES" | "tNO";
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemCreateDto = Pick<Item, "ItemCode"> & Partial<Item>;
|
||||||
|
export type ItemUpdateDto = Partial<Omit<Item, "ItemCode">>;
|
||||||
|
|
||||||
|
export interface DocumentLine {
|
||||||
|
ItemCode?: string;
|
||||||
|
Quantity?: number;
|
||||||
|
UnitPrice?: number;
|
||||||
|
WarehouseCode?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketingDocument {
|
||||||
|
DocEntry: number;
|
||||||
|
DocNum?: number;
|
||||||
|
CardCode?: string;
|
||||||
|
DocDate?: string;
|
||||||
|
DocDueDate?: string;
|
||||||
|
TaxDate?: string;
|
||||||
|
DocumentLines?: DocumentLine[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarketingDocumentCreateDto = Omit<Partial<MarketingDocument>, "DocEntry" | "DocNum"> & {
|
||||||
|
CardCode: string;
|
||||||
|
DocumentLines?: DocumentLine[];
|
||||||
|
};
|
||||||
|
export type MarketingDocumentUpdateDto = Partial<Omit<MarketingDocument, "DocEntry" | "DocNum">>;
|
||||||
|
|
||||||
|
export interface StockTransferLine {
|
||||||
|
ItemCode?: string;
|
||||||
|
Quantity?: number;
|
||||||
|
WarehouseCode?: string;
|
||||||
|
FromWarehouseCode?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockTransfer {
|
||||||
|
DocEntry: number;
|
||||||
|
DocNum?: number;
|
||||||
|
DocDate?: string;
|
||||||
|
FromWarehouse?: string;
|
||||||
|
ToWarehouse?: string;
|
||||||
|
StockTransferLines?: StockTransferLine[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StockTransferCreateDto = Omit<Partial<StockTransfer>, "DocEntry" | "DocNum"> & {
|
||||||
|
StockTransferLines?: StockTransferLine[];
|
||||||
|
};
|
||||||
|
export type StockTransferUpdateDto = Partial<Omit<StockTransfer, "DocEntry" | "DocNum">>;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export interface ODataQuery {
|
||||||
|
select?: string[];
|
||||||
|
filter?: string;
|
||||||
|
top?: number;
|
||||||
|
skip?: number;
|
||||||
|
orderby?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ODataResponse<T> {
|
||||||
|
value: T[];
|
||||||
|
"odata.metadata"?: string;
|
||||||
|
"odata.nextLink"?: string;
|
||||||
|
"@odata.context"?: string;
|
||||||
|
"@odata.nextLink"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ODataQuerySchema = z.object({
|
||||||
|
select: z.array(z.string().min(1)).optional(),
|
||||||
|
filter: z.string().min(1).optional(),
|
||||||
|
top: z.number().int().positive().optional(),
|
||||||
|
skip: z.number().int().min(0).optional(),
|
||||||
|
orderby: z.union([z.string().min(1), z.array(z.string().min(1))]).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ODataResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
|
||||||
|
z.object({
|
||||||
|
value: z.array(itemSchema),
|
||||||
|
"odata.metadata": z.string().optional(),
|
||||||
|
"odata.nextLink": z.string().optional(),
|
||||||
|
"@odata.context": z.string().optional(),
|
||||||
|
"@odata.nextLink": z.string().optional()
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const UnknownRecordSchema = z.object({}).catchall(z.unknown());
|
||||||
|
|
||||||
|
export const BusinessPartnerSchema = UnknownRecordSchema.extend({
|
||||||
|
CardCode: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ItemSchema = UnknownRecordSchema.extend({
|
||||||
|
ItemCode: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MarketingDocumentSchema = UnknownRecordSchema.extend({
|
||||||
|
DocEntry: z.number()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StockTransferSchema = UnknownRecordSchema.extend({
|
||||||
|
DocEntry: z.number()
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface SapB1Logger {
|
||||||
|
debug(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
info(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
error(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noopLogger: SapB1Logger = {
|
||||||
|
debug: () => undefined,
|
||||||
|
info: () => undefined,
|
||||||
|
warn: () => undefined,
|
||||||
|
error: () => undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sanitizeLogMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const forbidden = new Set(["password", "b1session", "routeid", "cookie", "authorization"]);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(meta).map(([key, value]) => {
|
||||||
|
if (forbidden.has(key.toLowerCase())) {
|
||||||
|
return [key, "[redacted]"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [key, value];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionManager } from "../src/auth/SessionManager";
|
||||||
|
import { SapB1Config } from "../src/config";
|
||||||
|
import { SapB1Error } from "../src/errors/SapB1Error";
|
||||||
|
|
||||||
|
const config: SapB1Config = {
|
||||||
|
baseUrl: "https://sap.example.local:50000",
|
||||||
|
companyDB: "SBODEMOUS",
|
||||||
|
username: "manager",
|
||||||
|
password: "secret",
|
||||||
|
timeout: 30_000,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
retryCount: 2,
|
||||||
|
retryDelayMs: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SessionManager", () => {
|
||||||
|
it("logs in and stores B1SESSION and ROUTEID cookies", async () => {
|
||||||
|
const http = {
|
||||||
|
post: vi.fn().mockResolvedValue({
|
||||||
|
data: { SessionId: "fallback", SessionTimeout: 30 },
|
||||||
|
headers: {
|
||||||
|
"set-cookie": ["B1SESSION=abc123; Path=/b1s/v1", "ROUTEID=.node1; Path=/b1s/v1"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionManager = new SessionManager(http as never, config);
|
||||||
|
const session = await sessionManager.getSession();
|
||||||
|
|
||||||
|
expect(http.post).toHaveBeenCalledWith(
|
||||||
|
"/Login",
|
||||||
|
{
|
||||||
|
CompanyDB: "SBODEMOUS",
|
||||||
|
UserName: "manager",
|
||||||
|
Password: "secret",
|
||||||
|
Language: undefined
|
||||||
|
},
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
expect(session.b1session).toBe("abc123");
|
||||||
|
expect(session.routeId).toBe(".node1");
|
||||||
|
expect(sessionManager.buildCookieHeader(session)).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the language as an integer code to the Login action", async () => {
|
||||||
|
const http = {
|
||||||
|
post: vi.fn().mockResolvedValue({
|
||||||
|
data: { SessionId: "abc123", SessionTimeout: 30 },
|
||||||
|
headers: {}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionManager = new SessionManager(http as never, { ...config, language: "3" });
|
||||||
|
await sessionManager.getSession();
|
||||||
|
|
||||||
|
expect(http.post).toHaveBeenCalledWith(
|
||||||
|
"/Login",
|
||||||
|
{
|
||||||
|
CompanyDB: "SBODEMOUS",
|
||||||
|
UserName: "manager",
|
||||||
|
Password: "secret",
|
||||||
|
Language: 3
|
||||||
|
},
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the SAP status, code and message when SAP rejects the login", async () => {
|
||||||
|
const rejection = new AxiosError("Request failed with status code 401", "ERR_BAD_REQUEST");
|
||||||
|
rejection.response = {
|
||||||
|
status: 401,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
headers: {},
|
||||||
|
config: {} as never,
|
||||||
|
data: { error: { code: "301", message: { value: "Invalid company database" } } }
|
||||||
|
};
|
||||||
|
const http = { post: vi.fn().mockRejectedValue(rejection) };
|
||||||
|
|
||||||
|
const sessionManager = new SessionManager(http as never, config);
|
||||||
|
const error = await sessionManager.getSession().catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SapB1Error);
|
||||||
|
expect(error.status).toBe(401);
|
||||||
|
expect(error.code).toBe("301");
|
||||||
|
expect(error.message).toBe("Invalid company database");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the generic login message for a transport error with no HTTP response", async () => {
|
||||||
|
const transportError = new AxiosError("connect ECONNREFUSED 10.0.0.1:50000", "ECONNREFUSED");
|
||||||
|
const http = { post: vi.fn().mockRejectedValue(transportError) };
|
||||||
|
|
||||||
|
const sessionManager = new SessionManager(http as never, config);
|
||||||
|
const error = await sessionManager.getSession().catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SapB1Error);
|
||||||
|
expect(error.status).toBeUndefined();
|
||||||
|
expect(error.message).toBe("connect ECONNREFUSED 10.0.0.1:50000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs out and clears the current session", async () => {
|
||||||
|
const http = {
|
||||||
|
post: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { SessionId: "abc123" },
|
||||||
|
headers: {}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: {}, headers: {} })
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionManager = new SessionManager(http as never, config);
|
||||||
|
await sessionManager.getSession();
|
||||||
|
await sessionManager.logout();
|
||||||
|
|
||||||
|
expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, {
|
||||||
|
skipAuth: true,
|
||||||
|
headers: { Cookie: "B1SESSION=abc123" }
|
||||||
|
});
|
||||||
|
expect(sessionManager.currentSession).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import axios, { AxiosAdapter, AxiosResponse } from "axios";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SapB1Client } from "../src/client/SapB1Client";
|
||||||
|
import { SapB1Config } from "../src/config";
|
||||||
|
|
||||||
|
const config: SapB1Config = {
|
||||||
|
baseUrl: "https://sap.example.local:50000",
|
||||||
|
companyDB: "SBODEMOUS",
|
||||||
|
username: "manager",
|
||||||
|
password: "secret",
|
||||||
|
timeout: 30_000,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
retryCount: 1,
|
||||||
|
retryDelayMs: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
function response(data: unknown, requestConfig: Parameters<AxiosAdapter>[0], status = 200): AxiosResponse {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {},
|
||||||
|
config: requestConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SapB1Client", () => {
|
||||||
|
it("adds session cookies to resource requests", async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const cookies: unknown[] = [];
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
urls.push(String(requestConfig.url));
|
||||||
|
cookies.push(requestConfig.headers?.Cookie);
|
||||||
|
|
||||||
|
if (requestConfig.url === "/Login") {
|
||||||
|
return {
|
||||||
|
...response({ SessionId: "abc123", SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1", "ROUTEID=.node1; Path=/b1s/v1"] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({ value: [{ CardCode: "C001" }] }, requestConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new SapB1Client(config, { http });
|
||||||
|
const result = await client.get("BusinessPartners", { top: 1 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ value: [{ CardCode: "C001" }] });
|
||||||
|
expect(urls).toEqual(["/Login", "BusinessPartners"]);
|
||||||
|
expect(cookies[1]).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-logins once after a 401 response", async () => {
|
||||||
|
let resourceCalls = 0;
|
||||||
|
let loginCalls = 0;
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
if (requestConfig.url === "/Login") {
|
||||||
|
loginCalls += 1;
|
||||||
|
return {
|
||||||
|
...response({ SessionId: `session-${loginCalls}`, SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": [`B1SESSION=session-${loginCalls}; Path=/b1s/v1`] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceCalls += 1;
|
||||||
|
|
||||||
|
if (resourceCalls === 1) {
|
||||||
|
return Promise.reject({
|
||||||
|
isAxiosError: true,
|
||||||
|
message: "Unauthorized",
|
||||||
|
response: { status: 401, data: { error: { message: { value: "Session expired" } } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({ value: [] }, requestConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new SapB1Client(config, { http });
|
||||||
|
await expect(client.get("Items")).resolves.toEqual({ value: [] });
|
||||||
|
expect(loginCalls).toBe(2);
|
||||||
|
expect(resourceCalls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-logins after a 401 even when retries are disabled", async () => {
|
||||||
|
let resourceCalls = 0;
|
||||||
|
let loginCalls = 0;
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
if (requestConfig.url === "/Login") {
|
||||||
|
loginCalls += 1;
|
||||||
|
return {
|
||||||
|
...response({ SessionId: `session-${loginCalls}`, SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": [`B1SESSION=session-${loginCalls}; Path=/b1s/v1`] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceCalls += 1;
|
||||||
|
|
||||||
|
if (resourceCalls === 1) {
|
||||||
|
return Promise.reject({
|
||||||
|
isAxiosError: true,
|
||||||
|
message: "Unauthorized",
|
||||||
|
response: { status: 401, data: { error: { message: { value: "Session expired" } } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({ value: [] }, requestConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new SapB1Client({ ...config, retryCount: 0 }, { http });
|
||||||
|
await expect(client.get("Items")).resolves.toEqual({ value: [] });
|
||||||
|
expect(loginCalls).toBe(2);
|
||||||
|
expect(resourceCalls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows an absolute nextLink while preserving its query string", async () => {
|
||||||
|
const requestedUrls: string[] = [];
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
if (requestConfig.url === "/Login") {
|
||||||
|
return {
|
||||||
|
...response({ SessionId: "abc123", SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedUrls.push(String(requestConfig.url));
|
||||||
|
|
||||||
|
if (requestConfig.url === "Items") {
|
||||||
|
return response(
|
||||||
|
{
|
||||||
|
value: [{ ItemCode: "A1" }],
|
||||||
|
"odata.nextLink": "https://sap.example.local:50000/b1s/v1/Items?$skip=20"
|
||||||
|
},
|
||||||
|
requestConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({ value: [{ ItemCode: "A2" }] }, requestConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new SapB1Client(config, { http });
|
||||||
|
const all = await client.getAll<{ ItemCode: string }>("Items");
|
||||||
|
|
||||||
|
expect(all).toEqual([{ ItemCode: "A1" }, { ItemCode: "A2" }]);
|
||||||
|
expect(requestedUrls).toEqual(["Items", "Items?$skip=20"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildODataParams, extractNextLink } from "../src/client/odata";
|
||||||
|
|
||||||
|
describe("OData helpers", () => {
|
||||||
|
it("builds SAP Service Layer OData query parameters", () => {
|
||||||
|
expect(
|
||||||
|
buildODataParams({
|
||||||
|
select: ["CardCode", "CardName"],
|
||||||
|
filter: "CardType eq 'cCustomer'",
|
||||||
|
top: 25,
|
||||||
|
skip: 50,
|
||||||
|
orderby: ["CardName asc", "CardCode desc"]
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
$select: "CardCode,CardName",
|
||||||
|
$filter: "CardType eq 'cCustomer'",
|
||||||
|
$top: 25,
|
||||||
|
$skip: 50,
|
||||||
|
$orderby: "CardName asc,CardCode desc"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports old and new nextLink field names", () => {
|
||||||
|
expect(extractNextLink({ "@odata.nextLink": "Items?$skip=20" })).toBe("Items?$skip=20");
|
||||||
|
expect(extractNextLink({ "odata.nextLink": "Items?$skip=40" })).toBe("Items?$skip=40");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { BusinessPartnersResource } from "../src/resources/BusinessPartners";
|
||||||
|
import { OrdersResource } from "../src/resources/Documents";
|
||||||
|
|
||||||
|
describe("resources", () => {
|
||||||
|
it("formats string keys for SAP Service Layer entity URLs", async () => {
|
||||||
|
const client = {
|
||||||
|
get: vi.fn().mockResolvedValue({ CardCode: "C'001" })
|
||||||
|
};
|
||||||
|
const resource = new BusinessPartnersResource(client as never);
|
||||||
|
|
||||||
|
await resource.get("C'001");
|
||||||
|
|
||||||
|
expect(client.get).toHaveBeenCalledWith("BusinessPartners('C''001')", undefined, expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks delete for marketing documents by default", async () => {
|
||||||
|
const resource = new OrdersResource({} as never);
|
||||||
|
|
||||||
|
await expect(() => resource.delete(1)).toThrow("Orders does not support delete through this connector");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import axios, { AxiosAdapter, AxiosResponse } from "axios";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SapBusinessOneServiceLayer } from "../src/SapBusinessOneServiceLayer";
|
||||||
|
import { SapB1Config } from "../src/config";
|
||||||
|
|
||||||
|
const config: SapB1Config = {
|
||||||
|
baseUrl: "https://sap.example.local:50000",
|
||||||
|
companyDB: "SBODEMOUS",
|
||||||
|
username: "manager",
|
||||||
|
password: "secret",
|
||||||
|
timeout: 30_000,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
retryCount: 0,
|
||||||
|
retryDelayMs: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
function response(data: unknown, requestConfig: Parameters<AxiosAdapter>[0], status = 200): AxiosResponse {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {},
|
||||||
|
config: requestConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getSystemInfo", () => {
|
||||||
|
it("collects version, entity sets and customization metadata, isolating section errors", async () => {
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
const url = String(requestConfig.url);
|
||||||
|
|
||||||
|
if (url === "/Login") {
|
||||||
|
return {
|
||||||
|
...response({ SessionId: "abc123", Version: "1000230", SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/Logout") {
|
||||||
|
return response({}, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/") {
|
||||||
|
return response(
|
||||||
|
{ value: [{ name: "Items", url: "Items" }, { name: "BusinessPartners", url: "BusinessPartners" }] },
|
||||||
|
requestConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "UserFieldsMD") {
|
||||||
|
return response({ value: [{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha" }] }, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "UserTablesMD") {
|
||||||
|
return response({ value: [{ TableName: "MY_TABLE", TableDescription: "Custom table" }] }, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "UserObjectsMD") {
|
||||||
|
return response({ value: [] }, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "CompanyService_GetAdminInfo") {
|
||||||
|
return Promise.reject({
|
||||||
|
isAxiosError: true,
|
||||||
|
message: "Forbidden",
|
||||||
|
response: { status: 403, data: { error: { message: { value: "No permission" } } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected request URL: ${url}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sap = new SapBusinessOneServiceLayer(config, { http });
|
||||||
|
const info = await sap.getSystemInfo();
|
||||||
|
|
||||||
|
expect(info.serviceLayer).toEqual({ version: "1000230", sessionTimeoutMinutes: 30 });
|
||||||
|
expect(info.entitySets).toEqual(["BusinessPartners", "Items"]);
|
||||||
|
expect(info.userFields).toEqual([{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha" }]);
|
||||||
|
expect(info.userTables).toEqual([{ TableName: "MY_TABLE", TableDescription: "Custom table" }]);
|
||||||
|
expect(info.userObjects).toEqual([]);
|
||||||
|
expect(info.adminInfo).toEqual({ error: "No permission" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps login info available after logout so the login endpoint can report the version", async () => {
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
if (requestConfig.url === "/Login") {
|
||||||
|
return {
|
||||||
|
...response({ SessionId: "abc123", Version: "1000230", SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({}, requestConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sap = new SapBusinessOneServiceLayer(config, { http });
|
||||||
|
await sap.login();
|
||||||
|
await sap.logout();
|
||||||
|
|
||||||
|
expect(sap.client.loginInfo).toEqual({ version: "1000230", sessionTimeoutMinutes: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-2
@@ -5,6 +5,11 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true,
|
||||||
}
|
"moduleResolution": "node",
|
||||||
|
"declaration": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["tests", "dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user