This commit is contained in:
JiriUhlir
2026-07-15 09:03:28 +02:00
parent 4b98f37407
commit fea25d7429
31 changed files with 4906 additions and 30 deletions
+103
View File
@@ -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) };
}
}
}
+177
View File
@@ -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;
}
}
+152
View File
@@ -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\/?/, "");
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import "axios";
declare module "axios" {
export interface AxiosRequestConfig {
skipAuth?: boolean;
skipRetry?: boolean;
}
}
+35
View File
@@ -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"];
}
+47
View File
@@ -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
});
}
+68
View File
@@ -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
View File
@@ -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();
app.set("trust proxy", true);
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) => {
res.json({
name: "Polstrin SAP BO1",
service: "polstrin-sap",
status: "ok"
});
res.json(serviceMetadata());
});
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
if (rootPath) {
app.get(rootPath, (_req, res) => {
res.json({
name: "Polstrin SAP BO1",
service: "polstrin-sap",
status: "ok"
});
});
app.get("/docs", (_req, res) => {
res.type("html").send(renderDocsHtml());
});
app.get(rootPath + "/health", (_req, res) => {
res.json({ status: "ok" });
// servers[0].url advertises the public prefix (ROOT_PATH) so Swagger UI "Try it out" targets
// {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", () => {
console.log("polstrin-sap listening on port " + port);
});
export { app };
+15
View File
@@ -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);
}
}
+38
View File
@@ -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);
}
}
+10
View File
@@ -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);
}
}
+44
View File
@@ -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, "''")}'`;
}
}
+10
View File
@@ -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);
}
}
+73
View File
@@ -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">>;
+34
View File
@@ -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()
});
+19
View File
@@ -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()
});
+27
View File
@@ -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];
})
);
}
+3
View File
@@ -0,0 +1,3 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}