diff --git a/docs/api/routes/v1/order.md b/docs/api/routes/v1/order.md index 30552c7..f41f97b 100644 --- a/docs/api/routes/v1/order.md +++ b/docs/api/routes/v1/order.md @@ -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 diff --git a/src/v1/routes/order.js b/src/v1/routes/order.js index e70757e..a05253c 100644 --- a/src/v1/routes/order.js +++ b/src/v1/routes/order.js @@ -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,