This commit is contained in:
seb
2026-10-27 08:39:09 +01:00
parent b3368a53f8
commit 88ed2be452
2 changed files with 303 additions and 68 deletions

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
JTL-POS order upload
Imports orders from JTL-POS, creating order manifests, payments, and shipments with stock deductions.
Imports orders from JTL-POS, creating order and retoure manifests, payments, and shipments with stock adjustments.
## Query parameters

View File

@@ -59,7 +59,7 @@ async function resolveGoogleAuthId(provider, store, companyId) {
* /v1/order:
* post:
* summary: JTL-POS order upload
* description: Imports orders from JTL-POS, creating order manifests, payments, and shipments with stock deductions.
* description: Imports orders from JTL-POS, creating order and retoure manifests, payments, and shipments with stock adjustments.
* tags:
* - POS Sync
* security:
@@ -124,12 +124,12 @@ export async function handleOrder(req, res, next) {
// 1. Deduplication check
if (extCheck) {
const existing = await provider.query(
`SELECT id, number, external_number FROM manifest WHERE company_id = @companyId AND (external_number = @extCheck OR number = @extCheck) AND type = 'order'`,
`SELECT id, number, external_number, type FROM manifest WHERE company_id = @companyId AND (external_number = @extCheck OR number = @extCheck) AND type IN ('order', 'retoure')`,
{ companyId, extCheck }
);
if (existing.rows.length > 0) {
const row = existing.rows[0];
const msg = `order already mapped, skipped save (kAuftrag=${row.id}, ${row.number})`;
const msg = `${row.type || 'document'} already mapped, skipped save (id=${row.id}, ${row.number})`;
log.info(`order externalId=${externalOrderId} skipped: ${msg}`);
results.push({
status: 'OK',
@@ -183,16 +183,30 @@ export async function handleOrder(req, res, next) {
}
}
// 3. Lines
// 3. Lines - separate sales (positive) and returns (negative)
const rawItems = Array.isArray(order.orderItems) ? order.orderItems : [];
const lines = rawItems.map((it) => {
const positiveLines = [];
const retoureLines = [];
for (const it of rawItems) {
const sku = it.sku ? String(it.sku).trim() : null;
const name = it.name ? String(it.name).trim() : (sku ? `Artikel ${sku}` : 'Artikel');
let amount = Number(it.quantity) || 1;
if (String(it.isReturn) === '1' && amount > 0) {
amount = -amount;
}
const net = Number(it.priceNet) || 0;
const rawQuantity = Number(it.quantity) || 1;
const rawPriceNet = Number(it.priceNet) || 0;
const rawPriceGross = Number(it.priceGross) || 0;
const rawTotalPriceNet = Number(it.totalPriceNet) || 0;
const rawTotalPriceGross = Number(it.totalPriceGross) || 0;
const isReturn = String(it.isReturn) === '1' ||
it.isReturn === true ||
String(it.isReturn).toLowerCase() === 'true' ||
String(order.type) === '4' ||
rawQuantity < 0 ||
rawPriceNet < 0 ||
rawPriceGross < 0 ||
rawTotalPriceNet < 0 ||
rawTotalPriceGross < 0;
const taxrate = Number(it.vat) || 19;
const rebate = Number(it.discountPercent) || 0;
const comment = it.note ? String(it.note).trim() : null;
@@ -200,58 +214,120 @@ export async function handleOrder(req, res, next) {
if (/^(summe glätten|shippingcost|versandkosten)$/i.test(name)) {
shippable = false;
}
return {
sku,
name,
amount,
net,
taxrate,
rebate,
comment,
shippable,
};
});
if (isReturn) {
const amount = Math.abs(rawQuantity);
const netMag = Math.abs(rawPriceNet) || (Math.abs(rawPriceGross) ? Math.abs(rawPriceGross) / (1 + taxrate / 100) : 0);
const net = -netMag;
retoureLines.push({
sku,
name,
amount,
net,
taxrate,
rebate,
comment,
shippable,
});
} else {
const amount = Math.abs(rawQuantity);
const net = Math.abs(rawPriceNet) || (Math.abs(rawPriceGross) ? Math.abs(rawPriceGross) / (1 + taxrate / 100) : 0);
positiveLines.push({
sku,
name,
amount,
net,
taxrate,
rebate,
comment,
shippable,
});
}
}
// 4. Date and Numbers
const parsedDate = order.creationDate ? new Date(order.creationDate) : new Date();
const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString();
const orderNumber = await allocateOrderNumber(provider, companyId);
const externalNumber = externalOrderNumber || (externalOrderId ? String(externalOrderId) : null);
// 5. Create Order
// 5. Create Order Manifest for positive positions
let createdOrder = null;
try {
createdOrder = await store.order.create(googleAuthId, {
number: orderNumber,
externalNumber,
date: validDate,
createdAt: validDate,
entityId: entityId || undefined,
lines,
});
} catch (createErr) {
const parentMatch = createErr.message?.match(/A parent article cannot be booked on documents \(([^)]+)\)/);
if (parentMatch) {
const offendingSku = parentMatch[1];
for (const l of lines) {
if (l.sku === offendingSku) l.sku = null;
}
if (positiveLines.length > 0) {
const orderNumber = await allocateOrderNumber(provider, companyId);
try {
createdOrder = await store.order.create(googleAuthId, {
number: orderNumber,
externalNumber,
date: validDate,
createdAt: validDate,
entityId: entityId || undefined,
lines,
lines: positiveLines,
});
} else {
throw createErr;
} catch (createErr) {
const parentMatch = createErr.message?.match(/A parent article cannot be booked on documents \(([^)]+)\)/);
if (parentMatch) {
const offendingSku = parentMatch[1];
for (const l of positiveLines) {
if (l.sku === offendingSku) l.sku = null;
}
createdOrder = await store.order.create(googleAuthId, {
number: orderNumber,
externalNumber,
date: validDate,
createdAt: validDate,
entityId: entityId || undefined,
lines: positiveLines,
});
} else {
throw createErr;
}
}
}
// 5b. Create Retoure Manifest for negative positions
let createdRetoure = null;
if (retoureLines.length > 0) {
try {
createdRetoure = await store.retoure.create(googleAuthId, {
externalNumber,
date: validDate,
createdAt: validDate,
entityId: entityId || undefined,
lines: retoureLines,
});
} catch (createErr) {
const parentMatch = createErr.message?.match(/A parent article cannot be booked on documents \(([^)]+)\)/);
if (parentMatch) {
const offendingSku = parentMatch[1];
for (const l of retoureLines) {
if (l.sku === offendingSku) l.sku = null;
}
createdRetoure = await store.retoure.create(googleAuthId, {
externalNumber,
date: validDate,
createdAt: validDate,
entityId: entityId || undefined,
lines: retoureLines,
});
} else {
throw createErr;
}
}
}
if (!createdOrder && !createdRetoure) {
log.info(`order externalId=${externalOrderId} had no lines, skipping`);
results.push({
status: 'OK',
externalOrderId,
message: '',
});
continue;
}
// 6. Payments
const payments = Array.isArray(order.payments) ? order.payments : [];
if (payments.length > 0) {
if (payments.length > 0 && (createdOrder || createdRetoure)) {
const accounts = await store.account.list(googleAuthId).catch(() => []);
const accountMap = new Map();
for (const acc of accounts) {
@@ -282,27 +358,76 @@ export async function handleOrder(req, res, next) {
}
};
for (const p of payments) {
const amount = Number(p.amount) || 0;
if (amount <= 0) continue;
const type = p.paymentMethodName || 'CASH';
const accountId = await resolveAccountId(type);
if (accountId) {
if (createdRetoure && !createdOrder) {
for (const p of payments) {
const raw = Number(p.amount) || 0;
if (raw === 0) continue;
const amount = -Math.abs(raw);
const type = p.paymentMethodName || 'CASH';
const accountId = await resolveAccountId(type);
if (accountId) {
await store.payment.create(googleAuthId, {
accountId,
manifestId: createdRetoure.id,
type,
amount,
});
}
}
} else if (createdOrder && !createdRetoure) {
for (const p of payments) {
const amount = Number(p.amount) || 0;
if (amount <= 0) continue;
const type = p.paymentMethodName || 'CASH';
const accountId = await resolveAccountId(type);
if (accountId) {
await store.payment.create(googleAuthId, {
accountId,
manifestId: createdOrder.id,
type,
amount,
});
}
}
} else if (createdOrder && createdRetoure) {
const retoureTotalGross = retoureLines.reduce((sum, l) => {
const lineGross = (l.amount || 0) * (l.net || 0) * (1 - (l.rebate || 0) / 100) * (1 + (l.taxrate || 0) / 100);
return sum + lineGross;
}, 0);
const retourePaymentType = payments[0]?.paymentMethodName || 'CASH';
const retoureAccId = await resolveAccountId(retourePaymentType);
if (retoureAccId && retoureTotalGross !== 0) {
await store.payment.create(googleAuthId, {
accountId,
manifestId: createdOrder.id,
type,
amount,
accountId: retoureAccId,
manifestId: createdRetoure.id,
type: retourePaymentType,
amount: Math.round(retoureTotalGross * 100) / 100,
});
}
for (const p of payments) {
const amount = Number(p.amount) || 0;
if (amount <= 0) continue;
const type = p.paymentMethodName || 'CASH';
const accountId = await resolveAccountId(type);
if (accountId) {
await store.payment.create(googleAuthId, {
accountId,
manifestId: createdOrder.id,
type,
amount,
});
}
}
}
}
// 7. Deliver / Shipment
// 7. Deliver / Shipment for Order (positive lines)
const shouldDeliver = String(order.settings?.deliver ?? '').trim() === '1' || order.deliver === true || order.shipped === true;
if (shouldDeliver) {
let defaultWarehouseId = null;
const resolveWarehouse = async () => {
if (defaultWarehouseId) return defaultWarehouseId;
let warehouses = await store.warehouse.list(googleAuthId).catch(() => []);
let defaultWarehouseId = warehouses[0]?.id;
defaultWarehouseId = warehouses[0]?.id;
if (!defaultWarehouseId) {
try {
const createdWh = await store.warehouse.create(googleAuthId, { name: 'Standardlager' });
@@ -313,11 +438,16 @@ export async function handleOrder(req, res, next) {
defaultWarehouseId = warehouses[0]?.id;
}
}
if (!defaultWarehouseId) {
return defaultWarehouseId;
};
if (createdOrder && shouldDeliver) {
const whId = await resolveWarehouse();
if (!whId) {
throw new Error('Warehouse not found');
}
const shippableLines = (createdOrder.lines || lines)
const shippableLines = (createdOrder.lines || positiveLines)
.filter((l) => l.shippable !== false)
.map((l) => {
if (l.sku) return { sku: l.sku, amount: l.amount };
@@ -337,14 +467,14 @@ export async function handleOrder(req, res, next) {
const detail = await store.article.getBySku(googleAuthId, sku).catch(() => null);
const art = detail?.article;
if (!art?.track_stock) continue;
const available = art.warehouses?.find((w) => w.id === defaultWarehouseId)?.stock ?? 0;
const available = art.warehouses?.find((w) => w.id === whId)?.stock ?? 0;
const shortfall = needed - available;
if (shortfall > 0) {
await store.stock.adjust(googleAuthId, {
sku,
warehouseId: defaultWarehouseId,
warehouseId: whId,
amount: shortfall,
text: `POS ${externalOrderNumber || orderNumber}`,
text: `POS ${externalOrderNumber || createdOrder.number}`,
});
}
}
@@ -364,31 +494,136 @@ export async function handleOrder(req, res, next) {
orderId: createdOrder.id,
date: validDate,
state: 'delivered',
warehouseId: defaultWarehouseId,
warehouseId: whId,
lines: shippableLines,
shipping: {
carrierId,
type: 'selbstabholer',
state: 'shipped',
warehouseId: defaultWarehouseId,
warehouseId: whId,
},
});
}
}
// 7b. Stock return & Selbstabholer shipment for Retoure
if (createdRetoure) {
const whId = await resolveWarehouse();
if (whId) {
const shippableRetoureLines = (createdRetoure.lines || retoureLines)
.filter((l) => l.shippable !== false)
.map((l) => {
if (l.sku) return { sku: l.sku, amount: l.amount };
if (l.id) return { orderLineId: l.id, amount: l.amount };
return null;
})
.filter(Boolean);
if (shouldDeliver && shippableRetoureLines.length > 0) {
const neededBySku = new Map();
const shortfallBySku = new Map();
for (const l of shippableRetoureLines) {
if (l.sku && l.amount > 0) {
neededBySku.set(l.sku, (neededBySku.get(l.sku) || 0) + Number(l.amount));
}
}
for (const [sku, needed] of neededBySku) {
const detail = await store.article.getBySku(googleAuthId, sku).catch(() => null);
const art = detail?.article;
if (!art?.track_stock) continue;
const available = art.warehouses?.find((w) => w.id === whId)?.stock ?? 0;
const shortfall = Math.max(0, needed - available);
shortfallBySku.set(sku, shortfall);
if (shortfall > 0) {
await store.stock.adjust(googleAuthId, {
sku,
warehouseId: whId,
amount: shortfall,
text: `POS Retoure ${externalOrderNumber || createdRetoure.number || externalOrderId}`,
});
}
}
const carriers = await store.carrier.list(googleAuthId).catch(() => []);
let carrier = carriers.find((c) => c.name?.toLowerCase() === 'selbstabholer') || carriers[0];
let carrierId = carrier?.id;
if (!carrierId) {
const createdCarrier = await store.carrier.create(googleAuthId, {
name: 'Selbstabholer',
hasPackages: false,
});
carrierId = createdCarrier.id;
}
await store.shipment.create(googleAuthId, {
orderId: createdRetoure.id,
date: validDate,
state: 'delivered',
warehouseId: whId,
lines: shippableRetoureLines,
shipping: {
carrierId,
type: 'selbstabholer',
state: 'shipped',
warehouseId: whId,
},
});
// stock.ship deducted needed quantity from warehouse stock.
// Adjust stock so that the returned articles are properly credited into inventory (+needed).
for (const [sku, needed] of neededBySku) {
const detail = await store.article.getBySku(googleAuthId, sku).catch(() => null);
const art = detail?.article;
if (!art?.track_stock) continue;
const shortfall = shortfallBySku.get(sku) || 0;
const returnAdjustment = 2 * needed - shortfall;
if (returnAdjustment > 0) {
await store.stock.adjust(googleAuthId, {
sku,
warehouseId: whId,
amount: returnAdjustment,
text: `POS Retoure Eingang ${externalOrderNumber || createdRetoure.number || externalOrderId}`,
});
}
}
} else {
for (const l of retoureLines) {
if (!l.sku) continue;
const detail = await store.article.getBySku(googleAuthId, l.sku).catch(() => null);
const art = detail?.article;
if (!art?.track_stock) continue;
const returnQty = Math.abs(Number(l.amount) || 1);
if (returnQty > 0) {
await store.stock.adjust(googleAuthId, {
sku,
warehouseId: whId,
amount: returnQty,
text: `POS Retoure ${externalOrderNumber || createdRetoure.number || externalOrderId}`,
});
}
}
}
}
}
// 8. Real-time updates
emit('order', { companyId });
if (createdOrder) emit('order', { companyId });
if (createdRetoure) emit('retoure', { companyId });
emit('stock', { companyId });
emit('ledger', { companyId });
emit('articles', { companyId });
emit('shipping', { companyId });
for (const l of lines) {
if ((createdOrder || createdRetoure) && shouldDeliver) emit('shipping', { companyId });
for (const l of [...positiveLines, ...retoureLines]) {
if (l.sku) {
emit(`article-${l.sku}`, { companyId });
}
}
log.info(`order ${orderNumber} created for externalId=${externalOrderId} (${externalOrderNumber})`);
const createdDescs = [];
if (createdOrder) createdDescs.push(`order ${createdOrder.number}`);
if (createdRetoure) createdDescs.push(`retoure ${createdRetoure.number}`);
log.info(`${createdDescs.join(' and ')} created for externalId=${externalOrderId} (${externalOrderNumber})`);
results.push({
status: 'OK',
externalOrderId,