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 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 ## Query parameters

View File

@@ -59,7 +59,7 @@ async function resolveGoogleAuthId(provider, store, companyId) {
* /v1/order: * /v1/order:
* post: * post:
* summary: JTL-POS order upload * 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: * tags:
* - POS Sync * - POS Sync
* security: * security:
@@ -124,12 +124,12 @@ export async function handleOrder(req, res, next) {
// 1. Deduplication check // 1. Deduplication check
if (extCheck) { if (extCheck) {
const existing = await provider.query( 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 } { companyId, extCheck }
); );
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
const row = existing.rows[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}`); log.info(`order externalId=${externalOrderId} skipped: ${msg}`);
results.push({ results.push({
status: 'OK', 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 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 sku = it.sku ? String(it.sku).trim() : null;
const name = it.name ? String(it.name).trim() : (sku ? `Artikel ${sku}` : 'Artikel'); const name = it.name ? String(it.name).trim() : (sku ? `Artikel ${sku}` : 'Artikel');
let amount = Number(it.quantity) || 1; const rawQuantity = Number(it.quantity) || 1;
if (String(it.isReturn) === '1' && amount > 0) { const rawPriceNet = Number(it.priceNet) || 0;
amount = -amount; const rawPriceGross = Number(it.priceGross) || 0;
} const rawTotalPriceNet = Number(it.totalPriceNet) || 0;
const net = Number(it.priceNet) || 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 taxrate = Number(it.vat) || 19;
const rebate = Number(it.discountPercent) || 0; const rebate = Number(it.discountPercent) || 0;
const comment = it.note ? String(it.note).trim() : null; 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)) { if (/^(summe glätten|shippingcost|versandkosten)$/i.test(name)) {
shippable = false; shippable = false;
} }
return {
sku, if (isReturn) {
name, const amount = Math.abs(rawQuantity);
amount, const netMag = Math.abs(rawPriceNet) || (Math.abs(rawPriceGross) ? Math.abs(rawPriceGross) / (1 + taxrate / 100) : 0);
net, const net = -netMag;
taxrate, retoureLines.push({
rebate, sku,
comment, name,
shippable, 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 // 4. Date and Numbers
const parsedDate = order.creationDate ? new Date(order.creationDate) : new Date(); const parsedDate = order.creationDate ? new Date(order.creationDate) : new Date();
const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString(); const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString();
const orderNumber = await allocateOrderNumber(provider, companyId);
const externalNumber = externalOrderNumber || (externalOrderId ? String(externalOrderId) : null); const externalNumber = externalOrderNumber || (externalOrderId ? String(externalOrderId) : null);
// 5. Create Order // 5. Create Order Manifest for positive positions
let createdOrder = null; let createdOrder = null;
try { if (positiveLines.length > 0) {
createdOrder = await store.order.create(googleAuthId, { const orderNumber = await allocateOrderNumber(provider, companyId);
number: orderNumber, try {
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;
}
createdOrder = await store.order.create(googleAuthId, { createdOrder = await store.order.create(googleAuthId, {
number: orderNumber, number: orderNumber,
externalNumber, externalNumber,
date: validDate, date: validDate,
createdAt: validDate, createdAt: validDate,
entityId: entityId || undefined, entityId: entityId || undefined,
lines, lines: positiveLines,
}); });
} else { } catch (createErr) {
throw 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 // 6. Payments
const payments = Array.isArray(order.payments) ? order.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 accounts = await store.account.list(googleAuthId).catch(() => []);
const accountMap = new Map(); const accountMap = new Map();
for (const acc of accounts) { for (const acc of accounts) {
@@ -282,27 +358,76 @@ export async function handleOrder(req, res, next) {
} }
}; };
for (const p of payments) { if (createdRetoure && !createdOrder) {
const amount = Number(p.amount) || 0; for (const p of payments) {
if (amount <= 0) continue; const raw = Number(p.amount) || 0;
const type = p.paymentMethodName || 'CASH'; if (raw === 0) continue;
const accountId = await resolveAccountId(type); const amount = -Math.abs(raw);
if (accountId) { 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, { await store.payment.create(googleAuthId, {
accountId, accountId: retoureAccId,
manifestId: createdOrder.id, manifestId: createdRetoure.id,
type, type: retourePaymentType,
amount, 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; 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 warehouses = await store.warehouse.list(googleAuthId).catch(() => []);
let defaultWarehouseId = warehouses[0]?.id; defaultWarehouseId = warehouses[0]?.id;
if (!defaultWarehouseId) { if (!defaultWarehouseId) {
try { try {
const createdWh = await store.warehouse.create(googleAuthId, { name: 'Standardlager' }); const createdWh = await store.warehouse.create(googleAuthId, { name: 'Standardlager' });
@@ -313,11 +438,16 @@ export async function handleOrder(req, res, next) {
defaultWarehouseId = warehouses[0]?.id; defaultWarehouseId = warehouses[0]?.id;
} }
} }
if (!defaultWarehouseId) { return defaultWarehouseId;
};
if (createdOrder && shouldDeliver) {
const whId = await resolveWarehouse();
if (!whId) {
throw new Error('Warehouse not found'); throw new Error('Warehouse not found');
} }
const shippableLines = (createdOrder.lines || lines) const shippableLines = (createdOrder.lines || positiveLines)
.filter((l) => l.shippable !== false) .filter((l) => l.shippable !== false)
.map((l) => { .map((l) => {
if (l.sku) return { sku: l.sku, amount: l.amount }; 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 detail = await store.article.getBySku(googleAuthId, sku).catch(() => null);
const art = detail?.article; const art = detail?.article;
if (!art?.track_stock) continue; 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; const shortfall = needed - available;
if (shortfall > 0) { if (shortfall > 0) {
await store.stock.adjust(googleAuthId, { await store.stock.adjust(googleAuthId, {
sku, sku,
warehouseId: defaultWarehouseId, warehouseId: whId,
amount: shortfall, 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, orderId: createdOrder.id,
date: validDate, date: validDate,
state: 'delivered', state: 'delivered',
warehouseId: defaultWarehouseId, warehouseId: whId,
lines: shippableLines, lines: shippableLines,
shipping: { shipping: {
carrierId, carrierId,
type: 'selbstabholer', type: 'selbstabholer',
state: 'shipped', 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 // 8. Real-time updates
emit('order', { companyId }); if (createdOrder) emit('order', { companyId });
if (createdRetoure) emit('retoure', { companyId });
emit('stock', { companyId }); emit('stock', { companyId });
emit('ledger', { companyId }); emit('ledger', { companyId });
emit('articles', { companyId }); emit('articles', { companyId });
emit('shipping', { companyId }); if ((createdOrder || createdRetoure) && shouldDeliver) emit('shipping', { companyId });
for (const l of lines) { for (const l of [...positiveLines, ...retoureLines]) {
if (l.sku) { if (l.sku) {
emit(`article-${l.sku}`, { companyId }); 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({ results.push({
status: 'OK', status: 'OK',
externalOrderId, externalOrderId,