import { safeSupabaseCall } from "../safeSupabaseCall"; import { CRIMEAN_CITIES } from "../../constants/cities.js"; import { logAction } from "./actionLogService"; import logger from "../../utils/logger"; import { hasSupabaseConfig, supabase } from "../../supabaseClient"; import { getOrderGroupDeliveryHalfDay, getOrderGroupDeliveryStatusLabel, getOrderGroupStatusLabel, } from "../orderGroupViews"; import { normalizeNom } from "../../utils/deliveryUtils"; const requireSupabase = () => { if (!hasSupabaseConfig || !supabase) { throw new Error("Supabase не сконфигурирован"); } return supabase; }; const normalizeText = (value) => (value == null ? "" : String(value)).trim(); const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]); const normalizePhone = (value) => normalizeText(value).replace(/[\s\-()]/g, ""); const toNumber = (value, fallback = 0) => { const nextValue = Number(value); return Number.isFinite(nextValue) ? nextValue : fallback; }; const toStringArray = (value) => { if (Array.isArray(value)) { return value.filter((item) => item != null && String(item).trim() !== "").map(String); } if (value == null || value === "") { return []; } return [String(value)]; }; const parseGroupKey = (groupKey) => { if (!groupKey || typeof groupKey !== "string") { return { phone: "", date: "" }; } const [phone = "", date = ""] = groupKey.split("|"); return { phone: normalizePhone(phone), date: normalizeText(date), }; }; export const mapOrderGroupRowToDeliveryGroup = (row) => { if (!row) { return null; } const parsedKey = parseGroupKey(row.group_key); const customerName = normalizeText(row.customer_name || row.legacy_customer_name); const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone); const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date); const orderNumbers = toStringArray(row.order_numbers); // Extract ALL bill numbers from source_orders (1C sends full orderList in every source_order) const allBillNumbers = (() => { const srcOrders = row.source_orders; if (!Array.isArray(srcOrders) || !srcOrders.length) return orderNumbers; const seen = new Set(); const result = []; for (const src of srcOrders) { if (src && Array.isArray(src.orderList)) { for (const ol of src.orderList) { if (ol && ol.nom) { const n = normalizeNom(ol.nom); if (n && !seen.has(n)) { seen.add(n); result.push(n); } } } } } return result.length > 0 ? result : orderNumbers; })(); const inferredOrderCount = orderNumbers.length; const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount); const readyCount = toNumber( row.ready_count ?? row.orders_ready ?? row.legacy_orders_ready, row.status === "ready_for_notification" ? ordersCount : 0, ); const notReadyCount = toNumber( row.not_ready_count ?? row.orders_not_ready ?? row.legacy_orders_not_ready, Math.max(ordersCount - readyCount, 0), ); const deliveryStatus = normalizeText(row.delivery_status) || "pending_confirmation"; const deliveryDate = normalizeText(row.delivery_date); const rawDeliveryTime = normalizeText(row.delivery_time); const rawDeliveryHalfDay = normalizeText(row.delivery_half_day); const deliveryTime = ALLOWED_DELIVERY_TIMES.has(rawDeliveryTime) ? rawDeliveryTime : ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay) ? rawDeliveryHalfDay : ""; const extractAddressFromSourceOrders = (sourceOrders) => { if (!Array.isArray(sourceOrders) || !sourceOrders.length) { return ""; } const first = sourceOrders[0]; return normalizeText(first.adress || first.address || ""); }; const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders); // Detect pickup from source_orders ship field (1C sends "САМОВЫВОЗ") const isPickupFromSource = Array.isArray(row.source_orders) && row.source_orders.length > 0 && normalizeText(row.source_orders[0].ship || "").toUpperCase() === "САМОВЫВОЗ"; // Also treat address equal to "САМОВЫВОЗ" as pickup indicator const isPickupAddress = deliveryAddress.toUpperCase() === "САМОВЫВОЗ"; // Resolve effective delivery type: // - If DB explicitly says "pickup" → pickup // - If status is "pickup" → pickup // - If DB explicitly says "delivery" (manually confirmed by logistician) → honor it, don't override // - Otherwise fall back to source/address detection const effectiveDeliveryType = (row.delivery_type === "pickup" || deliveryStatus === "pickup") ? "pickup" : row.delivery_type === "delivery" ? "delivery" : (isPickupFromSource || isPickupAddress) ? "pickup" : (row.delivery_type || "delivery"); // Preserve original address for pre-filling delivery form (don't clear for pickup) const originalDeliveryAddress = deliveryAddress; // For display: show nothing for pickup placeholder addresses const resolvedDeliveryAddress = (effectiveDeliveryType === "pickup" && (deliveryAddress.toUpperCase() === "САМОВЫВОЗ" || !deliveryAddress)) ? "" : deliveryAddress; const customerAddress = normalizeText(row.customer_address) || ""; const extractCity = (addr) => { if (!addr) return ""; // 1) explicit marker: г. Ялта, пгт. Куйбышево, etc. const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i); if (m) return m[1].trim(); // 2) known city name anywhere in address (case-insensitive) const lower = addr.toLowerCase(); for (const city of CRIMEAN_CITIES) { if (lower.includes(city.toLowerCase())) return city; } // 3) Бахчисарайский р-н → Бахчисарай const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i); if (district) { const base = district[1]; for (const city of CRIMEAN_CITIES) { if (city.toLowerCase().startsWith(base.toLowerCase())) return city; } } // 4) no match → empty (caller falls back to Севастополь) return ""; }; const city = extractCity(customerAddress) || extractCity(deliveryAddress) || "Севастополь"; return { id: row.id, groupKey: row.group_key, customer: { name: customerName, phone: customerPhone, phoneNormalized: parsedKey.phone || normalizePhone(customerPhone), date: customerDate, ordersCount, readyCount, notReadyCount, }, customerName, customerPhone, customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone), customerDate, deliveryAddress: resolvedDeliveryAddress, originalDeliveryAddress, customerAddress, city, assignedDriverId: row.assigned_driver_id || null, assignedDriverName: row.assigned_driver?.name || "", ordersCount, readyCount, notReadyCount, orderNumbers, allBillNumbers, status: row.status || "draft", smsSentAt: row.sms_sent_at || null, firstSmsSentAt: row.first_sms_sent_at || null, secondSmsSentAt: row.second_sms_sent_at || null, manualConfirmationAt: row.manual_confirmation_at || null, paidStorageAt: row.paid_storage_at || null, notificationStatus: normalizeText(row.notification_status), createdFromExchangeAt: row.created_from_exchange_at || null, sourceKey: row.source_key || null, legacyCustomerName: row.legacy_customer_name || null, legacyCustomerPhone: row.legacy_customer_phone || null, legacyCustomerPhoneNormalized: row.legacy_customer_phone_normalized || null, legacyCustomerDate: row.legacy_customer_date || null, legacyOrdersTotal: row.legacy_orders_total ?? null, legacyOrdersReady: row.legacy_orders_ready ?? null, legacyOrdersNotReady: row.legacy_orders_not_ready ?? null, sourceOrders: row.source_orders || null, orderList: row.order_list || null, orderListStructured: row.order_list_structured || null, createdAt: row.created_at, updatedAt: row.updated_at, deliveryStatus, delivery_status: deliveryStatus, displayTitle: customerName || `Группа ${row.group_key || row.id}`, displaySubtitle: [customerPhone, customerDate].filter(Boolean).join(" · ") || row.group_key || row.id, deliveryDate, deliveryTime, deliveryDateSource: row.delivery_date_source || null, deliveryType: effectiveDeliveryType, pickupDate: row.pickup_date || null, pickupTimeSlot: row.pickup_time_slot || null, driverShipmentData: row.driver_shipment_data || null, syncedTo1cAt: row.synced_to_1c_at || null, deliveryHalfDay: getOrderGroupDeliveryHalfDay({ deliveryHalfDay: rawDeliveryHalfDay, deliveryTime: rawDeliveryTime, deliveryWindow: row.delivery_window, sourceOrders: row.source_orders, }), orderNumberSummary: allBillNumbers.length ? allBillNumbers.join(", ") : "Номера не указаны", searchText: [ row.group_key, customerName, customerPhone, customerDate, resolvedDeliveryAddress, customerAddress, city, rawDeliveryHalfDay, rawDeliveryTime, row.delivery_window, deliveryStatus, getOrderGroupDeliveryStatusLabel(deliveryStatus), orderNumbers.join(" "), allBillNumbers.join(" "), row.status, getOrderGroupStatusLabel(row.status), getOrderGroupDeliveryHalfDay({ deliveryHalfDay: rawDeliveryHalfDay, deliveryTime: rawDeliveryTime, deliveryWindow: row.delivery_window, sourceOrders: row.source_orders, }), getOrderGroupDeliveryStatusLabel(deliveryStatus), row.notification_status, extractAddressFromSourceOrders(row.source_orders), ] .filter(Boolean) .join(" ") .toLowerCase(), }; }; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, synced_to_1c_at`; export const updateOrderGroupDeliveryChoice = async ({ orderGroupId, deliveryDate, deliveryTime, deliveryType, deliveryAddress, pickupDate, pickupTimeSlot, }) => { return safeSupabaseCall(async () => { const client = requireSupabase(); const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed"; const updatePayload = { delivery_status: effectiveDeliveryStatus, delivery_date: deliveryDate, delivery_time: deliveryTime, delivery_type: deliveryType || "delivery", delivery_date_source: "manual", notification_status: "confirmed", updated_at: new Date().toISOString(), }; if (deliveryType === "pickup") { updatePayload.pickup_date = pickupDate || null; updatePayload.pickup_time_slot = pickupTimeSlot || null; // Pickup orders don't need a driver — clear assignment updatePayload.assigned_driver_id = null; } else { updatePayload.pickup_date = null; updatePayload.pickup_time_slot = null; if (deliveryAddress !== undefined) { updatePayload.delivery_address = deliveryAddress; } } const updateResult = await client .from("order_groups") .update(updatePayload) .eq("id", orderGroupId); if (updateResult.error) { throw updateResult.error; } const { data, error } = await client .from("order_groups") .select(ORDER_GROUP_SELECT_FIELDS) .eq("id", orderGroupId) .single(); if (error) { throw error; } await logAction({ orderGroupId, action: "date_assigned", newValue: (deliveryType === "pickup" ? "pickup: " : "manual: ") + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual", delivery_type: deliveryType, pickup_date: pickupDate, pickup_time_slot: pickupTimeSlot } }).catch(() => {}); return mapOrderGroupRowToDeliveryGroup(data); }, "Ошибка сохранения согласования доставки"); }; export const assignDriverToOrderGroup = async ({ orderGroupId, driverId, }) => { return safeSupabaseCall(async () => { const client = requireSupabase(); logger.debug("[assignDriver] orderGroupId:", orderGroupId, "driverId:", driverId); // Direct UPDATE — RLS allows manager/logistician/admin const { data: currentGroup, error: fetchCurrentError } = await client .from("order_groups") .select("delivery_status, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") .eq("id", orderGroupId) .single(); if (fetchCurrentError) { throw fetchCurrentError; } // When assigning a driver, advance status to driver_assigned if currently agreed. // For loaded/on_route/delivered, keep existing status (driver reassigned mid-delivery). // For pending/manual statuses, also set driver_assigned (driver chosen before formal agreement). const currentStatus = currentGroup.delivery_status; const ADVANCED_STATUSES = ["loaded", "on_route", "delivered", "picked_up"]; const newStatus = driverId ? (ADVANCED_STATUSES.includes(currentStatus) ? undefined : "driver_assigned") : null; // removing driver → reset to pending const updates = { assigned_driver_id: driverId || null, ...(newStatus !== undefined && { delivery_status: newStatus }), updated_at: new Date().toISOString(), }; const { error: updateError } = await client .from("order_groups") .update(updates) .eq("id", orderGroupId); if (updateError) { throw updateError; } // Fetch with driver join for the mapper const { data, error } = await client .from("order_groups") .select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") .eq("id", orderGroupId) .single(); if (error) { throw error; } const driverName = data?.assigned_driver?.name || driverId || "—"; const oldDriverName = currentGroup?.assigned_driver?.name || currentGroup?.assigned_driver_id || ""; const logPayload = driverId ? { orderGroupId, action: "driver_assigned", oldValue: oldDriverName || undefined, newValue: driverName, details: { driver_name: driverName } } : { orderGroupId, action: "driver_removed", oldValue: oldDriverName, newValue: "Снят", details: { driver_name: oldDriverName } }; await logAction(logPayload).catch(() => {}); return mapOrderGroupRowToDeliveryGroup(data); }, "Ошибка назначения водителя"); }; export const saveShipmentData = async ({ orderGroupId, shipmentData }) => { return safeSupabaseCall(async () => { const client = requireSupabase(); const { error: updateError } = await client .from("order_groups") .update({ driver_shipment_data: shipmentData, updated_at: new Date().toISOString(), }) .eq("id", orderGroupId); if (updateError) throw updateError; const { data, error } = await client .from("order_groups") .select(ORDER_GROUP_SELECT_FIELDS) .eq("id", orderGroupId) .single(); if (error) throw error; await logAction({ orderGroupId, action: "shipment_data_saved", newValue: "shipment_data_updated", details: { itemCount: shipmentData?.length || 0 }, }).catch(() => {}); return mapOrderGroupRowToDeliveryGroup(data); }, "Ошибка сохранения данных отгрузки"); }; export const updateDeliveryStatus = async ({ orderGroupId, status, details, shipmentData } = {}) => { return safeSupabaseCall(async () => { const client = requireSupabase(); // Fetch current status before any update (needed for audit log) const { data: current, error: fetchCurrentError } = await client .from("order_groups") .select("delivery_status") .eq("id", orderGroupId) .single(); if (fetchCurrentError) throw fetchCurrentError; // Bypass stale RPC for paid_storage transitions // Server-side RPC still enforces driver-assignment checks that block // manager/logistician from moving groups into/out of paid_storage. // RLS policy "order groups update coordination roles" allows // manager/logistician/admin to update order_groups directly. if (status === "paid_storage") { const { error: updateError } = await client .from("order_groups") .update({ delivery_status: status, paid_storage_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq("id", orderGroupId); if (updateError) throw updateError; } else if (current.delivery_status === "paid_storage" && status === "pending_confirmation") { const { error: updateError } = await client .from("order_groups") .update({ delivery_status: status, paid_storage_at: null, updated_at: new Date().toISOString(), }) .eq("id", orderGroupId); if (updateError) throw updateError; } else { // All other statuses use the RPC (driver workflows, etc.) const { error: rpcError } = await client.rpc("update_delivery_status", { p_order_group_id: orderGroupId, p_status: status, }); if (rpcError) throw rpcError; } // Save shipment data if provided (e.g. partial delivery info) if (shipmentData) { const { error: shipmentUpdateError } = await client .from("order_groups") .update({ driver_shipment_data: shipmentData, updated_at: new Date().toISOString(), }) .eq("id", orderGroupId); if (shipmentUpdateError) { // Log but don't fail the status update console.error("[updateDeliveryStatus] Failed to save shipment data:", shipmentUpdateError); } } // Fetch updated group const { data, error } = await client .from("order_groups") .select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") .eq("id", orderGroupId) .single(); if (error) throw error; // Determine specific action type for better log readability const logActionType = status === "paid_storage" ? "paid_storage" : status === "cancelled" ? "cancelled" : "status_change"; const oldValue = current?.delivery_status || null; await logAction({ orderGroupId, action: logActionType, oldValue, newValue: status, details: { source: "admin_panel", ...details } }).catch(() => {}); return mapOrderGroupRowToDeliveryGroup(data); }, "Ошибка обновления статуса доставки"); }; export const fetchOrderGroups = async () => { return safeSupabaseCall(async () => { const client = requireSupabase(); const { data, error } = await client .from("order_groups") .select(ORDER_GROUP_SELECT_FIELDS) .order("updated_at", { ascending: false }); if (error) { throw error; } // Load driver names to patch groups where assigned_driver join is missing const { data: drivers, error: driversError } = await client.rpc("get_drivers"); const driverMap = new Map(); if (!driversError && drivers) { drivers.forEach((d) => driverMap.set(d.id, d.name || d.email)); } return (data || []).map((row) => { const group = mapOrderGroupRowToDeliveryGroup(row); if (group && group.assignedDriverId && !group.assignedDriverName) { group.assignedDriverName = driverMap.get(group.assignedDriverId) || ""; } return group; }).filter(Boolean); }, "Ошибка загрузки групп доставки"); };