supersam/src/services/supabase/orderGroupRepository.js

538 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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