arcadiasuite/server/retail/sync-service.ts

649 lines
22 KiB
TypeScript

import { db } from "../../db/index";
import { eq, and, isNull, desc } from "drizzle-orm";
import {
persons, personRoles, mobileDevices, serviceOrders,
deviceEvaluations, imeiHistory, type PersonRole
} from "@shared/schema";
import * as erpnextService from "../erpnext/service";
interface SyncResult {
success: boolean;
created: number;
updated: number;
errors: string[];
details?: unknown[];
}
interface ERPNextCustomer {
name: string;
customer_name: string;
customer_type?: string;
customer_group?: string;
territory?: string;
tax_id?: string;
mobile_no?: string;
email_id?: string;
custom_arcadia_person_id?: number;
}
interface ERPNextSupplier {
name: string;
supplier_name: string;
supplier_group?: string;
supplier_type?: string;
tax_id?: string;
mobile_no?: string;
email_id?: string;
custom_arcadia_person_id?: number;
}
interface ERPNextItem {
name: string;
item_name: string;
item_code: string;
item_group?: string;
stock_uom?: string;
is_stock_item?: number;
has_serial_no?: number;
serial_no_series?: string;
description?: string;
standard_rate?: number;
custom_brand?: string;
custom_model?: string;
custom_storage?: string;
custom_color?: string;
}
interface ERPNextSerialNo {
name: string;
serial_no: string;
item_code: string;
status?: string;
warehouse?: string;
purchase_rate?: number;
custom_imei2?: string;
custom_condition?: string;
custom_arcadia_device_id?: number;
}
export class RetailSyncService {
async isERPNextConnected(): Promise<boolean> {
if (!erpnextService.isConfigured()) return false;
const result = await erpnextService.testConnection();
return result.success;
}
// ========================================
// PERSON SYNC - Persons ↔ Customer/Supplier
// ========================================
async syncPersonToERPNext(personId: number): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const [person] = await db.select().from(persons).where(eq(persons.id, personId));
if (!person) {
result.success = false;
result.errors.push(`Person ${personId} not found`);
return result;
}
const roles = await db.select().from(personRoles).where(eq(personRoles.personId, personId));
const roleTypes = roles.map((r: PersonRole) => r.roleType);
// Sync as Customer if has customer role
if (roleTypes.includes("customer")) {
const customerResult = await this.syncPersonAsCustomer(person);
if (customerResult.created) result.created++;
if (customerResult.updated) result.updated++;
if (customerResult.error) result.errors.push(customerResult.error);
}
// Sync as Supplier if has supplier role
if (roleTypes.includes("supplier")) {
const supplierResult = await this.syncPersonAsSupplier(person);
if (supplierResult.created) result.created++;
if (supplierResult.updated) result.updated++;
if (supplierResult.error) result.errors.push(supplierResult.error);
}
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
private async syncPersonAsCustomer(person: typeof persons.$inferSelect): Promise<{ created?: boolean; updated?: boolean; error?: string }> {
try {
const customerData = {
customer_name: person.fullName,
customer_type: person.cpfCnpj && person.cpfCnpj.length > 14 ? "Company" : "Individual",
customer_group: "All Customer Groups",
territory: "Brazil",
tax_id: person.cpfCnpj || undefined,
mobile_no: person.phone || person.whatsapp || undefined,
email_id: person.email || undefined,
custom_arcadia_person_id: person.id,
};
// Check if customer exists
if (person.erpnextCustomerId) {
await erpnextService.updateDocument("Customer", person.erpnextCustomerId, customerData);
return { updated: true };
}
// Try to find by tax_id or create new
if (person.cpfCnpj) {
const existing = await erpnextService.getDocuments<ERPNextCustomer>(
"Customer",
{ tax_id: person.cpfCnpj },
["name"],
1
);
if (existing.length > 0) {
await db.update(persons).set({ erpnextCustomerId: existing[0].name }).where(eq(persons.id, person.id));
await erpnextService.updateDocument("Customer", existing[0].name, customerData);
return { updated: true };
}
}
// Create new customer
const newCustomer = await erpnextService.createDocument<ERPNextCustomer>("Customer", customerData);
await db.update(persons).set({ erpnextCustomerId: newCustomer.name }).where(eq(persons.id, person.id));
return { created: true };
} catch (error) {
return { error: `Customer sync error: ${error instanceof Error ? error.message : "Unknown"}` };
}
}
private async syncPersonAsSupplier(person: typeof persons.$inferSelect): Promise<{ created?: boolean; updated?: boolean; error?: string }> {
try {
const supplierData = {
supplier_name: person.fullName,
supplier_group: "All Supplier Groups",
supplier_type: person.cpfCnpj && person.cpfCnpj.length > 14 ? "Company" : "Individual",
tax_id: person.cpfCnpj || undefined,
mobile_no: person.phone || person.whatsapp || undefined,
email_id: person.email || undefined,
custom_arcadia_person_id: person.id,
};
if (person.erpnextSupplierId) {
await erpnextService.updateDocument("Supplier", person.erpnextSupplierId, supplierData);
return { updated: true };
}
if (person.cpfCnpj) {
const existing = await erpnextService.getDocuments<ERPNextSupplier>(
"Supplier",
{ tax_id: person.cpfCnpj },
["name"],
1
);
if (existing.length > 0) {
await db.update(persons).set({ erpnextSupplierId: existing[0].name }).where(eq(persons.id, person.id));
await erpnextService.updateDocument("Supplier", existing[0].name, supplierData);
return { updated: true };
}
}
const newSupplier = await erpnextService.createDocument<ERPNextSupplier>("Supplier", supplierData);
await db.update(persons).set({ erpnextSupplierId: newSupplier.name }).where(eq(persons.id, person.id));
return { created: true };
} catch (error) {
return { error: `Supplier sync error: ${error instanceof Error ? error.message : "Unknown"}` };
}
}
async syncAllPersonsToERPNext(): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [], details: [] };
try {
const allPersons = await db.select().from(persons);
for (const person of allPersons) {
const syncResult = await this.syncPersonToERPNext(person.id);
result.created += syncResult.created;
result.updated += syncResult.updated;
result.errors.push(...syncResult.errors);
}
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
// ========================================
// DEVICE SYNC - MobileDevices ↔ Item + Serial No
// ========================================
async syncDeviceToERPNext(deviceId: number): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const [device] = await db.select().from(mobileDevices).where(eq(mobileDevices.id, deviceId));
if (!device) {
result.success = false;
result.errors.push(`Device ${deviceId} not found`);
return result;
}
// 1. Ensure Item exists (product template)
const itemCode = `${device.brand}-${device.model}`.replace(/\s+/g, "-").toUpperCase();
const itemResult = await this.ensureItemExists(device, itemCode);
if (itemResult.error) {
result.errors.push(itemResult.error);
} else {
if (itemResult.created) result.created++;
if (itemResult.updated) result.updated++;
}
// 2. Create/Update Serial No for IMEI
const serialResult = await this.syncDeviceAsSerialNo(device, itemCode);
if (serialResult.error) {
result.errors.push(serialResult.error);
} else {
if (serialResult.created) result.created++;
if (serialResult.updated) result.updated++;
}
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
private async ensureItemExists(device: typeof mobileDevices.$inferSelect, itemCode: string): Promise<{ created?: boolean; updated?: boolean; error?: string }> {
try {
// Check if item exists
try {
await erpnextService.getDocument("Item", itemCode);
return { updated: true }; // Item exists
} catch {
// Item doesn't exist, create it
}
const itemData = {
item_code: itemCode,
item_name: `${device.brand} ${device.model}`,
item_group: "Mobile Devices",
stock_uom: "Nos",
is_stock_item: 1,
has_serial_no: 1,
serial_no_series: "IMEI-.#####",
description: `${device.brand} ${device.model} - ${device.storage || ""}`,
custom_brand: device.brand,
custom_model: device.model,
custom_storage: device.storage,
};
await erpnextService.createDocument("Item", itemData);
return { created: true };
} catch (error) {
return { error: `Item sync error: ${error instanceof Error ? error.message : "Unknown"}` };
}
}
private async syncDeviceAsSerialNo(device: typeof mobileDevices.$inferSelect, itemCode: string): Promise<{ created?: boolean; updated?: boolean; error?: string }> {
try {
const serialNoData = {
serial_no: device.imei,
item_code: itemCode,
status: device.status === "available" ? "Active" : device.status === "sold" ? "Delivered" : "Inactive",
purchase_rate: device.acquisitionCost ? parseFloat(device.acquisitionCost) : undefined,
custom_imei2: device.imei2 || undefined,
custom_condition: device.condition,
custom_arcadia_device_id: device.id,
};
if (device.erpnextSerialNo) {
await erpnextService.updateDocument("Serial No", device.erpnextSerialNo, serialNoData);
return { updated: true };
}
// Check if serial no exists by IMEI
try {
await erpnextService.getDocument("Serial No", device.imei);
await db.update(mobileDevices).set({ erpnextSerialNo: device.imei }).where(eq(mobileDevices.id, device.id));
await erpnextService.updateDocument("Serial No", device.imei, serialNoData);
return { updated: true };
} catch {
// Serial No doesn't exist
}
await erpnextService.createDocument("Serial No", serialNoData);
await db.update(mobileDevices).set({ erpnextSerialNo: device.imei }).where(eq(mobileDevices.id, device.id));
return { created: true };
} catch (error) {
return { error: `Serial No sync error: ${error instanceof Error ? error.message : "Unknown"}` };
}
}
// ========================================
// SERVICE ORDER SYNC - ServiceOrders ↔ Maintenance Visit
// ========================================
async syncServiceOrderToERPNext(orderId: number): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const [order] = await db.select().from(serviceOrders).where(eq(serviceOrders.id, orderId));
if (!order) {
result.success = false;
result.errors.push(`Service Order ${orderId} not found`);
return result;
}
// Get customer if exists
let customerName: string | undefined;
if (order.personId) {
const [person] = await db.select().from(persons).where(eq(persons.id, order.personId));
if (person?.erpnextCustomerId) {
customerName = person.erpnextCustomerId;
}
}
const maintenanceData = {
naming_series: "MAI-VST-.YYYY.-",
customer: customerName,
mntc_date: new Date().toISOString().split("T")[0],
mntc_time: new Date().toTimeString().split(" ")[0],
completion_status: this.mapServiceStatus(order.status || "pending"),
purposes: [{
work_done: order.diagnosisNotes || "Pending diagnosis",
service_unit: "Mobile Repair",
description: order.issueDescription,
}],
custom_arcadia_order_id: order.id,
custom_order_number: order.orderNumber,
custom_device_brand: order.brand,
custom_device_model: order.model,
custom_device_imei: order.imei,
};
if (order.erpnextDocName) {
await erpnextService.updateDocument("Maintenance Visit", order.erpnextDocName, maintenanceData);
result.updated++;
} else {
const newDoc = await erpnextService.createDocument<{ name: string }>("Maintenance Visit", maintenanceData);
await db.update(serviceOrders).set({ erpnextDocName: newDoc.name }).where(eq(serviceOrders.id, order.id));
result.created++;
}
result.success = true;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
private mapServiceStatus(status: string): string {
const statusMap: Record<string, string> = {
"pending": "Pending",
"waiting_parts": "Pending",
"in_progress": "Partially Completed",
"completed": "Fully Completed",
"delivered": "Fully Completed",
"cancelled": "Cancelled",
};
return statusMap[status] || "Pending";
}
// ========================================
// STOCK SYNC - Stock movements ↔ Stock Entry
// ========================================
async createStockEntry(
entryType: "Material Receipt" | "Material Issue" | "Material Transfer",
items: Array<{ itemCode: string; serialNo: string; qty: number; rate?: number }>,
fromWarehouse?: string,
toWarehouse?: string
): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const stockEntryData = {
stock_entry_type: entryType,
from_warehouse: fromWarehouse,
to_warehouse: toWarehouse,
items: items.map(item => ({
item_code: item.itemCode,
serial_no: item.serialNo,
qty: item.qty,
basic_rate: item.rate,
s_warehouse: fromWarehouse,
t_warehouse: toWarehouse,
})),
};
await erpnextService.createDocument("Stock Entry", stockEntryData);
result.created++;
result.success = true;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
// ========================================
// SALES SYNC - Sales → Sales Invoice
// ========================================
async createSalesInvoice(
customerName: string,
items: Array<{ itemCode: string; serialNo?: string; qty: number; rate: number }>,
paymentMode?: string
): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const invoiceData = {
naming_series: "SINV-.YYYY.-",
customer: customerName,
posting_date: new Date().toISOString().split("T")[0],
due_date: new Date().toISOString().split("T")[0],
items: items.map(item => ({
item_code: item.itemCode,
serial_no: item.serialNo,
qty: item.qty,
rate: item.rate,
})),
custom_payment_mode: paymentMode,
update_stock: 1,
};
await erpnextService.createDocument("Sales Invoice", invoiceData);
result.created++;
result.success = true;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
// ========================================
// IMPORT FROM ERPNEXT - Pull data into Arcadia
// ========================================
async importCustomersFromERPNext(limit: number = 100): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const customers = await erpnextService.getDocuments<ERPNextCustomer>(
"Customer",
{},
["name", "customer_name", "tax_id", "mobile_no", "email_id", "custom_arcadia_person_id"],
limit
);
for (const customer of customers) {
try {
// Skip if already linked
if (customer.custom_arcadia_person_id) {
const [existing] = await db.select().from(persons).where(eq(persons.id, customer.custom_arcadia_person_id));
if (existing) continue;
}
// Check by tax_id
if (customer.tax_id) {
const [existing] = await db.select().from(persons).where(eq(persons.cpfCnpj, customer.tax_id));
if (existing) {
await db.update(persons).set({ erpnextCustomerId: customer.name }).where(eq(persons.id, existing.id));
result.updated++;
continue;
}
}
// Create new person
const [newPerson] = await db.insert(persons).values({
fullName: customer.customer_name,
cpfCnpj: customer.tax_id || null,
phone: customer.mobile_no || null,
email: customer.email_id || null,
erpnextCustomerId: customer.name,
}).returning();
// Add customer role
await db.insert(personRoles).values({
personId: newPerson.id,
roleType: "customer",
});
result.created++;
} catch (error) {
result.errors.push(`Error importing ${customer.name}: ${error instanceof Error ? error.message : "Unknown"}`);
}
}
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
async importSuppliersFromERPNext(limit: number = 100): Promise<SyncResult> {
const result: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
try {
const suppliers = await erpnextService.getDocuments<ERPNextSupplier>(
"Supplier",
{},
["name", "supplier_name", "tax_id", "mobile_no", "email_id", "custom_arcadia_person_id"],
limit
);
for (const supplier of suppliers) {
try {
if (supplier.custom_arcadia_person_id) {
const [existing] = await db.select().from(persons).where(eq(persons.id, supplier.custom_arcadia_person_id));
if (existing) continue;
}
if (supplier.tax_id) {
const [existing] = await db.select().from(persons).where(eq(persons.cpfCnpj, supplier.tax_id));
if (existing) {
await db.update(persons).set({ erpnextSupplierId: supplier.name }).where(eq(persons.id, existing.id));
result.updated++;
continue;
}
}
const [newPerson] = await db.insert(persons).values({
fullName: supplier.supplier_name,
cpfCnpj: supplier.tax_id || null,
phone: supplier.mobile_no || null,
email: supplier.email_id || null,
erpnextSupplierId: supplier.name,
}).returning();
await db.insert(personRoles).values({
personId: newPerson.id,
roleType: "supplier",
});
result.created++;
} catch (error) {
result.errors.push(`Error importing ${supplier.name}: ${error instanceof Error ? error.message : "Unknown"}`);
}
}
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push(error instanceof Error ? error.message : "Unknown error");
}
return result;
}
// ========================================
// FULL SYNC - Sync everything
// ========================================
async runFullSync(): Promise<{
persons: SyncResult;
devices: SyncResult;
serviceOrders: SyncResult;
importedCustomers: SyncResult;
importedSuppliers: SyncResult;
}> {
const personSync = await this.syncAllPersonsToERPNext();
// Sync all devices
const deviceSync: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
const allDevices = await db.select().from(mobileDevices);
for (const device of allDevices) {
const result = await this.syncDeviceToERPNext(device.id);
deviceSync.created += result.created;
deviceSync.updated += result.updated;
deviceSync.errors.push(...result.errors);
}
deviceSync.success = deviceSync.errors.length === 0;
// Sync all service orders
const orderSync: SyncResult = { success: true, created: 0, updated: 0, errors: [] };
const allOrders = await db.select().from(serviceOrders);
for (const order of allOrders) {
const result = await this.syncServiceOrderToERPNext(order.id);
orderSync.created += result.created;
orderSync.updated += result.updated;
orderSync.errors.push(...result.errors);
}
orderSync.success = orderSync.errors.length === 0;
// Import from ERPNext
const importedCustomers = await this.importCustomersFromERPNext();
const importedSuppliers = await this.importSuppliersFromERPNext();
return {
persons: personSync,
devices: deviceSync,
serviceOrders: orderSync,
importedCustomers,
importedSuppliers,
};
}
}
export const retailSyncService = new RetailSyncService();