538 lines
20 KiB
JavaScript
538 lines
20 KiB
JavaScript
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);
|
||
}, "Ошибка загрузки групп доставки");
|
||
};
|