Seit AM hinter dem Buchungsstatus "Zahlung erfassen" anzeigt, lieferte der letzte Teil der Statuszeile kein Buchungsdatum mehr. Fallback fürs alte Layout sucht den Teil mit "gebucht". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1597 lines
72 KiB
JavaScript
1597 lines
72 KiB
JavaScript
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.AgrarmonitorConnector = void 0;
|
|
const axios_1 = __importDefault(require("axios"));
|
|
const axios_cookiejar_support_1 = require("axios-cookiejar-support");
|
|
const jsdom_1 = require("jsdom");
|
|
const tough_cookie_1 = require("tough-cookie");
|
|
const ZeitbuchungError_1 = require("./ZeitbuchungError");
|
|
class AgrarmonitorConnector {
|
|
options;
|
|
http;
|
|
static maxLivesearchSeiten = 500;
|
|
static berlinFormat = new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'Europe/Berlin',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hourCycle: 'h23',
|
|
});
|
|
static s3DateienBaseUrl = 'https://s3-eu-central-1.amazonaws.com/dateien.agrarmonitor.de/07';
|
|
baseUrl;
|
|
apiBaseUrl;
|
|
timeoutMs;
|
|
autoLogin;
|
|
autoRetry;
|
|
loginStrategy;
|
|
logger;
|
|
cookieJar;
|
|
apiHttp;
|
|
loginInProgress = null;
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.baseUrl = options.baseUrl ?? 'https://admin7.agrarmonitor.de';
|
|
this.apiBaseUrl = this.normalizeApiBaseUrl(options.apiBaseUrl ?? 'https://api.agrarmonitor.de/v1');
|
|
this.timeoutMs = options.timeoutMs ?? 15000;
|
|
this.autoLogin = options.autoLogin ?? true;
|
|
this.autoRetry = options.autoRetry ?? true;
|
|
this.loginStrategy = options.loginStrategy ?? 'redirect';
|
|
this.logger = options.logger;
|
|
}
|
|
async init() {
|
|
this.cookieJar = await this.options.cookieStore.load();
|
|
this.http = this.createHttpClient();
|
|
this.apiHttp = this.createApiHttpClient();
|
|
if (this.autoLogin) {
|
|
const valid = await this.isSessionValid();
|
|
if (!valid) {
|
|
await this.login();
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
async login() {
|
|
if (this.loginInProgress) {
|
|
return this.loginInProgress;
|
|
}
|
|
this.loginInProgress = this.performLogin().finally(() => {
|
|
this.loginInProgress = null;
|
|
});
|
|
return this.loginInProgress;
|
|
}
|
|
async clearSession() {
|
|
this.cookieJar = new tough_cookie_1.CookieJar();
|
|
await this.options.cookieStore.clear();
|
|
this.http = this.createHttpClient();
|
|
this.apiHttp = this.createApiHttpClient();
|
|
}
|
|
async saveSession() {
|
|
await this.options.cookieStore.save(this.cookieJar);
|
|
}
|
|
async getCookieCount(url = this.baseUrl) {
|
|
return this.cookieJar.getCookiesSync(url).length;
|
|
}
|
|
async checkFreigeschaltet() {
|
|
const response = await this.http.get('/', {
|
|
maxRedirects: 0,
|
|
validateStatus: status => status >= 200 && status < 400,
|
|
});
|
|
await this.saveSession();
|
|
const redirectLocation = this.getHeader(response, 'location');
|
|
const redirected = response.status >= 300 && response.status < 400 && this.isFreischaltungUrl(redirectLocation);
|
|
return {
|
|
freigeschaltet: !redirected,
|
|
status: response.status,
|
|
redirected,
|
|
redirectLocation,
|
|
timestamp: new Date().toISOString(),
|
|
cookies: await this.getCookieCount(),
|
|
};
|
|
}
|
|
async checkRegistriert() {
|
|
const response = await this.http.get('/', {
|
|
maxRedirects: 5,
|
|
validateStatus: status => status >= 200 && status < 500,
|
|
});
|
|
await this.saveSession();
|
|
const pageContent = typeof response.data === 'string' ? response.data : '';
|
|
const hasRegistrationText = pageContent.includes('Neues Gerät registrieren');
|
|
return {
|
|
registriert: !hasRegistrationText,
|
|
status: response.status,
|
|
hasRegistrationText,
|
|
timestamp: new Date().toISOString(),
|
|
cookies: await this.getCookieCount(),
|
|
};
|
|
}
|
|
async registerDevice(registration) {
|
|
const agrarmonitorId = registration.agrarmonitorId.trim();
|
|
const pcName = registration.pcName.trim();
|
|
if (!agrarmonitorId || !pcName) {
|
|
throw new Error('AgrarmonitorID und PC-Name sind erforderlich');
|
|
}
|
|
const freischaltungResponse = await this.http.get('/freischaltung/');
|
|
const responseContent = typeof freischaltungResponse.data === 'string' ? freischaltungResponse.data : '';
|
|
const nonce = this.extractNonce(responseContent, '#nonce, input[name="nonce"]');
|
|
const registerResponse = await this.http.post('/freischaltung/api/register.php', {
|
|
firma: agrarmonitorId,
|
|
name: pcName,
|
|
nonce,
|
|
}, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
validateStatus: status => status >= 200 && status < 500,
|
|
});
|
|
await this.saveSession();
|
|
const success = registerResponse.status >= 200 && registerResponse.status < 300;
|
|
return {
|
|
success,
|
|
status: registerResponse.status,
|
|
message: success ? 'Registrierung erfolgreich' : 'Registrierung fehlgeschlagen',
|
|
data: {
|
|
agrarmonitorId,
|
|
pcName,
|
|
nonce: this.maskNonce(nonce),
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
cookies: await this.getCookieCount(),
|
|
};
|
|
}
|
|
async fetchCustomers(options = {}) {
|
|
const response = await this.apiRequest('/kunden', {
|
|
params: {
|
|
per_page: options.perPage ?? 99999,
|
|
},
|
|
apiToken: options.apiToken,
|
|
});
|
|
const responseData = response.data;
|
|
if (!responseData || !Array.isArray(responseData.data)) {
|
|
throw new Error('Ungueltige Agrarmonitor API-Antwort');
|
|
}
|
|
return responseData.data;
|
|
}
|
|
async getKunden2(options = {}) {
|
|
const customers = await this.fetchAllCustomers(options);
|
|
return customers.map(customer => this.mapKunde2(customer));
|
|
}
|
|
async eingangsrechnungenLivesearch(suchstring) {
|
|
const response = await this.http.get('/module/dateien/livesearch.php', {
|
|
params: this.createDateienLivesearchParams(suchstring),
|
|
});
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#dateien tbody tr'));
|
|
const results = [];
|
|
for (const row of rows) {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
const typText = cells[3]?.textContent?.trim() ?? '';
|
|
if (!typText.startsWith('Eingangsrechnungen')) {
|
|
continue;
|
|
}
|
|
const dokumentId = this.parseNumber(row.getAttribute('data-file_id'));
|
|
const dataFile = row.getAttribute('data-file') ?? '';
|
|
const dokumentName = cells[2]?.querySelector('b > a')?.textContent?.trim() ?? '';
|
|
const dateiName = cells[2]?.querySelector('span')?.textContent?.trim() ?? '';
|
|
const belegLink = cells[3]?.querySelector('a');
|
|
const belegTextParts = (belegLink?.textContent ?? '').split(',').map(part => part.trim()).filter(Boolean);
|
|
const belegNummer = belegTextParts[0] ?? '';
|
|
const belegDatum = this.parseGermanShortDateFromText(belegTextParts.at(-1) ?? '');
|
|
const eingangId = this.parseNumber(this.lastPathSegment(belegLink?.getAttribute('href') ?? ''));
|
|
const { interneBelegNummer, kundenId, betriebId, dokumentTyp } = await this.getEingangsrechnungEditMeta(eingangId);
|
|
const { eingangsDatum, buchungsDatum } = await this.getEingangsrechnungDetailMeta(eingangId);
|
|
results.push({
|
|
dokumentId,
|
|
vorschauUrl: `${AgrarmonitorConnector.s3DateienBaseUrl}/v_${this.fileBasename(dataFile)}.png`,
|
|
dokumentUrl: `${AgrarmonitorConnector.s3DateienBaseUrl}/${dataFile}`,
|
|
dokumentName,
|
|
dateiName,
|
|
belegNummer,
|
|
interneBelegNummer,
|
|
belegDatum,
|
|
buchungsDatum,
|
|
eingangsDatum,
|
|
eingangId,
|
|
kundenId,
|
|
betriebId,
|
|
dokumentTyp,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
async eingangsrechnungVorhanden(suchstring) {
|
|
const response = await this.http.get('/module/dateien/livesearch.php', {
|
|
params: this.createDateienLivesearchParams(suchstring),
|
|
});
|
|
await this.saveSession();
|
|
return this.hasTableRows(response.data, 'table#dateien tbody tr');
|
|
}
|
|
async eingangsrechnungImDateieingangVorhanden(suchstring) {
|
|
const response = await this.http.get('/module/dateien/eingang/livesearch.php', {
|
|
params: {
|
|
suchstring,
|
|
seite: 1,
|
|
},
|
|
});
|
|
await this.saveSession();
|
|
return this.hasTableRows(response.data, 'table#dateien_eingang tbody tr');
|
|
}
|
|
async getRechnungsdaten(rechnungId) {
|
|
return this.rechnungsdatenAusFormular(await this.loadEingangsrechnungEditForm(rechnungId));
|
|
}
|
|
rechnungsdatenAusFormular(document) {
|
|
return {
|
|
lieferschein: this.inputValue(document, 'lieferscheinnummer'),
|
|
rechnung: this.inputValue(document, 'rechnungsnummer'),
|
|
datum: this.requireDate(this.parseGermanShortDate(this.inputValue(document, 'rechnungsdatum')), 'rechnungsdatum'),
|
|
kundenId: this.selectedNumberValue(document, 'rgempf'),
|
|
adresstext: this.inputValue(document, 'addressName'),
|
|
};
|
|
}
|
|
async setRechnungsdaten(rechnungId, daten) {
|
|
// update.php erwartet das komplette Formular. Deshalb das aktuelle Formular laden
|
|
// und nur die geaenderten Felder ueberschreiben, damit nichts geleert wird.
|
|
const document = await this.loadEingangsrechnungEditForm(rechnungId);
|
|
const formData = {
|
|
...this.serializeForm(document, 'form#rechnung-detail'),
|
|
lieferscheinnummer: daten.lieferschein,
|
|
rechnungsnummer: daten.rechnung,
|
|
rechnungsdatum: this.formatGermanShortDate(daten.datum),
|
|
rgempf: String(daten.kundenId),
|
|
addressName: daten.adresstext,
|
|
};
|
|
const response = await this.http.post('/module/eingangsrechnungen/api/update.php', { rechnungID: rechnungId, formData }, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json, */*',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
Referer: `${this.baseUrl}/eingangsrechnungen/detail/${rechnungId}`,
|
|
Origin: this.baseUrl,
|
|
},
|
|
// 4xx nicht als Axios-Fehler werfen: Agrarmonitor schickt den Grund (z. B. bei 422) im Body.
|
|
validateStatus: status => status >= 200 && status < 500,
|
|
});
|
|
await this.saveSession();
|
|
// Erfolg: leeres Array. Fehler: Objekt mit msg.
|
|
const msg = response.data?.msg;
|
|
const ok = response.status >= 200 && response.status < 300;
|
|
if ((typeof msg === 'string' && msg.trim()) || !ok) {
|
|
const grund = typeof msg === 'string' && msg.trim()
|
|
? msg.trim()
|
|
: (typeof response.data === 'string' ? response.data : JSON.stringify(response.data)).slice(0, 500);
|
|
throw new Error(`Rechnung ${rechnungId} konnte nicht gespeichert werden (HTTP ${response.status}): ${grund}`);
|
|
}
|
|
return true;
|
|
}
|
|
async setLieferscheinNummer(rechnungId, nummer) {
|
|
const rechnungsdaten = await this.getRechnungsdaten(rechnungId);
|
|
const success = await this.setRechnungsdaten(rechnungId, {
|
|
...rechnungsdaten,
|
|
lieferschein: nummer,
|
|
});
|
|
if (!success) {
|
|
throw new Error('Lieferscheinnummer konnte nicht gespeichert werden');
|
|
}
|
|
}
|
|
async setEingangsdatum(rechnungId, datum) {
|
|
const response = await this.http.post('/module/eingangsrechnungen/api/updateReceived.php', new URLSearchParams({
|
|
datum: this.formatGermanShortDate(datum),
|
|
receiptID: String(rechnungId),
|
|
}), this.formPostConfig(`/eingangsrechnungen/detail/${rechnungId}`));
|
|
await this.saveSession();
|
|
return response.status >= 200 && response.status < 300;
|
|
}
|
|
async getCustomerById(id) {
|
|
const response = await this.apiRequest(`/kunden/${id}`);
|
|
this.logDebug('Agrarmonitor customer API raw response', response.data);
|
|
if (this.isWrappedApiCustomer(response.data)) {
|
|
return response.data.data;
|
|
}
|
|
if (this.isApiCustomer(response.data)) {
|
|
return response.data;
|
|
}
|
|
throw new Error('Ungueltige Agrarmonitor Kunden-API-Antwort');
|
|
}
|
|
async getMaschinenKategorien() {
|
|
const response = await this.http.get('/maschinen/kategorien');
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#kategorien tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.id),
|
|
name: cells[0]?.textContent?.trim() ?? '',
|
|
zugmaschine: this.parseJaNein(cells[2]?.textContent ?? ''),
|
|
mobilSichtbar: Boolean(cells[4]?.querySelector('input[type="checkbox"]')?.checked),
|
|
};
|
|
});
|
|
}
|
|
async getMaschinen(gruppenId, suchstring = '') {
|
|
const response = await this.http.get('/module/maschinen/livesearch.php', {
|
|
params: {
|
|
suchstring,
|
|
maschinen_aktiv: 1,
|
|
maschinengruppe: gruppenId,
|
|
seite: 1,
|
|
},
|
|
});
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table.maschinen_table tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
|
|
nummer: cells[0]?.textContent?.trim() ?? '',
|
|
name: cells[1]?.textContent?.trim() ?? '',
|
|
kennzeichen: cells[2]?.textContent?.trim() ?? '',
|
|
aktiv: Boolean(cells[4]?.querySelector('.fa-check, .text-green')),
|
|
};
|
|
});
|
|
}
|
|
async getAlleMaschinen() {
|
|
const documents = await this.loadLivesearchSeiten('/module/maschinen/livesearch.php', { suchstring: '', maschinen_aktiv: 1, maschinengruppe: 0 }, 'table.maschinen_table tbody tr[data-id]');
|
|
const maschinen = new Map();
|
|
for (const document of documents) {
|
|
for (const table of Array.from(document.querySelectorAll('table.maschinen_table'))) {
|
|
const gruppenId = this.parseNumber(table.id.replace('maschinen_table_', ''));
|
|
const gruppe = this.vorherigeUeberschrift(table)?.textContent?.trim() ?? '';
|
|
for (const row of Array.from(table.querySelectorAll('tbody tr[data-id]'))) {
|
|
const id = this.parseNumber(row.getAttribute('data-id'));
|
|
const vorhanden = maschinen.get(id);
|
|
if (vorhanden) {
|
|
if (!vorhanden.gruppenIds.includes(gruppenId)) {
|
|
vorhanden.gruppenIds.push(gruppenId);
|
|
}
|
|
continue;
|
|
}
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
maschinen.set(id, {
|
|
id,
|
|
nummer: this.livesearchText(cells[0]),
|
|
name: this.livesearchText(cells[1]),
|
|
kennzeichen: this.livesearchText(cells[2]),
|
|
aktiv: Boolean(cells[4]?.querySelector('.fa-check, .text-green')),
|
|
gruppenId,
|
|
gruppe,
|
|
gruppenIds: [gruppenId],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return Array.from(maschinen.values());
|
|
}
|
|
async getFirmen() {
|
|
const response = await this.http.get('/einstellungen');
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#firmen_table tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.getAttribute('data-firma_id') ?? row.id),
|
|
bezeichnung: cells[0]?.textContent?.trim() ?? '',
|
|
};
|
|
});
|
|
}
|
|
async getArtikelEinheiten() {
|
|
const response = await this.http.get('/artikel/einheiten');
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#einheiten tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
|
|
bezeichnung: cells[0]?.textContent?.trim() ?? '',
|
|
kurz: cells[1]?.textContent?.trim() ?? '',
|
|
};
|
|
});
|
|
}
|
|
async getArtikelKategorien() {
|
|
const response = await this.http.get('/artikel/kategorien');
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#kategorien tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.id),
|
|
name: cells[1]?.textContent?.trim() ?? '',
|
|
};
|
|
});
|
|
}
|
|
async getArtikel(artikelGruppe, suchstring = '') {
|
|
const response = await this.http.get('/module/artikel/livesearch.php', {
|
|
params: {
|
|
suchstring,
|
|
artikel_aktiv: 0,
|
|
warengruppe: artikelGruppe,
|
|
lagerplatz: 0,
|
|
bestand: 0,
|
|
itemsperpage: 100000,
|
|
seite: 1,
|
|
},
|
|
});
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#artikel_table tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
|
|
nummer: cells[0]?.textContent?.trim() ?? '',
|
|
bezeichnung: cells[1]?.textContent?.trim() ?? '',
|
|
};
|
|
});
|
|
}
|
|
async getMitarbeiter(suchstring = '', mitarbeitergruppe = 0) {
|
|
const response = await this.http.get('/module/mitarbeiter/livesearch.php', {
|
|
params: {
|
|
suchstring,
|
|
mitarbeiter_aktiv: 1,
|
|
mitarbeitergruppe,
|
|
itemsperpage: 100000,
|
|
seite: 1,
|
|
},
|
|
});
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const rows = Array.from(document.querySelectorAll('table#mitarbeiter_table tbody tr, table.mitarbeiter_table tbody tr'));
|
|
return rows.map(row => {
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
return {
|
|
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
|
|
nummer: cells[0]?.textContent?.trim() ?? '',
|
|
nachname: cells[2]?.textContent?.trim() ?? '',
|
|
vorname: cells[3]?.textContent?.trim() ?? '',
|
|
aktiv: Boolean(row.querySelector('td.toggle .fa-check, td.toggle .text-green')),
|
|
};
|
|
});
|
|
}
|
|
async getTaetigkeiten() {
|
|
const documents = await this.loadLivesearchSeiten('/module/arbeitszeit/taetigkeiten-livesearch.php', { taetigkeiten_aktiv: 1 }, 'table.taetigkeit_table tbody tr[data-id]');
|
|
const taetigkeiten = new Map();
|
|
for (const document of documents) {
|
|
for (const table of Array.from(document.querySelectorAll('table.taetigkeit_table'))) {
|
|
const bereichElement = this.vorherigeUeberschrift(table)?.querySelector('.bereich');
|
|
for (const row of Array.from(table.querySelectorAll('tbody tr[data-id]'))) {
|
|
const id = this.parseNumber(row.getAttribute('data-id'));
|
|
const cells = Array.from(row.querySelectorAll('td'));
|
|
if (!id || taetigkeiten.has(id)) {
|
|
continue;
|
|
}
|
|
taetigkeiten.set(id, {
|
|
id,
|
|
uuid: row.getAttribute('data-activityuuid') ?? '',
|
|
nummer: cells[0]?.textContent?.trim() ?? '',
|
|
name: cells[1]?.textContent?.trim() ?? '',
|
|
aktiv: Boolean(row.querySelector('td.toggle .fa-check, td.toggle .text-green')),
|
|
bereichId: this.parseNumber(bereichElement?.getAttribute('data-id')),
|
|
bereich: bereichElement?.textContent?.trim() ?? '',
|
|
maschineZuordnen: this.parseJaNein(cells[2]?.textContent ?? ''),
|
|
abwesenheit: this.parseJaNein(cells[3]?.textContent ?? ''),
|
|
notizErzwingen: this.parseJaNein(cells[5]?.textContent ?? ''),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return Array.from(taetigkeiten.values());
|
|
}
|
|
/**
|
|
* Beginnt den Arbeitstag: legt eine laufende Schicht (Anwesenheit ohne Ende)
|
|
* an, wie es das Agrarmonitor-Menue "Arbeitstag beginnen" tut - aber fuer
|
|
* beliebige Mitarbeiter (saveSchicht.php statt bueroAnundAbmelden.php, das nur
|
|
* fuer den angemeldeten Benutzer wirkt). Laeuft am Tag schon eine Schicht,
|
|
* wird deren Referenz geliefert, damit Wiederholungen nichts doppelt anlegen.
|
|
*/
|
|
async beginneArbeitstag(mitarbeiterId, start, options = {}) {
|
|
const beginn = this.arbeitstagZeitpunkt(mitarbeiterId, start, 'start');
|
|
const datum = this.arbeitstagDatum(options.datum, beginn);
|
|
const laufend = (await this.ladeSchichten(mitarbeiterId, datum)).find(schicht => schicht.ende === null);
|
|
if (laufend) {
|
|
this.logDebug('Arbeitstag laeuft bereits', { mitarbeiterId, datum, schichtId: laufend.id });
|
|
return { ref: this.arbeitstagRef(mitarbeiterId, datum, laufend.id) };
|
|
}
|
|
const antwort = await this.zeitbuchungPost('/module/auftraege/api/saveSchicht.php', {
|
|
vorgang_id: '-1',
|
|
mitarbeiter_id: mitarbeiterId,
|
|
formdata: { timestamp_start: beginn.formular, timestamp_ende: '' },
|
|
}, 'Arbeitstag beginnen');
|
|
const id = this.parseNumber(antwort);
|
|
if (!id) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Agrarmonitor hat keine Schicht angelegt', this.zeitbuchungGrund(antwort));
|
|
}
|
|
return { ref: this.arbeitstagRef(mitarbeiterId, datum, id) };
|
|
}
|
|
async beendeArbeitstag(ref, ende) {
|
|
const zielRef = this.parseArbeitstagRef(ref);
|
|
const schicht = await this.ladeArbeitstag(zielRef, ref);
|
|
await this.arbeitstagSetzen(schicht, schicht.start, this.arbeitstagZeitpunkt(zielRef.mitarbeiterId, ende, 'ende').vergleich);
|
|
}
|
|
/** Korrigiert Beginn und Ende; `ende: null` laesst den Arbeitstag weiterlaufen. */
|
|
async updateArbeitstag(ref, zeiten) {
|
|
const zielRef = this.parseArbeitstagRef(ref);
|
|
const schicht = await this.ladeArbeitstag(zielRef, ref);
|
|
const start = this.arbeitstagZeitpunkt(zielRef.mitarbeiterId, zeiten.start, 'start').vergleich;
|
|
const ende = zeiten.ende === null ? null : this.arbeitstagZeitpunkt(zielRef.mitarbeiterId, zeiten.ende, 'ende').vergleich;
|
|
await this.arbeitstagSetzen(schicht, start, ende);
|
|
}
|
|
/** Loescht die Schicht samt aller Taetigkeiten und Pausen darin. */
|
|
async deleteArbeitstag(ref) {
|
|
const zielRef = this.parseArbeitstagRef(ref);
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/deleteSchicht.php', { shiftID: zielRef.id, employeeID: zielRef.mitarbeiterId, date: zielRef.datum }, 'Arbeitstag loeschen');
|
|
}
|
|
arbeitstagZeitpunkt(mitarbeiterId, date, feld) {
|
|
if (!Number.isInteger(mitarbeiterId) || mitarbeiterId <= 0) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Mitarbeiter-ID', 'mitarbeiterId');
|
|
}
|
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(feld === 'start' ? 'Ungueltiger Start' : 'Ungueltiges Ende', feld);
|
|
}
|
|
return this.zeitpunkt(date);
|
|
}
|
|
arbeitstagDatum(datum, start) {
|
|
if (datum !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(datum)) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiger Arbeitstag, erwartet YYYY-MM-DD', 'datum');
|
|
}
|
|
return datum ?? start.datum;
|
|
}
|
|
arbeitstagRef(mitarbeiterId, datum, schichtId) {
|
|
return `schicht:${mitarbeiterId}:${datum}:${schichtId}`;
|
|
}
|
|
parseArbeitstagRef(ref) {
|
|
const match = /^schicht:(\d+):(\d{4}-\d{2}-\d{2}):(\d+)$/.exec(ref);
|
|
if (!match) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Ungueltige Arbeitstag-Referenz: ${ref}`, 'ref');
|
|
}
|
|
return { mitarbeiterId: Number(match[1]), datum: match[2], id: Number(match[3]) };
|
|
}
|
|
async ladeArbeitstag(zielRef, ref) {
|
|
const schicht = await this.ladeSchicht(zielRef.id);
|
|
if (!schicht) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Arbeitstag ${ref} nicht gefunden`, 'nicht gefunden');
|
|
}
|
|
return schicht;
|
|
}
|
|
async arbeitstagSetzen(schicht, start, ende) {
|
|
if (ende !== null && ende <= start) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ende muss mindestens eine Minute nach dem Beginn liegen', 'dauer');
|
|
}
|
|
await this.schichtSetzen({ id: schicht.id, start, ende });
|
|
}
|
|
/**
|
|
* Bucht einen Taetigkeitsblock oder eine Pause. Gibt es fuer Mitarbeiter und
|
|
* Arbeitstag noch keine Schicht, legt `nachtragen.php` Schicht und Verteilung
|
|
* an (Pausen brauchen eine vorhandene Schicht). Sonst kommt die Buchung in die
|
|
* passende Schicht, die bei Bedarf verlaengert wird.
|
|
*/
|
|
async createZeitbuchung(daten) {
|
|
const buchung = this.zeitbuchungAufbereiten(daten);
|
|
const schichten = await this.ladeSchichten(buchung.mitarbeiterId, buchung.datum);
|
|
if (schichten.length === 0) {
|
|
if (buchung.art === 'pause') {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Pause am ${buchung.datum} kann nicht gebucht werden: keine Schicht vorhanden`, 'keine Schicht vorhanden');
|
|
}
|
|
const id = await this.taetigkeitNachtragen(buchung);
|
|
return { ref: this.zeitbuchungRef(buchung, id) };
|
|
}
|
|
const schicht = this.passendeSchicht(schichten, buchung);
|
|
const schichtVorher = { ...schicht };
|
|
await this.schichtErweitern(schicht, buchung);
|
|
try {
|
|
const id = buchung.art === 'pause'
|
|
? await this.pauseAnlegen(buchung, schicht.id)
|
|
: await this.verteilungAnlegen(buchung, schicht.id);
|
|
return { ref: this.zeitbuchungRef(buchung, id) };
|
|
}
|
|
catch (error) {
|
|
if (schicht.start !== schichtVorher.start || schicht.ende !== schichtVorher.ende) {
|
|
await this.schichtSetzen(schichtVorher).catch(restoreError => {
|
|
this.logger?.warn?.('Schicht konnte nicht zurueckgesetzt werden', {
|
|
schichtId: schichtVorher.id,
|
|
fehler: restoreError instanceof Error ? restoreError.message : String(restoreError),
|
|
});
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
async updateZeitbuchung(ref, daten) {
|
|
const zielRef = this.parseZeitbuchungRef(ref);
|
|
const buchung = this.zeitbuchungAufbereiten(daten);
|
|
if (buchung.art !== zielRef.art) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Art der Buchung (Taetigkeit/Pause) kann nicht geaendert werden', 'art geaendert');
|
|
}
|
|
if (buchung.mitarbeiterId !== zielRef.mitarbeiterId || buchung.datum !== zielRef.datum) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Mitarbeiter oder Arbeitstag einer Buchung kann nicht geaendert werden', 'mitarbeiter oder datum geaendert');
|
|
}
|
|
const tagesansicht = await this.ladeTagesansicht(zielRef.mitarbeiterId, zielRef.datum);
|
|
const zeilenKlasse = zielRef.art === 'pause' ? 'pause_betrieb' : 'betrieb';
|
|
const schichtId = this.parseNumber(tagesansicht
|
|
.querySelector(`tr.${zeilenKlasse}[id="${zielRef.id}"]`)
|
|
?.closest('table.verteilung_table')
|
|
?.getAttribute('data-schicht'));
|
|
if (!schichtId) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Buchung ${ref} nicht gefunden`, 'nicht gefunden');
|
|
}
|
|
const schicht = await this.ladeSchicht(schichtId);
|
|
if (schicht) {
|
|
await this.schichtErweitern(schicht, buchung);
|
|
}
|
|
if (buchung.art === 'pause') {
|
|
await this.pauseSpeichern(buchung, zielRef.id, 0);
|
|
return;
|
|
}
|
|
const formular = await this.ladeVerteilungFormular({
|
|
employeeID: zielRef.mitarbeiterId,
|
|
date: zielRef.datum,
|
|
distributionID: String(zielRef.id),
|
|
});
|
|
this.taetigkeitPruefen(formular, buchung.taetigkeitId);
|
|
const notizen = Array.from(formular.querySelectorAll('#notizen_table tbody tr[data-id]'))
|
|
.map(row => ({
|
|
id: this.parseNumber(row.getAttribute('data-id')),
|
|
text: this.normalisiereText(row.querySelectorAll('td')[2]?.textContent ?? ''),
|
|
}))
|
|
.filter(notiz => notiz.id > 0);
|
|
const notizUnveraendert = (notizen.length === 0 && !buchung.notiz) ||
|
|
(notizen.length === 1 && notizen[0].text === this.normalisiereText(buchung.notiz));
|
|
await this.verteilungSpeichern(buchung, zielRef.id, {
|
|
alteTaetigkeitId: this.selectedValue(formular, '#taetigkeit_id') || '0',
|
|
projektId: this.selectedValue(formular, '#projekt_id') || '0',
|
|
verfasserId: formular.querySelector('#verfasser_id')?.value ?? '',
|
|
notiz: notizUnveraendert ? '' : buchung.notiz,
|
|
});
|
|
// Alte Notizen erst nach erfolgreichem Speichern entfernen.
|
|
if (!notizUnveraendert) {
|
|
for (const notiz of notizen) {
|
|
await this.zeitbuchungPost('/module/notiz/api/delete.php', { noteID: notiz.id }, 'Notiz loeschen');
|
|
}
|
|
}
|
|
}
|
|
async deleteZeitbuchung(ref) {
|
|
const zielRef = this.parseZeitbuchungRef(ref);
|
|
if (zielRef.art === 'pause') {
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/deletePause.php', { pauseID: zielRef.id, employeeID: zielRef.mitarbeiterId, date: zielRef.datum }, 'Pause loeschen');
|
|
return;
|
|
}
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/deleteVerteilung.php', { distributionID: zielRef.id, employeeID: zielRef.mitarbeiterId, date: zielRef.datum }, 'Buchung loeschen');
|
|
}
|
|
zeitbuchungAufbereiten(daten) {
|
|
if (!Number.isInteger(daten.mitarbeiterId) || daten.mitarbeiterId <= 0) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Mitarbeiter-ID', 'mitarbeiterId');
|
|
}
|
|
if (!(daten.start instanceof Date) || Number.isNaN(daten.start.getTime())) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiger Start', 'start');
|
|
}
|
|
if (!(daten.ende instanceof Date) || Number.isNaN(daten.ende.getTime())) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiges Ende', 'ende');
|
|
}
|
|
if (daten.datum !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(daten.datum)) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiger Arbeitstag, erwartet YYYY-MM-DD', 'datum');
|
|
}
|
|
const start = this.zeitpunkt(daten.start);
|
|
const ende = this.zeitpunkt(daten.ende);
|
|
if (ende.vergleich <= start.vergleich) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ende muss mindestens eine Minute nach dem Start liegen', 'dauer');
|
|
}
|
|
const basis = {
|
|
mitarbeiterId: daten.mitarbeiterId,
|
|
datum: daten.datum ?? start.datum,
|
|
start,
|
|
ende,
|
|
};
|
|
if (daten.art === 'pause') {
|
|
return { ...basis, art: 'pause', taetigkeitId: 0, maschinenId: 0, notiz: '' };
|
|
}
|
|
if (!Number.isInteger(daten.taetigkeitId) || daten.taetigkeitId <= 0) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Taetigkeits-ID', 'taetigkeitId');
|
|
}
|
|
const maschinenId = daten.maschinenId ?? 0;
|
|
if (!Number.isInteger(maschinenId) || maschinenId < 0) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Maschinen-ID', 'maschinenId');
|
|
}
|
|
const kostenstelle = daten.kostenstelle?.trim();
|
|
const notiz = [daten.notiz?.trim(), kostenstelle ? `Kostenstelle: ${kostenstelle}` : '']
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
return { ...basis, art: 'taetigkeit', taetigkeitId: daten.taetigkeitId, maschinenId, notiz };
|
|
}
|
|
/** Rundet auf die volle Minute - Agrarmonitor speichert keine Sekunden. */
|
|
zeitpunkt(date) {
|
|
const gerundet = new Date(Math.round(date.getTime() / 60000) * 60000);
|
|
const teile = Object.fromEntries(AgrarmonitorConnector.berlinFormat.formatToParts(gerundet).map(teil => [teil.type, teil.value]));
|
|
const datum = `${teile.year}-${teile.month}-${teile.day}`;
|
|
const uhrzeit = `${teile.hour}:${teile.minute}`;
|
|
return {
|
|
vergleich: `${datum} ${uhrzeit}`,
|
|
formular: this.vergleichZuFormular(`${datum} ${uhrzeit}`),
|
|
datum,
|
|
uhrzeit,
|
|
};
|
|
}
|
|
/** YYYY-MM-DD HH:MM -> dd.mm.yy (HH:MM) */
|
|
vergleichZuFormular(wert) {
|
|
const [datum, uhrzeit] = wert.split(' ');
|
|
const [jahr, monat, tag] = datum.split('-');
|
|
return `${tag}.${monat}.${jahr.slice(2)} (${uhrzeit})`;
|
|
}
|
|
zeitbuchungRef(buchung, id) {
|
|
return `${buchung.art === 'pause' ? 'pause' : 'verteilung'}:${buchung.mitarbeiterId}:${buchung.datum}:${id}`;
|
|
}
|
|
parseZeitbuchungRef(ref) {
|
|
const match = /^(verteilung|pause):(\d+):(\d{4}-\d{2}-\d{2}):(\d+)$/.exec(ref);
|
|
if (!match) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Ungueltige Zeitbuchungs-Referenz: ${ref}`, 'ref');
|
|
}
|
|
return {
|
|
art: match[1] === 'pause' ? 'pause' : 'taetigkeit',
|
|
mitarbeiterId: Number(match[2]),
|
|
datum: match[3],
|
|
id: Number(match[4]),
|
|
};
|
|
}
|
|
async ladeTagesansicht(mitarbeiterId, datum) {
|
|
const response = await this.http.get(`/arbeitszeit/detail/${mitarbeiterId}/${datum}`);
|
|
await this.saveSession();
|
|
return this.parseHtmlDocument(response.data);
|
|
}
|
|
async ladeSchichten(mitarbeiterId, datum) {
|
|
const tagesansicht = await this.ladeTagesansicht(mitarbeiterId, datum);
|
|
const ids = new Set(Array.from(tagesansicht.querySelectorAll('table.verteilung_table[data-schicht]'))
|
|
.map(table => this.parseNumber(table.getAttribute('data-schicht')))
|
|
.filter(id => id > 0));
|
|
const schichten = [];
|
|
for (const id of ids) {
|
|
const schicht = await this.ladeSchicht(id);
|
|
if (schicht) {
|
|
schichten.push(schicht);
|
|
}
|
|
}
|
|
return schichten;
|
|
}
|
|
async ladeSchicht(schichtId) {
|
|
const response = await this.http.get('/module/arbeitszeit/api/getSchicht.php', {
|
|
params: { schicht_id: schichtId },
|
|
});
|
|
await this.saveSession();
|
|
const data = (typeof response.data === 'string' ? JSON.parse(response.data || 'null') : response.data);
|
|
if (!data?.timestamp_start || Number(data.ist_deleted) === 1) {
|
|
return null;
|
|
}
|
|
const start = data.timestamp_start.slice(0, 16);
|
|
const ende = data.timestamp_ende && !data.timestamp_ende.startsWith('0000') ? data.timestamp_ende.slice(0, 16) : null;
|
|
return { id: schichtId, start, ende };
|
|
}
|
|
/** Schicht, die sich mit der Buchung ueberschneidet, sonst die letzte davor, sonst die erste. */
|
|
passendeSchicht(schichten, buchung) {
|
|
return (schichten.find(schicht => schicht.start <= buchung.ende.vergleich && (schicht.ende === null || buchung.start.vergleich <= schicht.ende)) ??
|
|
schichten.filter(schicht => schicht.start <= buchung.start.vergleich).at(-1) ??
|
|
schichten[0]);
|
|
}
|
|
/**
|
|
* Neue Verteilungen verlaengern die Schicht in Agrarmonitor nicht von selbst.
|
|
* Eine laufende Schicht bleibt offen - nur ihr Start wird bei Bedarf vorgezogen.
|
|
*/
|
|
async schichtErweitern(schicht, buchung) {
|
|
const start = buchung.start.vergleich < schicht.start ? buchung.start.vergleich : schicht.start;
|
|
const ende = schicht.ende === null || buchung.ende.vergleich <= schicht.ende ? schicht.ende : buchung.ende.vergleich;
|
|
if (start === schicht.start && ende === schicht.ende) {
|
|
return;
|
|
}
|
|
await this.schichtSetzen({ id: schicht.id, start, ende });
|
|
schicht.start = start;
|
|
schicht.ende = ende;
|
|
}
|
|
async schichtSetzen(schicht) {
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/updateSchicht.php', {
|
|
shiftID: schicht.id,
|
|
timestampStart: this.vergleichZuFormular(schicht.start),
|
|
// Leeres Ende laesst die Schicht laufen.
|
|
timestampEnd: schicht.ende === null ? '' : this.vergleichZuFormular(schicht.ende),
|
|
}, 'Schicht anpassen');
|
|
}
|
|
/**
|
|
* Erste Buchung des Tages: nachtragen.php legt Schicht und Verteilung an,
|
|
* liefert aber keine ID. Die Verteilung wird danach in der Tagesansicht gesucht.
|
|
*/
|
|
async taetigkeitNachtragen(buchung) {
|
|
const nachtragenFormular = await this.http.get('/module/arbeitszeit/api/nachtragen-form.php');
|
|
await this.saveSession();
|
|
this.taetigkeitPruefen(this.parseHtmlDocument(nachtragenFormular.data), buchung.taetigkeitId);
|
|
const amArbeitstag = buchung.start.datum === buchung.datum && buchung.ende.datum === buchung.datum;
|
|
const form = {
|
|
taetigkeit_id: String(buchung.taetigkeitId),
|
|
projekt_id: '0',
|
|
arbeitszeit_start: buchung.start.uhrzeit,
|
|
// Nachtragen kennt nur Uhrzeiten; ueber Mitternacht wird unten korrigiert.
|
|
arbeitszeit_ende: amArbeitstag ? buchung.ende.uhrzeit : '23:59',
|
|
arbeitzeit_sollstunden: '',
|
|
pause_start: '',
|
|
pause_ende: '',
|
|
maschine_id: String(buchung.maschinenId),
|
|
notiz: buchung.notiz,
|
|
uebernehmen: '1',
|
|
mitarbeitertyp: '1',
|
|
mitarbeiter_id: String(buchung.mitarbeiterId),
|
|
kategorie_id: '0',
|
|
};
|
|
const body = new URLSearchParams();
|
|
for (const [name, value] of Object.entries(form)) {
|
|
body.append(`form[${name}]`, value);
|
|
}
|
|
body.append('tage[]', buchung.datum);
|
|
body.append('anzahl_datum', '1');
|
|
body.append('planungAusMitarbeiterId', '0');
|
|
const response = await this.http.post('/module/arbeitszeit/api/nachtragen.php', body, {
|
|
...this.formPostConfig('/arbeitszeit'),
|
|
validateStatus: status => status >= 200 && status < 500,
|
|
});
|
|
await this.saveSession();
|
|
this.zeitbuchungAntwortPruefen(response, 'Nachtragen');
|
|
const tagesansicht = await this.ladeTagesansicht(buchung.mitarbeiterId, buchung.datum);
|
|
const zeilen = Array.from(tagesansicht.querySelectorAll('table.verteilung_table tr.betrieb[id]'));
|
|
if (zeilen.length !== 1) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Nachgetragene Buchung am ${buchung.datum} nicht eindeutig gefunden (${zeilen.length} Buchungen)`, 'nicht eindeutig');
|
|
}
|
|
const id = this.parseNumber(zeilen[0].id);
|
|
if (!amArbeitstag) {
|
|
const formular = await this.ladeVerteilungFormular({
|
|
employeeID: buchung.mitarbeiterId,
|
|
date: buchung.datum,
|
|
distributionID: String(id),
|
|
});
|
|
await this.verteilungSpeichern(buchung, id, {
|
|
alteTaetigkeitId: String(buchung.taetigkeitId),
|
|
projektId: '0',
|
|
verfasserId: formular.querySelector('#verfasser_id')?.value ?? '',
|
|
notiz: '',
|
|
});
|
|
const schicht = await this.ladeSchicht(this.parseNumber(zeilen[0].closest('table.verteilung_table')?.getAttribute('data-schicht')));
|
|
if (schicht) {
|
|
await this.schichtErweitern(schicht, buchung);
|
|
}
|
|
}
|
|
return id;
|
|
}
|
|
/**
|
|
* editVerteilungForm.php mit shiftID legt sofort eine leere Verteilung an
|
|
* (das Agrarmonitor-UI loescht sie bei "Abbrechen"). Schlaegt das Speichern
|
|
* fehl, wird dieser Platzhalter wieder entfernt.
|
|
*/
|
|
async verteilungAnlegen(buchung, schichtId) {
|
|
const formular = await this.ladeVerteilungFormular({
|
|
employeeID: buchung.mitarbeiterId,
|
|
date: buchung.datum,
|
|
shiftID: schichtId,
|
|
});
|
|
const id = this.parseNumber(formular.querySelector('#verteilung_form')?.getAttribute('data-id'));
|
|
if (!id) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Agrarmonitor hat keine neue Buchung angelegt', 'keine verteilung_id');
|
|
}
|
|
try {
|
|
this.taetigkeitPruefen(formular, buchung.taetigkeitId);
|
|
await this.verteilungSpeichern(buchung, id, {
|
|
alteTaetigkeitId: '0',
|
|
projektId: '0',
|
|
verfasserId: formular.querySelector('#verfasser_id')?.value ?? '',
|
|
notiz: buchung.notiz,
|
|
});
|
|
}
|
|
catch (error) {
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/deleteVerteilung.php', { distributionID: id, employeeID: buchung.mitarbeiterId, date: buchung.datum }, 'Platzhalter loeschen').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
return id;
|
|
}
|
|
async ladeVerteilungFormular(body) {
|
|
const html = await this.zeitbuchungPost('/module/arbeitszeit/api/editVerteilungForm.php', body, 'Buchungsformular laden');
|
|
const formular = this.parseHtmlDocument(html);
|
|
if (!formular.querySelector('#verteilung_form')) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Buchungsformular nicht gefunden', 'kein verteilung_form');
|
|
}
|
|
return formular;
|
|
}
|
|
async verteilungSpeichern(buchung, id, formular) {
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/updateVerteilung.php', {
|
|
mitarbeiterId: buchung.mitarbeiterId,
|
|
verteilungId: String(id),
|
|
verteilungForm: {
|
|
timestamp_start: buchung.start.formular,
|
|
timestamp_ende: buchung.ende.formular,
|
|
alte_taetigkeit_id: formular.alteTaetigkeitId,
|
|
taetigkeit_id: String(buchung.taetigkeitId),
|
|
ganzertag: '1',
|
|
maschine_id: String(buchung.maschinenId),
|
|
projekt_id: formular.projektId,
|
|
},
|
|
// Nicht leere notizfeld werden als neue Notiz an die Verteilung gehaengt.
|
|
notizForm: {
|
|
notizfeld: formular.notiz,
|
|
verfasser_id: formular.verfasserId,
|
|
},
|
|
}, 'Buchung speichern');
|
|
}
|
|
/** Wie bei Verteilungen legt editPauseForm.php mit shiftID sofort eine Pause an. */
|
|
async pauseAnlegen(buchung, schichtId) {
|
|
const html = await this.zeitbuchungPost('/module/arbeitszeit/api/editPauseForm.php', { employeeID: buchung.mitarbeiterId, date: buchung.datum, shiftID: schichtId }, 'Pausenformular laden');
|
|
const id = this.parseNumber(this.parseHtmlDocument(html).querySelector('#pause_form')?.getAttribute('data-id'));
|
|
if (!id) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError('Agrarmonitor hat keine neue Pause angelegt', 'keine pause_id');
|
|
}
|
|
try {
|
|
await this.pauseSpeichern(buchung, id, 1);
|
|
}
|
|
catch (error) {
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/deletePause.php', { pauseID: id, employeeID: buchung.mitarbeiterId, date: buchung.datum }, 'Platzhalter loeschen').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
return id;
|
|
}
|
|
async pauseSpeichern(buchung, id, neu) {
|
|
await this.zeitbuchungPost('/module/arbeitszeit/api/updatePause.php', {
|
|
mitarbeiterId: buchung.mitarbeiterId,
|
|
pause_id: String(id),
|
|
pauseForm: {
|
|
pause_start: buchung.start.formular,
|
|
pause_ende: buchung.ende.formular,
|
|
},
|
|
neu,
|
|
}, 'Pause speichern');
|
|
}
|
|
/**
|
|
* JSON-POST an die Arbeitszeit-API. 4xx (z. B. 422 mit Grund im Body) wird
|
|
* als ZeitbuchungError geworfen; Netzwerk- und 5xx-Fehler bleiben Axios-Fehler.
|
|
*/
|
|
async zeitbuchungPost(url, body, aktion) {
|
|
const response = await this.http.post(url, body, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json, */*',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
Referer: `${this.baseUrl}/arbeitszeit`,
|
|
Origin: this.baseUrl,
|
|
},
|
|
validateStatus: status => status >= 200 && status < 500,
|
|
});
|
|
await this.saveSession();
|
|
this.zeitbuchungAntwortPruefen(response, aktion);
|
|
return response.data;
|
|
}
|
|
zeitbuchungAntwortPruefen(response, aktion) {
|
|
if (response.status >= 200 && response.status < 300) {
|
|
return;
|
|
}
|
|
const grund = this.zeitbuchungGrund(response.data);
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`${aktion} fehlgeschlagen (HTTP ${response.status}): ${grund}`, grund, response.status);
|
|
}
|
|
/** 422-Antworten sehen so aus: {"verteilungForm":"ganzertag must be present"} */
|
|
zeitbuchungGrund(data) {
|
|
if (data && typeof data === 'object') {
|
|
const texte = Object.values(data)
|
|
.flatMap(wert => (wert && typeof wert === 'object' ? Object.values(wert) : [wert]))
|
|
.filter((wert) => typeof wert === 'string' && wert.trim() !== '');
|
|
return texte.length ? texte.join('; ') : JSON.stringify(data).slice(0, 500);
|
|
}
|
|
return String(data ?? '').trim().slice(0, 500) || 'keine Antwort';
|
|
}
|
|
/**
|
|
* Agrarmonitor speichert auch unbekannte oder inaktive Taetigkeits-IDs (dann
|
|
* ohne Taetigkeit). Deshalb gegen die Auswahl im Buchungsformular pruefen.
|
|
*/
|
|
taetigkeitPruefen(formular, taetigkeitId) {
|
|
const buchbar = Array.from(formular.querySelectorAll('select#taetigkeit_id option')).map(option => option.value);
|
|
if (!buchbar.includes(String(taetigkeitId))) {
|
|
throw new ZeitbuchungError_1.ZeitbuchungError(`Taetigkeit ${taetigkeitId} ist in Agrarmonitor nicht buchbar (unbekannt oder inaktiv)`, 'taetigkeit nicht buchbar');
|
|
}
|
|
}
|
|
selectedValue(document, selector) {
|
|
const select = document.querySelector(selector);
|
|
return select?.querySelector('option[selected]')?.value ?? select?.value ?? '';
|
|
}
|
|
normalisiereText(text) {
|
|
return text.replace(/\s+/g, ' ').trim();
|
|
}
|
|
/**
|
|
* Laedt eine Livesearch-Liste Seite fuer Seite. Die Seitenzahl steht in
|
|
* `.pagination[data-pages]`; zusaetzlich bricht die Schleife ab, sobald eine
|
|
* Seite keine neuen Zeilen bringt (Agrarmonitor ignoriert `seite` teils).
|
|
*/
|
|
async loadLivesearchSeiten(url, params, rowSelector) {
|
|
const documents = [];
|
|
const bekannteZeilen = new Set();
|
|
for (let seite = 1; seite <= AgrarmonitorConnector.maxLivesearchSeiten; seite += 1) {
|
|
const response = await this.http.get(url, { params: { ...params, seite } });
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const neueZeilen = Array.from(document.querySelectorAll(rowSelector))
|
|
.map(row => `${row.closest('table')?.id ?? ''}:${row.getAttribute('data-id') ?? row.id}`)
|
|
.filter(key => !bekannteZeilen.has(key));
|
|
if (neueZeilen.length === 0) {
|
|
break;
|
|
}
|
|
neueZeilen.forEach(key => bekannteZeilen.add(key));
|
|
documents.push(document);
|
|
const seiten = this.parseNumber(document.querySelector('.pagination')?.getAttribute('data-pages'));
|
|
if (seite >= seiten) {
|
|
break;
|
|
}
|
|
}
|
|
return documents;
|
|
}
|
|
vorherigeUeberschrift(element) {
|
|
let sibling = element.previousElementSibling;
|
|
while (sibling && !/^H[1-6]$/.test(sibling.tagName)) {
|
|
sibling = sibling.previousElementSibling;
|
|
}
|
|
return sibling;
|
|
}
|
|
/** Agrarmonitor zeigt leere Werte in Livesearch-Tabellen als "-" an. */
|
|
livesearchText(cell) {
|
|
const text = cell?.textContent?.trim() ?? '';
|
|
return text === '-' ? '' : text;
|
|
}
|
|
createHttpClient() {
|
|
const client = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
|
|
baseURL: this.baseUrl,
|
|
jar: this.cookieJar,
|
|
withCredentials: true,
|
|
timeout: this.timeoutMs,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
},
|
|
maxRedirects: 5,
|
|
validateStatus: status => status >= 200 && status < 400,
|
|
}));
|
|
client.interceptors.response.use(async (response) => {
|
|
await this.options.cookieStore.save(this.cookieJar);
|
|
const config = response.config;
|
|
if (this.autoRetry && !config._agrarmonitorLoginRequest && this.isLoginRequiredResponse(response)) {
|
|
return this.retryAfterLogin(config);
|
|
}
|
|
return response;
|
|
}, async (error) => {
|
|
const response = error.response;
|
|
const config = error.config;
|
|
if (this.autoRetry && response && config && !config._agrarmonitorLoginRequest && this.isLoginRequiredResponse(response)) {
|
|
return this.retryAfterLogin(config);
|
|
}
|
|
throw error;
|
|
});
|
|
return client;
|
|
}
|
|
createApiHttpClient(apiToken = this.options.apiToken) {
|
|
return axios_1.default.create({
|
|
baseURL: this.apiBaseUrl,
|
|
timeout: this.timeoutMs,
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}),
|
|
},
|
|
validateStatus: status => status >= 200 && status < 500,
|
|
});
|
|
}
|
|
async apiRequest(url, config = {}) {
|
|
const apiToken = config.apiToken ?? this.options.apiToken;
|
|
if (!apiToken) {
|
|
throw new Error('Agrarmonitor API-Token nicht konfiguriert');
|
|
}
|
|
const { apiToken: _apiToken, ...axiosConfig } = config;
|
|
const client = apiToken === this.options.apiToken ? this.apiHttp : this.createApiHttpClient(apiToken);
|
|
return client.get(url, axiosConfig);
|
|
}
|
|
async fetchAllCustomers(options) {
|
|
const customers = [];
|
|
let page = 1;
|
|
let lastPage = 1;
|
|
do {
|
|
const response = await this.apiRequest('/kunden', {
|
|
params: {
|
|
per_page: options.perPage ?? 99999,
|
|
page,
|
|
},
|
|
apiToken: options.apiToken,
|
|
});
|
|
if (!response.data || !Array.isArray(response.data.data)) {
|
|
throw new Error('Ungueltige Agrarmonitor API-Antwort');
|
|
}
|
|
customers.push(...response.data.data);
|
|
lastPage = this.parsePositiveNumber(response.data.meta?.last_page, page);
|
|
page++;
|
|
} while (page <= lastPage);
|
|
return customers;
|
|
}
|
|
mapKunde2(customer) {
|
|
return {
|
|
ID: this.parseNumber(customer.id),
|
|
ist_Kunde: this.toBoolean(customer.ist_kunde),
|
|
Kundennummer: this.toStringValue(customer.kundennummer),
|
|
ist_Lieferant: this.toBoolean(customer.ist_lieferant),
|
|
Lieferantennummer: this.toStringValue(customer.lieferantennummer),
|
|
Firma: this.toStringValue(customer.firma),
|
|
Anrede: this.toStringValue(customer.anrede),
|
|
Vorname: this.toStringValue(customer.vorname),
|
|
Nachname: this.toStringValue(customer.nachname),
|
|
Strasse: this.toStringValue(customer.strasse),
|
|
Plz: this.toStringValue(customer.plz),
|
|
Ort: this.toStringValue(customer.ort),
|
|
Land: this.toStringValue(customer.land_iso_3166),
|
|
ist_aktiv: this.toBoolean(customer.ist_aktiv),
|
|
};
|
|
}
|
|
async performLogin() {
|
|
if (!this.options.username || !this.options.password) {
|
|
throw new Error('Agrarmonitor-Credentials nicht konfiguriert');
|
|
}
|
|
this.logger?.info?.('Fuehre Agrarmonitor-Login durch');
|
|
this.logLoginDetail('Login gestartet', {
|
|
phase: 'login-start',
|
|
baseUrl: this.baseUrl,
|
|
username: this.options.username,
|
|
loginStrategy: this.loginStrategy,
|
|
autoRetry: this.autoRetry,
|
|
cookies: this.formatCookies(this.snapshotCookies()),
|
|
});
|
|
if (this.loginStrategy === 'auto') {
|
|
this.logger?.warn?.('loginStrategy "auto" ist veraltet; verwende Redirect-Login');
|
|
}
|
|
await this.performRedirectLogin();
|
|
if (!(await this.isSessionValid({ skipAutoRetry: true }))) {
|
|
throw new Error('Agrarmonitor-Login durchgefuehrt, Session weiterhin ungueltig - Geraet freigeschaltet? Credentials korrekt?');
|
|
}
|
|
await this.options.cookieStore.save(this.cookieJar);
|
|
this.logger?.info?.('Agrarmonitor-Login erfolgreich');
|
|
this.logLoginDetail('Login abgeschlossen', {
|
|
phase: 'login-done',
|
|
cookies: this.formatCookies(this.snapshotCookies()),
|
|
});
|
|
}
|
|
async performRedirectLogin() {
|
|
const loginPageResponse = await this.loggedRequest('Loginseite', 'GET', '/', () => this.http.get('/', this.loginRequestConfig()));
|
|
const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
|
|
if (!this.isLoginPageText(loginPageText)) {
|
|
this.logLoginDetail('Keine Loginseite erhalten, Session ist bereits gueltig', {
|
|
phase: 'login-skip',
|
|
url: '/',
|
|
});
|
|
return;
|
|
}
|
|
const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]');
|
|
this.logLoginDetail('Nonce aus Loginseite gelesen', {
|
|
phase: 'nonce',
|
|
url: '/',
|
|
nonce: this.maskNonce(nonce),
|
|
});
|
|
const loginData = {
|
|
username: this.options.username,
|
|
passwort: this.options.password,
|
|
nonce,
|
|
ssoAction: "",
|
|
ssoReturn: "",
|
|
};
|
|
const loginPostConfig = {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
_agrarmonitorLoginRequest: true,
|
|
};
|
|
const response = await this.loggedRequest('Login-POST', 'POST', '/login/api/login.php', () => this.http.post('/login/api/login.php', loginData, loginPostConfig), {
|
|
contentType: 'application/json',
|
|
body: { ...loginData, passwort: '***', nonce: this.maskNonce(nonce) },
|
|
});
|
|
const responseText = typeof response.data === 'string' ? response.data : '';
|
|
if (this.isLoginPageText(responseText)) {
|
|
throw new Error('Agrarmonitor-Redirect-Login fehlgeschlagen');
|
|
}
|
|
}
|
|
async isSessionValid(options = {}) {
|
|
try {
|
|
const response = await this.http.get('/', options.skipAutoRetry ? this.loginRequestConfig() : undefined);
|
|
const grund = this.loginRequiredReason(response);
|
|
this.logLoginDetail(`Session-Pruefung: ${grund ? 'ungueltig' : 'gueltig'}`, {
|
|
phase: 'session-check',
|
|
url: '/',
|
|
status: response.status,
|
|
gueltig: grund === null,
|
|
grund,
|
|
cookies: this.formatCookies(this.snapshotCookies()),
|
|
});
|
|
return grund === null;
|
|
}
|
|
catch (error) {
|
|
this.logLoginDetail('Session-Pruefung fehlgeschlagen', {
|
|
phase: 'session-check',
|
|
url: '/',
|
|
gueltig: false,
|
|
grund: 'request-fehler',
|
|
fehler: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
loginRequestConfig() {
|
|
return {
|
|
_agrarmonitorLoginRequest: true,
|
|
};
|
|
}
|
|
isLoginRequiredResponse(response) {
|
|
return this.loginRequiredReason(response) !== null;
|
|
}
|
|
/**
|
|
* Liefert das Kriterium, an dem eine abgelaufene Session erkannt wurde,
|
|
* oder null fuer eine gueltige Session.
|
|
*/
|
|
loginRequiredReason(response) {
|
|
const responseText = typeof response.data === 'string' ? response.data : '';
|
|
if (response.status === 401 || response.status === 403) {
|
|
return `status-${response.status}`;
|
|
}
|
|
if (this.isLoginPageText(responseText)) {
|
|
return 'login-markup';
|
|
}
|
|
return null;
|
|
}
|
|
async retryAfterLogin(config) {
|
|
if (config._agrarmonitorRetry) {
|
|
throw new Error('Agrarmonitor-Request nach erneutem Login weiterhin nicht autorisiert');
|
|
}
|
|
config._agrarmonitorRetry = true;
|
|
const method = (config.method ?? 'get').toUpperCase();
|
|
const url = config.url ?? '/';
|
|
this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt');
|
|
this.logLoginDetail(`Session abgelaufen, wiederhole ${method} ${url} nach erneutem Login`, {
|
|
phase: 'session-expired',
|
|
method,
|
|
url,
|
|
cookies: this.formatCookies(this.snapshotCookies()),
|
|
});
|
|
await this.login();
|
|
return this.loggedRequest('Wiederholung nach Login', method, url, () => this.http.request(config), {}, 'retry');
|
|
}
|
|
createDateienLivesearchParams(suchstring) {
|
|
return {
|
|
suchstring,
|
|
stammdatum_typ: -1,
|
|
mobil: -1,
|
|
sensibel: -1,
|
|
firma: 0,
|
|
itemsperpage: 100000,
|
|
seite: 1,
|
|
};
|
|
}
|
|
async getEingangsrechnungEditMeta(rechnungId) {
|
|
const document = await this.loadEingangsrechnungEditForm(rechnungId);
|
|
return {
|
|
interneBelegNummer: this.inputValue(document, 'lieferscheinnummer'),
|
|
kundenId: this.selectedNumberValue(document, 'rgempf'),
|
|
betriebId: this.selectedNumberValue(document, 'firma_id'),
|
|
dokumentTyp: this.selectedNumberValue(document, 'typ'),
|
|
};
|
|
}
|
|
async loadEingangsrechnungEditForm(rechnungId) {
|
|
const response = await this.http.get('/module/eingangsrechnungen/api/editForm.php', {
|
|
params: {
|
|
invoiceID: rechnungId,
|
|
},
|
|
});
|
|
await this.saveSession();
|
|
return this.parseHtmlDocument(response.data);
|
|
}
|
|
/** Flaches Objekt aus allen Formularfeldern, wie es das Agrarmonitor-Frontend an update.php schickt. */
|
|
serializeForm(document, formSelector) {
|
|
const form = document.querySelector(formSelector);
|
|
if (!form) {
|
|
throw new Error(`Formular ${formSelector} nicht gefunden`);
|
|
}
|
|
const values = {};
|
|
for (const field of Array.from(form.querySelectorAll('input, select, textarea'))) {
|
|
// Disabled-Felder bewusst mitnehmen: Bei gebuchten Rechnungen liefert editForm.php
|
|
// alle Felder disabled, update.php verlangt sie aber (sonst HTTP 422).
|
|
if (!field.name) {
|
|
continue;
|
|
}
|
|
if (field instanceof document.defaultView.HTMLInputElement) {
|
|
if (['button', 'submit', 'reset', 'image', 'file'].includes(field.type)) {
|
|
continue;
|
|
}
|
|
if (field.type === 'checkbox') {
|
|
values[field.name] = field.checked ? '1' : '0';
|
|
continue;
|
|
}
|
|
if (field.type === 'radio') {
|
|
if (field.checked) {
|
|
values[field.name] = field.value;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
values[field.name] = field.value;
|
|
}
|
|
return values;
|
|
}
|
|
async getEingangsrechnungDetailMeta(rechnungId) {
|
|
const response = await this.http.get(`/eingangsrechnungen/detail/${rechnungId}`);
|
|
await this.saveSession();
|
|
const document = this.parseHtmlDocument(response.data);
|
|
const receivedStatus = document.querySelector('#receivedStatus');
|
|
const receivedText = receivedStatus?.textContent?.trim() ?? '';
|
|
// Buchungsstatus steht in #bookedStatus. Fallback für das alte Layout: der Teil der
|
|
// Statuszeile mit "gebucht" (nicht einfach der letzte, dahinter folgt "Zahlung erfassen").
|
|
const bookingText = document.querySelector('#bookedStatus')?.textContent?.trim() ??
|
|
(receivedStatus?.parentElement?.textContent ?? '')
|
|
.split('-')
|
|
.map(part => part.trim())
|
|
.find(part => /gebucht/i.test(part)) ??
|
|
'';
|
|
return {
|
|
eingangsDatum: !receivedText || receivedText === 'Nicht empfangen'
|
|
? null
|
|
: this.parseGermanShortDateFromText(receivedText.slice(13).trim()),
|
|
buchungsDatum: !bookingText || bookingText === 'Nicht gebucht'
|
|
? null
|
|
: this.parseGermanShortDateFromText(bookingText.slice(11).trim()),
|
|
};
|
|
}
|
|
formPostConfig(refererPath) {
|
|
return {
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Referer: `${this.baseUrl}${refererPath}`,
|
|
Origin: this.baseUrl,
|
|
},
|
|
validateStatus: status => status >= 200 && status < 400,
|
|
};
|
|
}
|
|
parseHtmlDocument(data) {
|
|
return new jsdom_1.JSDOM(typeof data === 'string' ? data : String(data ?? '')).window.document;
|
|
}
|
|
hasTableRows(data, selector) {
|
|
return this.parseHtmlDocument(data).querySelectorAll(selector).length > 0;
|
|
}
|
|
inputValue(document, name) {
|
|
return document.querySelector(`input[name="${name}"]`)?.value.trim() ?? '';
|
|
}
|
|
selectedNumberValue(document, name) {
|
|
return this.parseNumber(document.querySelector(`select[name="${name}"] option:checked`)?.value);
|
|
}
|
|
parseNumber(value) {
|
|
const numberValue = Number(String(value ?? '').trim());
|
|
return Number.isFinite(numberValue) ? numberValue : 0;
|
|
}
|
|
parsePositiveNumber(value, fallback) {
|
|
const numberValue = this.parseNumber(value);
|
|
return numberValue > 0 ? numberValue : fallback;
|
|
}
|
|
toStringValue(value) {
|
|
return value === null || typeof value === 'undefined' ? '' : String(value);
|
|
}
|
|
toBoolean(value) {
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value === 1;
|
|
}
|
|
const normalized = String(value ?? '').trim().toLowerCase();
|
|
return normalized === '1' || normalized === 'true' || normalized === 'ja';
|
|
}
|
|
parseJaNein(value) {
|
|
return value.trim().toLowerCase() === 'ja';
|
|
}
|
|
parseGermanShortDate(value) {
|
|
const match = value.trim().match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const [, day, month, year] = match;
|
|
const parsed = new Date(Number(`20${year}`), Number(month) - 1, Number(day));
|
|
if (parsed.getFullYear() !== Number(`20${year}`) ||
|
|
parsed.getMonth() !== Number(month) - 1 ||
|
|
parsed.getDate() !== Number(day)) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
}
|
|
requireDate(value, fieldName) {
|
|
if (!value) {
|
|
throw new Error(`Ungueltiges Datumsformat fuer ${fieldName}`);
|
|
}
|
|
return value;
|
|
}
|
|
formatGermanShortDate(date) {
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const year = String(date.getFullYear()).slice(-2);
|
|
return `${day}.${month}.${year}`;
|
|
}
|
|
lastPathSegment(value) {
|
|
return value.split('?')[0]?.split('/').filter(Boolean).at(-1) ?? '';
|
|
}
|
|
fileBasename(fileName) {
|
|
const lastDotIndex = fileName.lastIndexOf('.');
|
|
return lastDotIndex === -1 ? fileName : fileName.slice(0, lastDotIndex);
|
|
}
|
|
normalizeApiBaseUrl(value) {
|
|
const withoutTrailingSlash = value.replace(/\/+$/, '');
|
|
return withoutTrailingSlash.endsWith('/v1') ? withoutTrailingSlash : `${withoutTrailingSlash}/v1`;
|
|
}
|
|
isWrappedApiCustomer(value) {
|
|
return (typeof value === 'object' &&
|
|
value !== null &&
|
|
'data' in value &&
|
|
this.isApiCustomer(value.data));
|
|
}
|
|
isApiCustomer(value) {
|
|
return (typeof value === 'object' &&
|
|
value !== null &&
|
|
'id' in value &&
|
|
(typeof value.id === 'string' || typeof value.id === 'number'));
|
|
}
|
|
parseGermanShortDateFromText(value) {
|
|
const match = value.match(/(\d{2}\.\d{2}\.\d{2})/);
|
|
return match ? this.parseGermanShortDate(match[1]) : null;
|
|
}
|
|
logDebug(message, meta) {
|
|
if (this.logger?.debug) {
|
|
this.logger.debug(message, meta);
|
|
return;
|
|
}
|
|
this.logger?.info?.(message, meta);
|
|
}
|
|
getResponseUrl(response) {
|
|
const request = response.request;
|
|
return request?.res?.responseUrl ?? '';
|
|
}
|
|
getHeader(response, header) {
|
|
const value = response.headers[header.toLowerCase()];
|
|
if (Array.isArray(value)) {
|
|
return value[0] ?? null;
|
|
}
|
|
return typeof value === 'string' ? value : null;
|
|
}
|
|
isFreischaltungUrl(value) {
|
|
return Boolean(value?.includes('freischaltung'));
|
|
}
|
|
isLoginPageText(responseText) {
|
|
return responseText.includes('Anmeldung - AGRARMONITOR');
|
|
}
|
|
extractNonce(html, selector) {
|
|
const dom = new jsdom_1.JSDOM(html);
|
|
const element = dom.window.document.querySelector(selector);
|
|
const nonce = element?.getAttribute('value') ?? element?.value ?? '';
|
|
if (!nonce) {
|
|
throw new Error('Nonce-Element nicht gefunden oder leer');
|
|
}
|
|
return nonce;
|
|
}
|
|
maskNonce(nonce) {
|
|
return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`;
|
|
}
|
|
get debugLogging() {
|
|
return typeof this.logger?.debug === 'function';
|
|
}
|
|
/**
|
|
* Detail-Logging des Login-Flows. Anders als logDebug faellt das bewusst
|
|
* NICHT auf info zurueck - ohne debug-Logger bleiben die Details still.
|
|
*/
|
|
logLoginDetail(message, meta) {
|
|
this.logger?.debug?.(message, meta);
|
|
}
|
|
maskValue(value) {
|
|
if (!value) {
|
|
return '(leer)';
|
|
}
|
|
return value.length <= 8 ? `\u2026(len=${value.length})` : `${value.slice(0, 4)}\u2026(len=${value.length})`;
|
|
}
|
|
/**
|
|
* Liest die Cookies des Jars fuer eine URL aus. Liefert eine leere Liste,
|
|
* solange kein debug-Logger haengt - dann wird gar nicht erst serialisiert.
|
|
*/
|
|
snapshotCookies(url = this.baseUrl) {
|
|
if (!this.debugLogging) {
|
|
return [];
|
|
}
|
|
try {
|
|
return this.cookieJar.getCookiesSync(url).map(cookie => ({
|
|
name: cookie.key,
|
|
value: cookie.value,
|
|
domain: cookie.domain ?? null,
|
|
path: cookie.path ?? null,
|
|
expires: cookie.expires instanceof Date ? cookie.expires.toISOString() : 'Session',
|
|
httpOnly: Boolean(cookie.httpOnly),
|
|
secure: Boolean(cookie.secure),
|
|
}));
|
|
}
|
|
catch {
|
|
return [];
|
|
}
|
|
}
|
|
/**
|
|
* Maskiert die Werte im rohen Set-Cookie-Header - sonst stuende die neue
|
|
* Session-ID im Klartext im Log, obwohl der Jar-Snapshot sie maskiert.
|
|
*/
|
|
formatSetCookieHeader(header) {
|
|
if (!Array.isArray(header)) {
|
|
return null;
|
|
}
|
|
return header.map(entry => {
|
|
const [pair, ...attributes] = String(entry).split(';');
|
|
const separator = pair.indexOf('=');
|
|
if (separator === -1) {
|
|
return [pair, ...attributes].join(';');
|
|
}
|
|
const name = pair.slice(0, separator);
|
|
const value = pair.slice(separator + 1);
|
|
return [`${name}=${this.maskValue(value)}`, ...attributes].join(';');
|
|
});
|
|
}
|
|
/** Wie snapshotCookies, aber mit maskierten Werten - nur das geht ins Log. */
|
|
formatCookies(cookies) {
|
|
return cookies.map(cookie => ({ ...cookie, value: this.maskValue(cookie.value) }));
|
|
}
|
|
diffCookies(before, after) {
|
|
const beforeByName = new Map(before.map(cookie => [cookie.name, cookie.value]));
|
|
const afterByName = new Map(after.map(cookie => [cookie.name, cookie.value]));
|
|
return {
|
|
added: [...afterByName.keys()].filter(name => !beforeByName.has(name)),
|
|
changed: [...afterByName.entries()]
|
|
.filter(([name, value]) => beforeByName.has(name) && beforeByName.get(name) !== value)
|
|
.map(([name]) => name),
|
|
removed: [...beforeByName.keys()].filter(name => !afterByName.has(name)),
|
|
};
|
|
}
|
|
/**
|
|
* Klammert einen Request mit je einem Cookie-Snapshot davor und danach.
|
|
* Der Snapshot davor wird vor dem Absenden geloggt, damit er auch dann
|
|
* im Log steht, wenn der Request haengt oder wirft.
|
|
*/
|
|
async loggedRequest(label, method, url, exec, details = {}, phase = 'request') {
|
|
const before = this.snapshotCookies();
|
|
this.logLoginDetail(`${label}: ${method} ${url}`, {
|
|
phase,
|
|
method,
|
|
url,
|
|
cookies: this.formatCookies(before),
|
|
...details,
|
|
});
|
|
try {
|
|
const response = await exec();
|
|
const after = this.snapshotCookies();
|
|
const responseText = typeof response.data === 'string' ? response.data : '';
|
|
this.logLoginDetail(`${label}: ${method} ${url} -> ${response.status}`, {
|
|
phase: 'response',
|
|
method,
|
|
url,
|
|
status: response.status,
|
|
responseUrl: this.getResponseUrl(response) || null,
|
|
contentType: this.getHeader(response, 'content-type'),
|
|
setCookie: this.formatSetCookieHeader(response.headers['set-cookie']),
|
|
istLoginSeite: this.isLoginPageText(responseText),
|
|
cookies: this.formatCookies(after),
|
|
cookieChanges: this.diffCookies(before, after),
|
|
});
|
|
return response;
|
|
}
|
|
catch (error) {
|
|
const after = this.snapshotCookies();
|
|
this.logLoginDetail(`${label}: ${method} ${url} -> Fehler`, {
|
|
phase: 'error',
|
|
method,
|
|
url,
|
|
fehler: error instanceof Error ? error.message : String(error),
|
|
cookies: this.formatCookies(after),
|
|
cookieChanges: this.diffCookies(before, after),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
exports.AgrarmonitorConnector = AgrarmonitorConnector;
|
|
//# sourceMappingURL=AgrarmonitorConnector.js.map
|