Files
jtlPosSync/src/queries/create-order.js
2026-07-27 01:14:36 +02:00

780 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { createDemoOrder } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
import { deliverOrder } from './delivery/index.js';
const config = {
kBenutzer: Number(process.env.JTL_KBENUTZER) || 1,
kFirmaHistory: Number(process.env.JTL_KFIRMAHISTORY) || 0,
kSprache: Number(process.env.JTL_KSPRACHE) || 1,
kPlattform: Number(process.env.JTL_KPLATTFORM) || 7,
kVersandArt: Number(process.env.JTL_KVERSANDART) || 0,
kKundengruppe: Number(process.env.JTL_KKUNDENGRUPPE) || 0,
// kLaufendeNummer 3 = Auftrag, 6 = Kunde in dbo.tLaufendeNummern
orderNumberSequence: Number(process.env.JTL_ORDER_NUMBER_SEQUENCE) || 3,
customerNumberSequence: Number(process.env.JTL_CUSTOMER_NUMBER_SEQUENCE) || 6,
};
/** DB-dependent foreign keys, resolved once on first order. */
let resolvedDefaults = null;
async function getDefaults() {
if (resolvedDefaults) {
return resolvedDefaults;
}
const result = await getPool().request().input('kPlattform', sql.Int, config.kPlattform).query(`
SELECT
(SELECT MAX(kFirmaHistory) FROM dbo.tFirmaHistory) AS kFirmaHistory,
(SELECT MIN(kVersandArt) FROM dbo.tVersandArt) AS kVersandArt,
(SELECT TOP 1 kKundenGruppe FROM dbo.tKundenGruppe ORDER BY nStandard DESC, kKundenGruppe) AS kKundengruppe,
(SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.tPlattform WHERE nPlattform = @kPlattform) THEN @kPlattform ELSE 1 END) AS kPlattform;
`);
const row = result.recordset[0] || {};
resolvedDefaults = {
kFirmaHistory: config.kFirmaHistory || row.kFirmaHistory || 1,
kVersandArt: config.kVersandArt || row.kVersandArt || 1,
kKundengruppe: config.kKundengruppe || row.kKundengruppe || 1,
kPlattform: row.kPlattform || 1,
};
return resolvedDefaults;
}
const COUNTRY_NAMES = {
DE: 'Deutschland',
AT: 'Österreich',
CH: 'Schweiz',
};
function toNumber(value, fallback = 0) {
const n = parseFloat(String(value ?? '').replace(',', '.'));
return Number.isFinite(n) ? n : fallback;
}
// order.creationDate comes in as "YYYY-MM-DD HH:mm:ss" (no timezone); treat it as
// local server time, same as `new Date('YYYY-MM-DDTHH:mm:ss')` would. Falls back to
// the server's current time if missing/unparseable.
function parseOrderDate(creationDate) {
if (creationDate) {
const parsed = new Date(String(creationDate).replace(' ', 'T'));
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return new Date();
}
// JTL maps VAT rates to Steuerklassen: 19% => 1 (voller Satz), 7% => 2 (ermäßigt)
function steuerklasseForVat(vat) {
if (vat >= 15) return 1;
if (vat > 0) return 2;
return 1;
}
function isoWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
/**
* Resolves the Admin > Nummernkreise placeholders used in tLaufendeNummern's
* cPrefix/cSuffix columns: J = Jahr, M = Monat, T = Tag, K = Kalenderwoche.
*/
function formatNumberPlaceholders(template, date) {
if (!template) return '';
const pad2 = (n) => String(n).padStart(2, '0');
return template
.replace(/<J>/g, String(date.getFullYear()))
.replace(/<M>/g, pad2(date.getMonth() + 1))
.replace(/<T>/g, pad2(date.getDate()))
.replace(/<K>/g, pad2(isoWeek(date)));
}
/**
* Atomically increments nNummer for a dbo.tLaufendeNummern row and returns
* the fully formatted number (cPrefix + nNummer + cSuffix, with placeholders
* resolved against `date`).
*/
async function nextNumberFromSequence(transaction, kLaufendeNummer, date) {
const result = await new sql.Request(transaction).input('kLaufendeNummer', sql.Int, kLaufendeNummer).query(`
DECLARE @n INT, @cPrefix NVARCHAR(50), @cSuffix NVARCHAR(50);
UPDATE dbo.tLaufendeNummern
SET @n = nNummer = nNummer + 1, @cPrefix = cPrefix, @cSuffix = cSuffix
WHERE kLaufendeNummer = @kLaufendeNummer;
SELECT @n AS nNummer, @cPrefix AS cPrefix, @cSuffix AS cSuffix;
`);
const row = result.recordset[0];
if (!row || row.nNummer == null) {
throw new Error(`dbo.tLaufendeNummern has no row ${kLaufendeNummer}`);
}
const prefix = formatNumberPlaceholders(row.cPrefix, date);
const suffix = formatNumberPlaceholders(row.cSuffix, date);
return `${prefix}${row.nNummer}${suffix}`;
}
/**
* Allocates the next primary key for a table via dbo.tpk
* (JTL tables like tZahlung/tZahlungsart have no identity column).
*/
async function allocatePk(transaction, tableName) {
const result = await new sql.Request(transaction).input('cName', sql.NVarChar, tableName).query(`
DECLARE @pk INT;
UPDATE dbo.tpk SET @pk = nummer, nummer = nummer + 1, dChanged = GETDATE() WHERE cName = @cName;
SELECT @pk AS pk;
`);
const pk = result.recordset[0]?.pk;
if (pk == null) {
throw new Error(`dbo.tpk has no row for table '${tableName}'`);
}
return pk;
}
const VERSANDPOSITION_TYPE = 2;
const ZAHLUNG_TYPE_ZAHLUNG = 10;
const NIST_READONLY_NICHT_AENDERBAR = 2;
const NIST_EXTERNE_RECHNUNG_KEINE = 2;
function parseImportSetting(order) {
return toNumber(order.settings?.importSetting, 0);
}
function parseInvoiceSetting(order) {
return toNumber(order.settings?.invoiceSetting, 0);
}
/** PosOrderSettingsService: importSetting=0 → NichtAenderbar. */
function resolveNIstReadOnly(order) {
return parseImportSetting(order) === 0 ? NIST_READONLY_NICHT_AENDERBAR : 0;
}
/**
* PosOrderSettingsService: importSetting=0 → KeineRechnungKeineAusgabe;
* importSetting 25 or invoiceSetting Create flag → Wawi invoice.
*/
function resolveNIstExterneRechnung(order) {
const importSetting = parseImportSetting(order);
const invoiceSetting = parseInvoiceSetting(order);
if (importSetting >= 2 && importSetting <= 5) return 0;
if (invoiceSetting & 1) return 0;
if (importSetting === 0) return NIST_EXTERNE_RECHNUNG_KEINE;
return 0;
}
function isVersandposition(item) {
return toNumber(item.type, 0) === VERSANDPOSITION_TYPE;
}
function hasVersandposition(items) {
return items.some(isVersandposition);
}
function hasNonReturnSaleItems(items) {
return items.some((item) => toNumber(item.isReturn, 0) === 0);
}
function shouldInjectSelbstabholerShipping(items) {
if (hasVersandposition(items)) return false;
return hasNonReturnSaleItems(items);
}
async function lookupVersandArt(transaction, shippingName) {
const name = String(shippingName ?? '').trim() || 'Selbstabholer';
const tryLookup = async (cName) => {
const result = await new sql.Request(transaction)
.input('cName', sql.NVarChar, cName)
.query(`
SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt
FROM dbo.tVersandArt
WHERE cName = @cName
`);
return result.recordset[0] ?? null;
};
let row = await tryLookup(name);
if (!row && name !== 'Selbstabholer') {
row = await tryLookup('Selbstabholer');
}
return row;
}
function syntheticShippingItem(versandArt) {
const vat = toNumber(versandArt.fMwSt, 19);
const gross = toNumber(versandArt.fPrice, 0);
const net = gross / (1 + vat / 100);
return {
type: String(VERSANDPOSITION_TYPE),
quantity: '1',
name: versandArt.cName,
priceGross: String(gross),
priceNet: String(net),
vat: String(vat),
isReturn: '0',
};
}
/**
* Resolves a Zahlungsart by name; creates it on the fly (including its PK
* from dbo.tpk) when it does not exist yet.
*/
async function resolveZahlungsart(transaction, name, cache) {
const lookupName = String(name || 'Bar');
const cacheKey = lookupName.toLowerCase();
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
let row = null;
const exact = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName');
row = exact.recordset[0] ?? null;
if (!row) {
const lang = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query(`
SELECT TOP 1 z.kZahlungsart, z.cName
FROM dbo.tZahlungsArtSprache zs
INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart
WHERE zs.cName = @cName
`);
row = lang.recordset[0] ?? null;
}
if (!row) {
const ci = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(@cName)');
row = ci.recordset[0] ?? null;
}
let zahlungsart;
if (row) {
zahlungsart = { kZahlungsart: row.kZahlungsart, cName: row.cName };
} else {
const kZahlungsart = await allocatePk(transaction, 'tZahlungsart');
await new sql.Request(transaction)
.input('kZahlungsart', sql.Int, kZahlungsart)
.input('cName', sql.NVarChar, lookupName)
.query(`
INSERT INTO dbo.tZahlungsart
(kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,
nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum,
nMatchingOptionen, nIstStandard, nAktiv)
VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)
`);
zahlungsart = { kZahlungsart, cName: lookupName };
}
cache.set(cacheKey, zahlungsart);
return zahlungsart;
}
async function nextCustomerNumber(transaction) {
return nextNumberFromSequence(transaction, config.customerNumberSequence, new Date());
}
/**
* Creates a Kunde via Kunde.spKundeInsert (which fills tKunde, tAdresse and
* the search tables). Columns of the table type are named explicitly because
* their order differs between JTL versions.
*/
async function createCustomer(transaction, { customerNumber, address, defaults }) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
const kKundengruppe = Number(a.customerGroupId) || defaults.kKundengruppe;
const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber)
.input('cFirma', sql.NVarChar, a.company || '')
.input('cAnrede', sql.NVarChar, a.salutation || '')
.input('cTitel', sql.NVarChar, a.title || '')
.input('cVorname', sql.NVarChar, a.firstName || '')
.input('cName', sql.NVarChar, a.lastName || 'Laufkunde')
.input('cStrasse', sql.NVarChar, a.street || '-')
.input('cPLZ', sql.NVarChar, a.zipCode || '')
.input('cOrt', sql.NVarChar, a.city || '-')
.input('cLand', sql.NVarChar, COUNTRY_NAMES[iso] || iso)
.input('cTel', sql.NVarChar, a.phone || '')
.input('cFax', sql.NVarChar, a.fax || '')
.input('cEMail', sql.NVarChar, a.email || '')
.input('cMobil', sql.NVarChar, a.mobile || '')
.input('fRabatt', sql.Decimal(18, 13), toNumber(a.discount, 0))
.input('cAdressZusatz', sql.NVarChar, a.addressAddition || '')
.input('cGeburtstag', sql.NVarChar, a.birthday || '')
.input('kKundenGruppe', sql.Int, kKundengruppe)
.input('kSprache', sql.Int, config.kSprache)
.input('cISO', sql.NVarChar, iso)
.input('cBundesland', sql.NVarChar, a.state || '')
.input('cHerkunft', sql.NVarChar, 'Kasse')
.input('cKassenKunde', sql.Char(1), 'Y')
.input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0)
.query(`
DECLARE @returnValue INT;
DECLARE @kunde_daten dbo.TYPE_spkundeInsert;
INSERT INTO @kunde_daten
(kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName,
cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter,
cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe,
nZahlungsziel, kSprache, cISO, cBundesland, cHerkunft, cKassenKunde, cHRNr, kZahlungsart,
nDebitorennr, cSteuerNr, nKreditlimit, kKundenDrucktext, nMahnstopp, nMahnrhythmus, kFirma,
fProvision, nVertreter, fSkonto, nSkontoInTagen)
VALUES
(0, 0, @cKundenNr, @cFirma, @cAnrede, @cTitel, @cVorname, @cName,
@cStrasse, @cPLZ, @cOrt, @cLand, @cTel, @cFax, @cEMail, GETDATE(), @cMobil, @fRabatt, NULL, 'N',
N'', N'', 0, @cAdressZusatz, @cGeburtstag, N'', 'N', NULL, @kKundenGruppe,
0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0,
@nDebitorennr, N'', 0, 0, 0, 0, 0,
NULL, 0, 0, 0);
EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;
SELECT @returnValue AS kKunde;
`);
const kKunde = result.recordset[0]?.kKunde;
if (!kKunde || kKunde <= 0) {
throw new Error(`Kunde.spKundeInsert failed for '${customerNumber}'`);
}
return { kKunde, kKundengruppe };
}
function isWalkInOrder(order) {
const customerNumber = String(order.customerNumber || '').trim();
if (!customerNumber || customerNumber === '0') return true;
const billing = order.billingAddress || {};
return (billing.lastName || '') === 'Laufkunde' && !(billing.firstName || '').trim();
}
function resolveAuftragCKundenNr(order) {
return isWalkInOrder(order) ? '0' : String(order.customerNumber || '');
}
async function lookupKassenkunde(transaction, defaults) {
const result = await new sql.Request(transaction).query(
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde"
);
if (result.recordset[0]) {
return {
kKunde: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return null;
}
/**
* Resolves the customer by cKundenNr; creates the Kunde on the fly when it
* does not exist. Walk-in orders use (or create) the Kassenkunde.
*/
async function resolveCustomer(transaction, order, defaults) {
if (isWalkInOrder(order)) {
const kassenKunde = await lookupKassenkunde(transaction, defaults);
if (kassenKunde) return kassenKunde;
return createCustomer(transaction, {
customerNumber: await nextCustomerNumber(transaction),
address: order.billingAddress,
defaults,
});
}
const customerNumber = String(order.customerNumber || '').trim();
const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber)
.query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr');
if (result.recordset[0]) {
return {
kKunde: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return createCustomer(transaction, {
customerNumber,
address: order.billingAddress,
defaults,
});
}
async function nextOrderNumber(transaction, orderDate) {
return nextNumberFromSequence(transaction, config.orderNumberSequence, orderDate);
}
async function insertOrderAddress(transaction, kAuftrag, kKunde, address, nTyp) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.input('kKunde', sql.Int, kKunde)
.input('cFirma', sql.NVarChar, a.company || '')
.input('cAnrede', sql.NVarChar, a.salutation || '')
.input('cTitel', sql.NVarChar, a.title || '')
.input('cVorname', sql.NVarChar, a.firstName || '')
.input('cName', sql.NVarChar, a.lastName || '-')
.input('cStrasse', sql.NVarChar, a.street || '-')
.input('cPLZ', sql.NVarChar, a.zipCode || '')
.input('cOrt', sql.NVarChar, a.city || '-')
.input('cLand', sql.NVarChar, COUNTRY_NAMES[iso] || iso)
.input('cTel', sql.NVarChar, a.phone || '')
.input('cZusatz', sql.NVarChar, a.extraAddressLine || '')
.input('cAdressZusatz', sql.NVarChar, a.addressAddition || '')
.input('cMobil', sql.NVarChar, a.mobile || '')
.input('cMail', sql.NVarChar, a.email || '')
.input('cFax', sql.NVarChar, a.fax || '')
.input('cBundesland', sql.NVarChar, a.state || '')
.input('cISO', sql.NVarChar, iso)
.input('nTyp', sql.Int, nTyp)
.query(`
INSERT INTO Verkauf.tAuftragAdresse
(kAuftrag, kKunde, cFirma, cAnrede, cTitel, cVorname, cName, cStrasse, cPLZ, cOrt, cLand,
cTel, cZusatz, cAdressZusatz, cMobil, cMail, cFax, cBundesland, cISO, nTyp, nZolldokumenteErforderlich)
VALUES (@kAuftrag, @kKunde, @cFirma, @cAnrede, @cTitel, @cVorname, @cName, @cStrasse, @cPLZ, @cOrt, @cLand,
@cTel, @cZusatz, @cAdressZusatz, @cMobil, @cMail, @cFax, @cBundesland, @cISO, @nTyp, 0)
`);
}
async function insertOrderItem(transaction, kAuftrag, item) {
const vat = toNumber(item.vat, 19);
const quantity = toNumber(item.quantity, 1);
const priceNet = toNumber(item.priceNet, toNumber(item.priceGross) / (1 + vat / 100));
const discount = toNumber(item.discountPercent, 0);
const kSteuerklasse = steuerklasseForVat(vat);
const sku = String(item.sku || '').trim();
const positionType = isVersandposition(item) ? VERSANDPOSITION_TYPE : toNumber(item.type, 0);
let kArtikel = null;
if (sku && positionType !== VERSANDPOSITION_TYPE) {
const result = await new sql.Request(transaction)
.input('cArtNr', sql.NVarChar, sku)
.query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr');
kArtikel = result.recordset[0]?.kArtikel ?? null;
}
const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0);
const nReserviert = nType === VERSANDPOSITION_TYPE ? 0 : 1;
const result = await new sql.Request(transaction)
.input('kArtikel', sql.Int, kArtikel)
.input('kAuftrag', sql.Int, kAuftrag)
.input('cArtNr', sql.NVarChar, kArtikel ? sku : null)
.input('cName', sql.NVarChar, item.name || sku || 'Position')
.input('cHinweis', sql.NVarChar, item.note || '')
.input('fAnzahl', sql.Float, quantity)
.input('fVkNetto', sql.Float, priceNet)
.input('fMwSt', sql.Float, vat)
.input('kSteuerklasse', sql.Int, kSteuerklasse)
.input('nType', sql.Int, nType)
.input('nReserviert', sql.Int, nReserviert)
.input('cEinheit', sql.NVarChar, item.unit || '')
.input('fRabatt', sql.Float, discount)
.query(`
DECLARE @t TABLE ([kAuftragPosition] INT);
INSERT INTO Verkauf.tAuftragPosition
(kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt,
cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)
OUTPUT inserted.kAuftragPosition INTO @t
VALUES (@kArtikel, @kAuftrag, @cArtNr, @nReserviert, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
@cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt);
SELECT kAuftragPosition FROM @t;
`);
return result.recordset[0]?.kAuftragPosition ?? null;
}
/**
* JTL PosOrderCreationService.CheckIfOrderExists — skip re-import when the POS
* order id is already mapped to a Wawi order for this subshop with the same
* external receipt number.
*/
function externalOrderNumbersMatch(mapped, incoming) {
if (!incoming) return true;
return String(mapped || '').toLowerCase() === String(incoming).toLowerCase();
}
async function findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return null;
}
const result = await getPool()
.request()
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
SELECT TOP 1 m.kAuftrag, a.cAuftragsNr, ISNULL(a.cExterneAuftragsnummer, '') AS cExterneAuftragsnummer
FROM Pos.tAuftragMapping m
LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag
WHERE m.kPosAuftrag = @kPosAuftrag
AND m.kShopSubShop = @kShopSubShop
AND m.kAuftrag IS NOT NULL
ORDER BY m.kAuftrag DESC
`);
const row = result.recordset[0];
if (!row?.kAuftrag) {
return null;
}
if (!externalOrderNumbersMatch(row.cExterneAuftragsnummer, externalOrderNumber)) {
return null;
}
return row;
}
/**
* Links a JTL-Wawi order back to the originating POS order id.
* MERGE matches JTL CreateOrUpdate outcome for the final committed row.
*/
async function upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return;
}
await new sql.Request(transaction)
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
DELETE FROM Pos.tAuftragMapping
WHERE kPosAuftrag = @kPosAuftrag
AND kShopSubShop = @kShopSubShop
`);
await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop)
VALUES (@kAuftrag, @kPosAuftrag, @kShopSubShop)
`);
}
async function insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kAuftragPosition) || !Number.isInteger(kPosAuftragPosition) || !kShopSubShop) {
return;
}
await new sql.Request(transaction)
.input('kAuftragPosition', sql.Int, kAuftragPosition)
.input('kPosAuftragPosition', sql.Int, kPosAuftragPosition)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
INSERT INTO Pos.tAuftragPositionMapping (kAuftragPosition, kPosAuftragPosition, kShopSubShop)
VALUES (@kAuftragPosition, @kPosAuftragPosition, @kShopSubShop)
`);
}
/**
* Mirrors JTL's own PosOrderCreationService -> DeliveryStep.Run(): after a POS
* order is created, `Settings.Deliver` (default true, see decompiled
* jtlCore.Classes.Sync.PosOrderSteps.DeliveryStep:199,
* `bool flag = orderEntity.Settings?.Deliver ?? true;`) decides whether the
* order is delivered immediately. Digital-voucher partial delivery
* (IstGutscheinDigital) is intentionally not reproduced.
*
* The actual delivery mechanics (Pickliste creation, real stock decrement,
* numbered Lieferschein) live in ./delivery/ - see delivery.md for the full
* investigation behind it.
*/
function isOrderDelivered(order) {
const deliver = order.settings?.deliver;
if (deliver === undefined || deliver === null) {
return true;
}
return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true';
}
async function recalculateAuftragEckdaten(transaction, kAuftrag) {
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
`);
}
async function getOffenerAuftragswert(transaction, kAuftrag) {
const result = await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.query(`
SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert
FROM Verkauf.tAuftrag
LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag
WHERE tAuftrag.kAuftrag = @kAuftrag
`);
const value = result.recordset[0]?.fOffenerAuftragswert;
return value == null ? null : toNumber(value, 0);
}
function isNewPayment(payment) {
const paymentId = toNumber(payment.paymentId, 0);
return paymentId <= 0;
}
async function insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache) {
const zahlungsart = await resolveZahlungsart(transaction, payment.paymentMethodName || order.paymentMethodName, zahlungsartCache);
const kZahlung = await allocatePk(transaction, 'tZahlung');
await recalculateAuftragEckdaten(transaction, kAuftrag);
const fOffenerWert = await getOffenerAuftragswert(transaction, kAuftrag);
if (fOffenerWert == null) {
throw new Error(`no open order amount for kAuftrag=${kAuftrag}`);
}
const result = await new sql.Request(transaction)
.input('kZahlung', sql.Int, kZahlung)
.input('cName', sql.NVarChar, zahlungsart.cName)
.input('dDatum', sql.DateTime, orderDate)
.input('fBetrag', sql.Float, toNumber(payment.amount, 0))
.input('kBestellung', sql.Int, kAuftrag)
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '')
.input('fOffenerWert', sql.Float, fOffenerWert)
.query(`
IF EXISTS (
SELECT 1 FROM Verkauf.tAuftragEckdaten
WHERE kAuftrag = @kBestellung
AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(@fOffenerWert, 2)
)
BEGIN
INSERT INTO dbo.tZahlung
(kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,
nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)
VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart,
0, @cExternalTransactionId, 0, ${ZAHLUNG_TYPE_ZAHLUNG}, '', 0);
END
SELECT @@ROWCOUNT AS inserted;
`);
if (!result.recordset[0]?.inserted) {
throw new Error(`payment insert skipped: open amount changed for kAuftrag=${kAuftrag}`);
}
}
export async function createOrder(order) {
if (isDemoMode()) {
return createDemoOrder(order);
}
const kPosAuftrag = Number.parseInt(order.externalId, 10);
const externalOrderNumber = order.externalOrderNumber || '';
if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) {
const existing = await findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber);
if (existing) {
return {
orderId: String(existing.kAuftrag),
orderNumber: existing.cAuftragsNr || '',
alreadyExists: true,
};
}
}
const defaults = await getDefaults();
const transaction = new sql.Transaction(getPool());
await transaction.begin(sql.ISOLATION_LEVEL.READ_COMMITTED);
try {
const zahlungsartCache = new Map();
// Use the client-supplied order.creationDate for stored timestamps (dErstellt,
// number-sequence date placeholders, payment date), falling back to the
// server's current time if it's missing/unparseable.
const orderDate = parseOrderDate(order.creationDate);
const { kKunde, kKundengruppe } = await resolveCustomer(transaction, order, defaults);
const zahlungsart = await resolveZahlungsart(transaction, order.paymentMethodName, zahlungsartCache);
const cAuftragsNr = await nextOrderNumber(transaction, orderDate);
const orderItems = [...(order.orderItems || [])];
const versandArt = await lookupVersandArt(transaction, order.shippingName);
let kVersandArt = versandArt?.kVersandArt ?? defaults.kVersandArt;
if (shouldInjectSelbstabholerShipping(orderItems) && versandArt) {
orderItems.push(syntheticShippingItem(versandArt));
}
const nIstReadOnly = resolveNIstReadOnly(order);
const nIstExterneRechnung = resolveNIstExterneRechnung(order);
const resultAuftrag = await new sql.Request(transaction)
.input('cAuftragsNr', sql.NVarChar, cAuftragsNr)
.input('dErstellt', sql.DateTime, orderDate)
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kKunde', sql.Int, kKunde)
.input('kFirmaHistory', sql.Int, defaults.kFirmaHistory)
.input('kSprache', sql.Int, config.kSprache)
.input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR')
.input('kPlattform', sql.Int, defaults.kPlattform)
.input('kShop', sql.Int, getActiveShopId() || null)
.input('cKundenNr', sql.NVarChar, resolveAuftragCKundenNr(order))
.input('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase())
.input('kVersandArt', sql.Int, kVersandArt)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('kKundengruppe', sql.Int, kKundengruppe)
.input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '')
.input('nIstExterneRechnung', sql.Int, nIstExterneRechnung)
.input('nIstReadOnly', sql.Int, nIstReadOnly)
.query(`
DECLARE @t TABLE ([kAuftrag] INT);
INSERT INTO Verkauf.tAuftrag
(cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor,
kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten,
cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe,
cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)
OUTPUT inserted.kAuftrag INTO @t
VALUES (@cAuftragsNr, @dErstellt, 0, @kBenutzer, @kKunde, @kBenutzer, 1, 1.0,
@kFirmaHistory, @kSprache, @cWaehrung, 1.0, 0.0,
@cWaehrung, @kPlattform, @kShop, @cKundenNr, @cVersandlandISO, @kVersandArt, @kZahlungsart, @kKundengruppe,
@cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', @nIstReadOnly, 0, 10);
SELECT kAuftrag FROM @t;
`);
const kAuftrag = resultAuftrag.recordset[0].kAuftrag;
const kPosAuftrag = Number.parseInt(order.externalId, 10);
await upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag);
await insertOrderAddress(transaction, kAuftrag, kKunde, order.shippingAddress, 0);
await insertOrderAddress(transaction, kAuftrag, kKunde, order.billingAddress, 1);
const deliveredItems = [];
for (const item of orderItems) {
const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item);
const kPosAuftragPosition = Number.parseInt(item.externalId, 10);
if (Number.isInteger(kPosAuftragPosition)) {
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
}
if (kAuftragPosition != null && !isVersandposition(item)) {
deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) });
}
}
for (const payment of order.payments || []) {
if (!isNewPayment(payment)) continue;
await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache);
}
if (isOrderDelivered(order)) {
await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems);
}
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
`);
await transaction.commit();
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr, alreadyExists: false };
} catch (err) {
try {
await transaction.rollback();
} catch {
// transaction may already be aborted by the server
}
throw err;
}
}