465 lines
18 KiB
JavaScript
465 lines
18 KiB
JavaScript
// MARK: - Imports
|
|
// #region Imports
|
|
import { cleanValue, parseGermanFloat } from './csvParser.js';
|
|
import { createLogger } from '../../util/logger.js';
|
|
// #endregion
|
|
|
|
const log = createLogger('import');
|
|
|
|
// MARK: - Order / Manifest Importer
|
|
// #region Order / Manifest Importer
|
|
|
|
/**
|
|
* Imports order manifests and line items, resolving customer entities, payments, and shippings.
|
|
*
|
|
* @param {object} params
|
|
* @param {object} params.store - Store singleton
|
|
* @param {number} params.googleAuthId
|
|
* @param {Array<Record<string, string>>} params.manRecords
|
|
* @param {Array<Record<string, string>>} params.itemRecords
|
|
* @param {Map<string, string>} [params.jtlArtIdToSku]
|
|
* @param {Set<string>} [params.parentSkus]
|
|
* @param {object} params.progressTracker
|
|
* @returns {Promise<{ ordersImported: number, ordersSkipped: number, paymentsImported: number, shippingsImported: number }>}
|
|
*/
|
|
export async function importOrders({
|
|
store,
|
|
googleAuthId,
|
|
manRecords,
|
|
itemRecords,
|
|
jtlArtIdToSku = new Map(),
|
|
parentSkus = new Set(),
|
|
progressTracker,
|
|
}) {
|
|
let ordersImported = 0;
|
|
let ordersSkipped = 0;
|
|
let paymentsImported = 0;
|
|
let shippingsImported = 0;
|
|
|
|
if (manRecords.length === 0 || itemRecords.length === 0) {
|
|
return { ordersImported, ordersSkipped, paymentsImported, shippingsImported };
|
|
}
|
|
|
|
const itemsByAuftrag = new Map();
|
|
for (const it of itemRecords) {
|
|
const kAuftrag = String(it.manifestid ?? it.kAuftrag ?? '').trim();
|
|
if (!kAuftrag) continue;
|
|
if (!itemsByAuftrag.has(kAuftrag)) {
|
|
itemsByAuftrag.set(kAuftrag, []);
|
|
}
|
|
itemsByAuftrag.get(kAuftrag).push(it);
|
|
}
|
|
|
|
const existingOrdersResult = await store.order.list(googleAuthId);
|
|
const existingOrderList = Array.isArray(existingOrdersResult)
|
|
? existingOrdersResult
|
|
: (existingOrdersResult?.orders || []);
|
|
const existingOrderNumbers = new Set(
|
|
existingOrderList.map((o) => (o.number || '').trim()).filter(Boolean)
|
|
);
|
|
|
|
let defaultCustomerGroupId = null;
|
|
try {
|
|
const groups = await store.customergroup.list(googleAuthId);
|
|
const posGroup = groups.find((g) => g.name === 'POS' || g.name?.toUpperCase() === 'POS');
|
|
defaultCustomerGroupId = posGroup?.id || groups[0]?.id || null;
|
|
} catch (err) {
|
|
log.warn('Could not list customer groups:', err.message);
|
|
}
|
|
|
|
let defaultWarehouseId = null;
|
|
try {
|
|
const warehouses = await store.warehouse.list(googleAuthId);
|
|
if (warehouses && warehouses.length > 0) {
|
|
defaultWarehouseId = warehouses[0].id;
|
|
} else {
|
|
const wh = await store.warehouse.create(googleAuthId, { name: 'Standardlager' });
|
|
defaultWarehouseId = wh.id;
|
|
}
|
|
} catch (err) {
|
|
log.warn('Could not ensure warehouse:', err.message);
|
|
}
|
|
|
|
let customersImported = 0;
|
|
const entityMap = new Map();
|
|
const entityByNumber = new Map();
|
|
try {
|
|
const entities = await store.entity.list(googleAuthId);
|
|
for (const ent of entities) {
|
|
if (ent.name) {
|
|
entityMap.set(ent.name.trim().toLowerCase(), ent.id);
|
|
}
|
|
if (ent.number) {
|
|
entityByNumber.set(String(ent.number).trim().toLowerCase(), ent.id);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn('Could not list existing entities:', err.message);
|
|
}
|
|
|
|
const resolveEntityId = async (name, firstName, number) => {
|
|
const cleanName = (name || '').trim();
|
|
const cleanNr = (number || '').trim();
|
|
if (!cleanName || !defaultCustomerGroupId) return null;
|
|
|
|
const nrKey = cleanNr ? cleanNr.toLowerCase() : null;
|
|
if (nrKey && entityByNumber.has(nrKey)) {
|
|
return entityByNumber.get(nrKey);
|
|
}
|
|
|
|
const key = cleanName.toLowerCase();
|
|
if (!nrKey && entityMap.has(key)) return entityMap.get(key);
|
|
|
|
try {
|
|
const ent = await store.entity.create(googleAuthId, {
|
|
customergroupId: defaultCustomerGroupId,
|
|
name: cleanName,
|
|
firstName: (firstName || '').trim() || undefined,
|
|
number: cleanNr || undefined,
|
|
});
|
|
entityMap.set(key, ent.id);
|
|
if (ent.number) {
|
|
entityByNumber.set(String(ent.number).trim().toLowerCase(), ent.id);
|
|
}
|
|
if (nrKey) {
|
|
entityByNumber.set(nrKey, ent.id);
|
|
}
|
|
customersImported++;
|
|
return ent.id;
|
|
} catch (err) {
|
|
if (cleanNr && err.message?.includes('Number already in use')) {
|
|
try {
|
|
const refreshed = await store.entity.list(googleAuthId).catch(() => []);
|
|
const found = refreshed.find(
|
|
(e) => String(e.number).trim().toLowerCase() === nrKey
|
|
);
|
|
if (found) {
|
|
entityByNumber.set(nrKey, found.id);
|
|
if (found.name) entityMap.set(found.name.trim().toLowerCase(), found.id);
|
|
return found.id;
|
|
}
|
|
} catch {}
|
|
try {
|
|
const ent = await store.entity.create(googleAuthId, {
|
|
customergroupId: defaultCustomerGroupId,
|
|
name: cleanName,
|
|
firstName: (firstName || '').trim() || undefined,
|
|
});
|
|
entityMap.set(key, ent.id);
|
|
if (ent.number) {
|
|
entityByNumber.set(String(ent.number).trim().toLowerCase(), ent.id);
|
|
}
|
|
customersImported++;
|
|
return ent.id;
|
|
} catch (retryErr) {
|
|
log.warn(`Could not create entity "${cleanName}":`, retryErr.message);
|
|
return null;
|
|
}
|
|
}
|
|
log.warn(`Could not create entity "${cleanName}":`, err.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const accountMap = new Map();
|
|
try {
|
|
const accounts = await store.account.list(googleAuthId);
|
|
for (const acc of accounts) {
|
|
if (acc.name) {
|
|
accountMap.set(acc.name.trim().toLowerCase(), acc.id);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn('Could not list accounts:', err.message);
|
|
}
|
|
|
|
const resolveAccountId = async (accountName) => {
|
|
const cleanAccName = (accountName || 'Bar').trim();
|
|
const key = cleanAccName.toLowerCase();
|
|
if (accountMap.has(key)) return accountMap.get(key);
|
|
|
|
try {
|
|
const created = await store.account.create(googleAuthId, {
|
|
type: cleanAccName,
|
|
name: cleanAccName,
|
|
});
|
|
accountMap.set(key, created.id);
|
|
return created.id;
|
|
} catch (err) {
|
|
log.warn(`Could not create account "${cleanAccName}":`, err.message);
|
|
return accountMap.values().next().value || null;
|
|
}
|
|
};
|
|
|
|
const carrierMap = new Map();
|
|
try {
|
|
const carriers = await store.carrier.list(googleAuthId);
|
|
for (const c of carriers) {
|
|
if (c.name) {
|
|
carrierMap.set(c.name.trim().toLowerCase(), c.id);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn('Could not list carriers:', err.message);
|
|
}
|
|
|
|
const resolveCarrierId = async (name) => {
|
|
const cleanName = (name || '').trim() || 'Standard';
|
|
const key = cleanName.toLowerCase();
|
|
if (carrierMap.has(key)) return carrierMap.get(key);
|
|
|
|
try {
|
|
const carrier = await store.carrier.create(googleAuthId, {
|
|
name: cleanName,
|
|
hasPackages: true,
|
|
});
|
|
carrierMap.set(key, carrier.id);
|
|
return carrier.id;
|
|
} catch (err) {
|
|
const list = await store.carrier.list(googleAuthId).catch(() => []);
|
|
const found = list.find((c) => c.name.trim().toLowerCase() === key);
|
|
if (found) {
|
|
carrierMap.set(key, found.id);
|
|
return found.id;
|
|
}
|
|
log.warn(`Could not create carrier "${cleanName}":`, err.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const processManifest = async (m) => {
|
|
const orderNumber = cleanValue(m.nr ?? m.cAuftragsNr);
|
|
if (!orderNumber || existingOrderNumbers.has(orderNumber)) {
|
|
ordersSkipped++;
|
|
progressTracker.counts.receiptsdone++;
|
|
progressTracker.emitProgress();
|
|
return;
|
|
}
|
|
|
|
const manifestKey = String(m.id ?? m.kAuftrag ?? '').trim();
|
|
const rawItems = itemsByAuftrag.get(manifestKey) || [];
|
|
if (rawItems.length === 0) {
|
|
ordersSkipped++;
|
|
progressTracker.counts.receiptsdone++;
|
|
progressTracker.emitProgress();
|
|
return;
|
|
}
|
|
|
|
rawItems.sort((a, b) => (Number(a.sort ?? a.nSort) || 0) - (Number(b.sort ?? b.nSort) || 0));
|
|
|
|
const lines = rawItems.map((it) => {
|
|
const articleId = cleanValue(it.articleid ?? it.kArtikel);
|
|
let sku = cleanValue(it.sku ?? it.cArtNr) || (articleId && jtlArtIdToSku?.get?.(articleId)) || null;
|
|
if (sku && parentSkus.has(sku)) {
|
|
sku = null;
|
|
}
|
|
const rawName = cleanValue(it.name ?? it.cName ?? it.cNameStandard);
|
|
const name = rawName || (sku ? `Artikel ${sku}` : 'Artikel');
|
|
const amount = parseGermanFloat(it.amount ?? it.fAnzahl, 1);
|
|
const net = parseGermanFloat(it.net ?? it.fVKNetto, 0);
|
|
const taxrate = parseGermanFloat(it.taxrate ?? it.fMwSt, 19);
|
|
const rebate = parseGermanFloat(it.rebate ?? it.fRabatt, 0);
|
|
const comment = cleanValue(it.comment ?? it.cHinweis) || null;
|
|
|
|
let shippable = true;
|
|
if (it.shippable !== undefined && it.shippable !== null && String(it.shippable).trim() !== '') {
|
|
const s = String(it.shippable).trim().toLowerCase();
|
|
shippable = s !== '0' && s !== 'false';
|
|
} else if (/^(summe glätten|shippingcost|versandkosten)$/i.test(name.trim())) {
|
|
shippable = false;
|
|
}
|
|
|
|
return {
|
|
sku,
|
|
name,
|
|
amount,
|
|
net,
|
|
taxrate,
|
|
rebate,
|
|
comment,
|
|
shippable,
|
|
};
|
|
});
|
|
|
|
const custName = cleanValue(m.customerName ?? m.name ?? m.cName) || 'Laufkunde';
|
|
const custFirstName = cleanValue(m.firstName ?? m.cVorname);
|
|
const custNr = cleanValue(m.customerNr ?? m.cKundenNr);
|
|
const entityId = await resolveEntityId(custName, custFirstName, custNr);
|
|
|
|
const dateStr = cleanValue(m.created_at ?? m.dErstellt);
|
|
const parsedDate = dateStr ? new Date(dateStr) : new Date();
|
|
const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString();
|
|
|
|
let createdOrder = null;
|
|
try {
|
|
createdOrder = await store.order.create(googleAuthId, {
|
|
number: orderNumber,
|
|
externalNumber: cleanValue(m.extnr ?? m.cExterneAuftragsnummer) || null,
|
|
date: validDate,
|
|
createdAt: validDate,
|
|
entityId: entityId || undefined,
|
|
lines,
|
|
});
|
|
existingOrderNumbers.add(orderNumber);
|
|
ordersImported++;
|
|
} catch (err) {
|
|
const parentMatch = err.message?.match(/A parent article cannot be booked on documents \(([^)]+)\)/);
|
|
if (parentMatch) {
|
|
const offendingSku = parentMatch[1];
|
|
parentSkus.add(offendingSku);
|
|
for (const l of lines) {
|
|
if (l.sku === offendingSku) l.sku = null;
|
|
}
|
|
try {
|
|
createdOrder = await store.order.create(googleAuthId, {
|
|
number: orderNumber,
|
|
externalNumber: cleanValue(m.extnr ?? m.cExterneAuftragsnummer) || null,
|
|
date: validDate,
|
|
createdAt: validDate,
|
|
entityId: entityId || undefined,
|
|
lines,
|
|
});
|
|
existingOrderNumbers.add(orderNumber);
|
|
ordersImported++;
|
|
} catch (retryErr) {
|
|
log.warn(`Failed to create order ${orderNumber} after stripping parent SKU ${offendingSku}:`, retryErr.message);
|
|
}
|
|
} else {
|
|
log.warn(`Failed to create order ${orderNumber}:`, err.message);
|
|
}
|
|
}
|
|
|
|
if (createdOrder) {
|
|
// Handle Payment
|
|
const isPayed = String(m.payed ?? '').trim() === '1';
|
|
const paymentType = cleanValue(m.paymentType);
|
|
if (isPayed && paymentType) {
|
|
const orderTotal = Math.round(
|
|
lines.reduce(
|
|
(sum, l) => sum + (l.amount * l.net * (1 - (l.rebate || 0) / 100) * (1 + (l.taxrate || 0) / 100)),
|
|
0
|
|
) * 100
|
|
) / 100;
|
|
|
|
if (orderTotal > 0) {
|
|
try {
|
|
const accountId = await resolveAccountId(paymentType);
|
|
if (accountId) {
|
|
await store.payment.create(googleAuthId, {
|
|
accountId,
|
|
manifestId: createdOrder.id,
|
|
type: paymentType,
|
|
amount: orderTotal,
|
|
});
|
|
paymentsImported++;
|
|
}
|
|
} catch (err) {
|
|
log.warn(`Failed to create payment for order ${orderNumber}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle Delivery / Shipping
|
|
const shippingType = cleanValue(m.shippingType);
|
|
const isShipped = String(m.shipped ?? '').trim() === '1';
|
|
|
|
if (isShipped || shippingType) {
|
|
const orderLines = createdOrder?.lines || lines;
|
|
const shippableLines = orderLines
|
|
.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 (shippableLines.length > 0) {
|
|
try {
|
|
const isSelbstabholer = (shippingType || '').trim().toLowerCase() === 'selbstabholer';
|
|
const deliveryState = isShipped ? 'delivered' : 'new';
|
|
|
|
const shipment = await store.shipment.create(googleAuthId, {
|
|
orderId: createdOrder.id,
|
|
date: validDate,
|
|
state: deliveryState,
|
|
lines: shippableLines,
|
|
});
|
|
|
|
if (!isSelbstabholer) {
|
|
const trackingCode = cleanValue(m.trackingCode ?? m.tracking_code ?? m.cTracking);
|
|
const carrierName = cleanValue(m.carrier ?? m.cCarrier) || 'Standard';
|
|
const carrierId = await resolveCarrierId(carrierName);
|
|
const shippingState = isShipped ? 'shipped' : 'draft';
|
|
|
|
if (carrierId) {
|
|
await store.shipping.create(googleAuthId, {
|
|
manifestId: shipment.id,
|
|
carrierId,
|
|
type: shippingType || 'standard',
|
|
state: shippingState,
|
|
trackingCode: trackingCode || undefined,
|
|
warehouseId: defaultWarehouseId || undefined,
|
|
});
|
|
shippingsImported++;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn(`Failed to create shipment/shipping for order ${orderNumber}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
progressTracker.counts.receiptsdone++;
|
|
progressTracker.emitProgress();
|
|
};
|
|
|
|
const runTx = async (fn) => {
|
|
if (typeof store.transaction === 'function') {
|
|
return store.transaction(fn);
|
|
}
|
|
if (typeof store.provider?.transaction === 'function') {
|
|
return store.provider.transaction(fn);
|
|
}
|
|
return fn();
|
|
};
|
|
|
|
const BATCH_SIZE = 25;
|
|
for (let i = 0; i < manRecords.length; i += BATCH_SIZE) {
|
|
const batch = manRecords.slice(i, i + BATCH_SIZE);
|
|
try {
|
|
await runTx(async () => {
|
|
for (const m of batch) {
|
|
await processManifest(m);
|
|
}
|
|
});
|
|
} catch (batchErr) {
|
|
log.warn(`Batch of ${batch.length} manifests failed in transaction, falling back to one-by-one:`, batchErr.message);
|
|
for (const m of batch) {
|
|
try {
|
|
await processManifest(m);
|
|
} catch (singleErr) {
|
|
log.warn(`Single manifest failed:`, singleErr.message);
|
|
progressTracker.counts.receiptsdone++;
|
|
progressTracker.emitProgress();
|
|
}
|
|
}
|
|
}
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
progressTracker.emitProgress(true);
|
|
log.info(`Orders complete: ${ordersImported} created, ${ordersSkipped} skipped, ${paymentsImported} payments, ${shippingsImported} shippings, ${customersImported} customers`);
|
|
|
|
return {
|
|
ordersImported,
|
|
ordersSkipped,
|
|
paymentsImported,
|
|
shippingsImported,
|
|
customersImported,
|
|
};
|
|
}
|
|
|
|
// #endregion
|