u
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user