mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 06:26:40 +02:00
Backend (CRM-Expert Phase 1): - New enums: ContactSource, EntityStatus, CompanySize, OwnerRole, LostReason, EmailType, PhoneType - Contact: add linkedinUrl, birthday, source, department, status - Company: add vatId, taxId, tradeRegisterNumber, registerCourt, companySize, deliveryAddress, dataEnrichedAt/Source, status - Deal: add lostReason + lostReasonText (required when status=LOST) - Multi-value emails/phones tables (contact_emails, contact_phones) - Owner m:n model (contact_owners, company_owners, deal_owners) - Redis Pub/Sub CRM events (crm.contact.created, crm.deal.won, etc.) - Activity due_soon scheduler (cron every 15 min) - SQL migration with data migration for existing records Frontend integration: - types.ts: all new enums, interfaces, label maps - api.ts: owner CRUD endpoints (add/remove for contacts/companies/deals) - hooks.ts: 6 new owner mutation hooks - ContactFormModal: LinkedIn, birthday, source, department, status fields - ContactDetailPage: display new fields (LinkedIn, department, birthday, source, status badge) - CompanyDetailPage: display vatId, taxId, trade register, company size, delivery address, data enrichment info - DealFormModal: lost reason dropdown + text (shown when status=LOST) - DealDetailPage: display lost reason with label - CompaniesPage: EntityStatus-aware status dots (ACTIVE/INACTIVE/BLOCKED) - ActivityType: add FOLLOWUP to all label maps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
// ============================================================
|
|
// Mapping-Funktionen: Lexware Office <-> CRM
|
|
// ============================================================
|
|
|
|
import { VoucherType } from '.prisma/crm-client';
|
|
import {
|
|
LexwareContact,
|
|
LexwareVoucherDetail,
|
|
LexwareVoucherListItem,
|
|
} from '../interfaces/lexware-api.interfaces';
|
|
|
|
// --------------------------------------------------------
|
|
// Typen fuer Multi-Value Email/Phone Arrays
|
|
// --------------------------------------------------------
|
|
interface EmailEntry {
|
|
email: string;
|
|
type: 'WORK' | 'PERSONAL' | 'OTHER';
|
|
isPrimary: boolean;
|
|
}
|
|
|
|
interface PhoneEntry {
|
|
phone: string;
|
|
type: 'OFFICE' | 'MOBILE' | 'FAX';
|
|
isPrimary: boolean;
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Lexware Contact -> CRM Company
|
|
// --------------------------------------------------------
|
|
export function lexwareContactToCompanyData(lc: LexwareContact): {
|
|
name: string;
|
|
email?: string;
|
|
phone?: string;
|
|
website?: string;
|
|
street?: string;
|
|
zip?: string;
|
|
city?: string;
|
|
country?: string;
|
|
notes?: string;
|
|
emails: EmailEntry[];
|
|
phones: PhoneEntry[];
|
|
} {
|
|
const name =
|
|
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
|
|
|
|
const billingAddr = lc.addresses?.billing?.[0];
|
|
const email = getFirstEmail(lc);
|
|
const phone = getFirstPhone(lc);
|
|
|
|
return {
|
|
name,
|
|
email: email || undefined,
|
|
phone: phone || undefined,
|
|
street: billingAddr?.street || undefined,
|
|
zip: billingAddr?.zip || undefined,
|
|
city: billingAddr?.city || undefined,
|
|
country: billingAddr?.countryCode || 'DE',
|
|
notes: lc.note || undefined,
|
|
emails: extractAllEmails(lc),
|
|
phones: extractAllPhones(lc),
|
|
};
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Lexware Contact -> CRM Contact
|
|
// --------------------------------------------------------
|
|
export function lexwareContactToContactData(lc: LexwareContact): {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
companyName?: string;
|
|
position?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
mobile?: string;
|
|
street?: string;
|
|
zip?: string;
|
|
city?: string;
|
|
country?: string;
|
|
notes?: string;
|
|
type: 'PERSON' | 'ORGANIZATION';
|
|
emails: EmailEntry[];
|
|
phones: PhoneEntry[];
|
|
} {
|
|
const isPerson = !!lc.person;
|
|
const billingAddr = lc.addresses?.billing?.[0];
|
|
const email = getFirstEmail(lc);
|
|
const phone = getFirstPhone(lc);
|
|
const mobile = lc.phoneNumbers?.mobile?.[0];
|
|
const emails = extractAllEmails(lc);
|
|
const phones = extractAllPhones(lc);
|
|
|
|
if (isPerson) {
|
|
return {
|
|
type: 'PERSON',
|
|
firstName: lc.person?.firstName || undefined,
|
|
lastName: lc.person?.lastName || 'Unbekannt',
|
|
companyName: lc.company?.name || undefined,
|
|
position: lc.company?.contactPersons?.[0]?.primary
|
|
? 'Primaerkontakt'
|
|
: undefined,
|
|
email: email || undefined,
|
|
phone: phone || undefined,
|
|
mobile: mobile || undefined,
|
|
street: billingAddr?.street || undefined,
|
|
zip: billingAddr?.zip || undefined,
|
|
city: billingAddr?.city || undefined,
|
|
country: billingAddr?.countryCode || 'DE',
|
|
notes: lc.note || undefined,
|
|
emails,
|
|
phones,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'ORGANIZATION',
|
|
firstName: lc.company?.contactPersons?.[0]?.firstName || undefined,
|
|
lastName:
|
|
lc.company?.contactPersons?.[0]?.lastName || lc.company?.name || 'Unbekannt',
|
|
companyName: lc.company?.name || undefined,
|
|
email: email || undefined,
|
|
phone: phone || undefined,
|
|
mobile: mobile || undefined,
|
|
street: billingAddr?.street || undefined,
|
|
zip: billingAddr?.zip || undefined,
|
|
city: billingAddr?.city || undefined,
|
|
country: billingAddr?.countryCode || 'DE',
|
|
notes: lc.note || undefined,
|
|
emails,
|
|
phones,
|
|
};
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// CRM Company -> Lexware Contact Body (fuer POST/PUT)
|
|
// --------------------------------------------------------
|
|
export function companyToLexwareContactBody(company: {
|
|
name: string;
|
|
email?: string | null;
|
|
phone?: string | null;
|
|
street?: string | null;
|
|
zip?: string | null;
|
|
city?: string | null;
|
|
country?: string | null;
|
|
notes?: string | null;
|
|
emails?: Array<{ email: string; type: string }>;
|
|
phones?: Array<{ phone: string; type: string }>;
|
|
}): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {
|
|
version: 0,
|
|
roles: { customer: {} },
|
|
company: { name: company.name },
|
|
};
|
|
|
|
if (company.street || company.zip || company.city) {
|
|
body.addresses = {
|
|
billing: [
|
|
{
|
|
street: company.street || undefined,
|
|
zip: company.zip || undefined,
|
|
city: company.city || undefined,
|
|
countryCode: company.country || 'DE',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Multi-Value emails → Lexware categories
|
|
if (company.emails && company.emails.length > 0) {
|
|
const emailAddresses: Record<string, string[]> = {};
|
|
for (const e of company.emails) {
|
|
const category = emailTypeToLexwareCategory(e.type);
|
|
if (!emailAddresses[category]) emailAddresses[category] = [];
|
|
emailAddresses[category].push(e.email);
|
|
}
|
|
body.emailAddresses = emailAddresses;
|
|
} else if (company.email) {
|
|
body.emailAddresses = { business: [company.email] };
|
|
}
|
|
|
|
// Multi-Value phones → Lexware categories
|
|
if (company.phones && company.phones.length > 0) {
|
|
const phoneNumbers: Record<string, string[]> = {};
|
|
for (const p of company.phones) {
|
|
const category = phoneTypeToLexwareCategory(p.type);
|
|
if (!phoneNumbers[category]) phoneNumbers[category] = [];
|
|
phoneNumbers[category].push(p.phone);
|
|
}
|
|
body.phoneNumbers = phoneNumbers;
|
|
} else if (company.phone) {
|
|
body.phoneNumbers = { business: [company.phone] };
|
|
}
|
|
|
|
if (company.notes) {
|
|
body.note = company.notes.substring(0, 1000);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// CRM Contact -> Lexware Contact Body (fuer POST/PUT)
|
|
// --------------------------------------------------------
|
|
export function contactToLexwareContactBody(contact: {
|
|
firstName?: string | null;
|
|
lastName?: string | null;
|
|
companyName?: string | null;
|
|
email?: string | null;
|
|
phone?: string | null;
|
|
mobile?: string | null;
|
|
street?: string | null;
|
|
zip?: string | null;
|
|
city?: string | null;
|
|
country?: string | null;
|
|
notes?: string | null;
|
|
type: string;
|
|
emails?: Array<{ email: string; type: string }>;
|
|
phones?: Array<{ phone: string; type: string }>;
|
|
}): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {
|
|
version: 0,
|
|
roles: { customer: {} },
|
|
};
|
|
|
|
if (contact.type === 'ORGANIZATION' && contact.companyName) {
|
|
body.company = { name: contact.companyName };
|
|
} else {
|
|
body.person = {
|
|
firstName: contact.firstName || undefined,
|
|
lastName: contact.lastName || 'Unbekannt',
|
|
};
|
|
}
|
|
|
|
if (contact.street || contact.zip || contact.city) {
|
|
body.addresses = {
|
|
billing: [
|
|
{
|
|
street: contact.street || undefined,
|
|
zip: contact.zip || undefined,
|
|
city: contact.city || undefined,
|
|
countryCode: contact.country || 'DE',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Multi-Value emails → Lexware categories
|
|
if (contact.emails && contact.emails.length > 0) {
|
|
const emailAddresses: Record<string, string[]> = {};
|
|
for (const e of contact.emails) {
|
|
const category = emailTypeToLexwareCategory(e.type);
|
|
if (!emailAddresses[category]) emailAddresses[category] = [];
|
|
emailAddresses[category].push(e.email);
|
|
}
|
|
body.emailAddresses = emailAddresses;
|
|
} else if (contact.email) {
|
|
body.emailAddresses = { business: [contact.email] };
|
|
}
|
|
|
|
// Multi-Value phones → Lexware categories
|
|
if (contact.phones && contact.phones.length > 0) {
|
|
const phoneNumbers: Record<string, string[]> = {};
|
|
for (const p of contact.phones) {
|
|
const category = phoneTypeToLexwareCategory(p.type);
|
|
if (!phoneNumbers[category]) phoneNumbers[category] = [];
|
|
phoneNumbers[category].push(p.phone);
|
|
}
|
|
body.phoneNumbers = phoneNumbers;
|
|
} else {
|
|
const phoneNumbers: Record<string, string[]> = {};
|
|
if (contact.phone) phoneNumbers.business = [contact.phone];
|
|
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
|
|
if (Object.keys(phoneNumbers).length > 0) {
|
|
body.phoneNumbers = phoneNumbers;
|
|
}
|
|
}
|
|
|
|
if (contact.notes) {
|
|
body.note = contact.notes.substring(0, 1000);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Voucher Type Mapping
|
|
// --------------------------------------------------------
|
|
export function voucherTypeFromLexware(lexwareType: string): VoucherType {
|
|
const map: Record<string, VoucherType> = {
|
|
invoice: 'INVOICE',
|
|
quotation: 'QUOTATION',
|
|
orderconfirmation: 'ORDER_CONFIRMATION',
|
|
creditnote: 'CREDIT_NOTE',
|
|
};
|
|
return map[lexwareType.toLowerCase()] || 'INVOICE';
|
|
}
|
|
|
|
export function voucherTypeToLexwareEndpoint(type: VoucherType): string {
|
|
const map: Record<VoucherType, string> = {
|
|
INVOICE: 'invoices',
|
|
QUOTATION: 'quotations',
|
|
ORDER_CONFIRMATION: 'order-confirmations',
|
|
CREDIT_NOTE: 'credit-notes',
|
|
};
|
|
return map[type];
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Deep Link Builder
|
|
// --------------------------------------------------------
|
|
export function buildLexwareDeepLink(
|
|
voucherType: VoucherType,
|
|
voucherId: string,
|
|
): string {
|
|
const typeMap: Record<VoucherType, string> = {
|
|
INVOICE: 'invoices',
|
|
QUOTATION: 'quotations',
|
|
ORDER_CONFIRMATION: 'order-confirmations',
|
|
CREDIT_NOTE: 'credit-notes',
|
|
};
|
|
const typePath = typeMap[voucherType];
|
|
return `https://app.lexware.de/permalink/${typePath}/view/${voucherId}`;
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Lexware Voucher Detail -> CRM Cache Daten
|
|
// --------------------------------------------------------
|
|
export function voucherDetailToCacheData(
|
|
detail: LexwareVoucherDetail,
|
|
voucherType: VoucherType,
|
|
lexwareContactId: string,
|
|
): {
|
|
voucherType: VoucherType;
|
|
voucherNumber: string | null;
|
|
voucherDate: Date | null;
|
|
voucherStatus: string | null;
|
|
totalGrossAmount: number | null;
|
|
totalNetAmount: number | null;
|
|
totalTaxAmount: number | null;
|
|
currency: string;
|
|
title: string | null;
|
|
lineItemsCount: number | null;
|
|
lineItemsJson: string | null;
|
|
lexwareContactId: string;
|
|
lexwareDeepLink: string;
|
|
} {
|
|
const lineItems = detail.lineItems?.map((li) => ({
|
|
name: li.name,
|
|
quantity: li.quantity,
|
|
unitName: li.unitName,
|
|
unitPrice: li.unitPrice?.grossAmount,
|
|
amount: li.lineItemAmount,
|
|
}));
|
|
|
|
return {
|
|
voucherType,
|
|
voucherNumber: detail.voucherNumber || null,
|
|
voucherDate: detail.voucherDate ? new Date(detail.voucherDate) : null,
|
|
voucherStatus: detail.voucherStatus || null,
|
|
totalGrossAmount: detail.totalPrice?.totalGrossAmount ?? null,
|
|
totalNetAmount: detail.totalPrice?.totalNetAmount ?? null,
|
|
totalTaxAmount: detail.totalPrice?.totalTaxAmount ?? null,
|
|
currency: detail.totalPrice?.currency || 'EUR',
|
|
title: detail.title || null,
|
|
lineItemsCount: detail.lineItems?.length ?? null,
|
|
lineItemsJson: lineItems ? JSON.stringify(lineItems) : null,
|
|
lexwareContactId,
|
|
lexwareDeepLink: buildLexwareDeepLink(voucherType, detail.id),
|
|
};
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Hilfsfunktionen
|
|
// --------------------------------------------------------
|
|
function getFirstEmail(lc: LexwareContact): string | undefined {
|
|
const emails = lc.emailAddresses;
|
|
if (!emails) return undefined;
|
|
return (
|
|
emails.business?.[0] ||
|
|
emails.office?.[0] ||
|
|
emails.private?.[0] ||
|
|
emails.other?.[0]
|
|
);
|
|
}
|
|
|
|
function getFirstPhone(lc: LexwareContact): string | undefined {
|
|
const phones = lc.phoneNumbers;
|
|
if (!phones) return undefined;
|
|
return (
|
|
phones.business?.[0] ||
|
|
phones.office?.[0] ||
|
|
phones.private?.[0] ||
|
|
phones.other?.[0]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Alle Lexware-Emails in Multi-Value Array konvertieren.
|
|
* Mapping: business/office → WORK, private → PERSONAL, other → OTHER
|
|
*/
|
|
function extractAllEmails(lc: LexwareContact): EmailEntry[] {
|
|
const result: EmailEntry[] = [];
|
|
const emails = lc.emailAddresses;
|
|
if (!emails) return result;
|
|
|
|
let isFirst = true;
|
|
const addEmails = (addresses: string[] | undefined, type: EmailEntry['type']) => {
|
|
if (!addresses) return;
|
|
for (const addr of addresses) {
|
|
result.push({ email: addr, type, isPrimary: isFirst });
|
|
isFirst = false;
|
|
}
|
|
};
|
|
|
|
addEmails(emails.business, 'WORK');
|
|
addEmails(emails.office, 'WORK');
|
|
addEmails(emails.private, 'PERSONAL');
|
|
addEmails(emails.other, 'OTHER');
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Alle Lexware-Phones in Multi-Value Array konvertieren.
|
|
* Mapping: business/office → OFFICE, mobile → MOBILE, fax → FAX, private/other → OFFICE
|
|
*/
|
|
function extractAllPhones(lc: LexwareContact): PhoneEntry[] {
|
|
const result: PhoneEntry[] = [];
|
|
const phones = lc.phoneNumbers;
|
|
if (!phones) return result;
|
|
|
|
let isFirst = true;
|
|
const addPhones = (numbers: string[] | undefined, type: PhoneEntry['type']) => {
|
|
if (!numbers) return;
|
|
for (const num of numbers) {
|
|
result.push({ phone: num, type, isPrimary: isFirst });
|
|
isFirst = false;
|
|
}
|
|
};
|
|
|
|
addPhones(phones.business, 'OFFICE');
|
|
addPhones(phones.office, 'OFFICE');
|
|
addPhones(phones.mobile, 'MOBILE');
|
|
addPhones(phones.fax, 'FAX');
|
|
addPhones(phones.private, 'OFFICE');
|
|
addPhones(phones.other, 'OFFICE');
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* CRM EmailType → Lexware Kategorie
|
|
*/
|
|
function emailTypeToLexwareCategory(type: string): string {
|
|
switch (type) {
|
|
case 'WORK': return 'business';
|
|
case 'PERSONAL': return 'private';
|
|
case 'OTHER': return 'other';
|
|
default: return 'business';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CRM PhoneType → Lexware Kategorie
|
|
*/
|
|
function phoneTypeToLexwareCategory(type: string): string {
|
|
switch (type) {
|
|
case 'OFFICE': return 'business';
|
|
case 'MOBILE': return 'mobile';
|
|
case 'FAX': return 'fax';
|
|
default: return 'business';
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Lexware VoucherList Item -> Voucher Type
|
|
// --------------------------------------------------------
|
|
export function voucherListItemToType(
|
|
item: LexwareVoucherListItem,
|
|
): VoucherType {
|
|
return voucherTypeFromLexware(item.voucherType);
|
|
}
|