diff --git a/.opencode.json b/.opencode.json new file mode 100644 index 0000000..e6000c2 --- /dev/null +++ b/.opencode.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "n8n-mcp": { + "type": "http", + "url": "https://n8n.supersamsev.ru/mcp-server/http", + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjMyZTZiZC00MzlhLTQwYjMtODgzMi05MzAxZTM3ZjRlMzgiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImE1OTU3YjgzLWExYTUtNDg4OC1iMzUyLWQxM2JhZDEyZDg0NCIsImlhdCI6MTc3Nzg5ODEyN30.wT6z8T9V8gRsS1uUg3HYEFFoS223krEC0KjQPBxlfis" + } + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..4990cc2 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "servers": { + "n8n-mcp": { + "type": "http", + "url": "https://n8n.supersamsev.ru/mcp-server/http", + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjMyZTZiZC00MzlhLTQwYjMtODgzMi05MzAxZTM3ZjRlMzgiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImE1OTU3YjgzLWExYTUtNDg4OC1iMzUyLWQxM2JhZDEyZDg0NCIsImlhdCI6MTc3Nzg5ODEyN30.wT6z8T9V8gRsS1uUg3HYEFFoS223krEC0KjQPBxlfis" + } + } + } +} diff --git a/anonymize_xml.py b/anonymize_xml.py new file mode 100644 index 0000000..0907b61 --- /dev/null +++ b/anonymize_xml.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Скрипт для анонимизации персональных данных в XML-файле. +Заменяет ФИО и телефоны на сгенерированные, сохраняя соответствие дублей. + +Использование: + python3 anonymize_xml.py <входной_xml> [выходной_xml] +""" + +import sys +import random +import xml.etree.ElementTree as ET +from pathlib import Path + +# --- Генераторы фейковых данных --- + +MALE_SURNAMES = [ + "Иванов", + "Смирнов", + "Кузнецов", + "Попов", + "Васильев", + "Петров", + "Соколов", + "Михайлов", + "Новиков", + "Федоров", + "Морозов", + "Волков", + "Алексеев", + "Лебедев", + "Семенов", + "Егоров", + "Павлов", + "Козлов", + "Степанов", + "Николаев", + "Орлов", + "Андреев", + "Макаров", + "Зайцев", + "Соловьев", + "Борисов", + "Яковлев", + "Григорьев", + "Романов", + "Воробьев", + "Антонов", + "Захаров", + "Максимов", + "Фролов", + "Дмитриев", + "Калинин", + "Беляев", + "Гусев", + "Назаров", + "Карпов", + "Афанасьев", + "Тихонов", + "Марков", + "Комаров", + "Шестаков", + "Артемьев", + "Кукушкин", + "Плотников", + "Дорофеев", +] + +FEMALE_SURNAMES = [ + "Иванова", + "Смирнова", + "Кузнецова", + "Попова", + "Васильева", + "Петрова", + "Соколова", + "Михайлова", + "Новикова", + "Федорова", + "Морозова", + "Волкова", + "Алексеева", + "Лебедева", + "Семенова", + "Егорова", + "Павлова", + "Козлова", + "Степанова", + "Николаева", + "Орлова", + "Андреева", + "Макарова", + "Зайцева", + "Соловьева", + "Борисова", + "Яковлева", + "Григорьева", + "Романова", + "Воробьева", + "Атонова", + "Захарова", + "Максимова", + "Фролова", + "Дмитриева", + "Калинина", + "Беляева", + "Гусева", + "Назарова", + "Карпова", + "Афанасьева", + "Тихонова", + "Маркова", + "Комарова", + "Шестакова", + "Артемьева", + "Кукушкина", + "Плотникова", + "Дорофеева", +] + +MALE_NAMES = [ + "Александр", + "Дмитрий", + "Максим", + "Сергей", + "Андрей", + "Алексей", + "Артем", + "Илья", + "Кирилл", + "Михаил", + "Никита", + "Матвей", + "Роман", + "Егор", + "Арсений", + "Иван", + "Денис", + "Евгений", + "Даниил", + "Тимофей", + "Владимир", + "Павел", + "Руслан", + "Марк", + "Константин", + "Тимур", + "Олег", + "Ярослав", + "Степан", + "Глеб", + "Николай", + "Петр", + "Виктор", + "Семен", + "Леонид", + "Григорий", + "Богдан", + "Захар", + "Тарас", + "Федор", +] + +FEMALE_NAMES = [ + "Анна", + "Мария", + "Елена", + "Алиса", + "Виктория", + "Полина", + "Александра", + "Елизавета", + "Дарья", + "Анастасия", + "Ксения", + "Валерия", + "Варвара", + "Вероника", + "София", + "Арина", + "Марина", + "Юлия", + "Татьяна", + "Наталья", + "Ольга", + "Светлана", + "Ирина", + "Екатерина", + "Оксана", + "Алина", + "Кристина", + "Людмила", + "Галина", + "Надежда", + "Любовь", + "Маргарита", + "Софья", + "Яна", + "Диана", + "Алла", + "Инна", + "Эмилия", + "Лариса", + "Злата", +] + +MALE_PATRONYMICS = [ + "Александрович", + "Дмитриевич", + "Максимович", + "Сергеевич", + "Андреевич", + "Алексеевич", + "Артемович", + "Ильич", + "Кириллович", + "Михайлович", + "Никитич", + "Матвеевич", + "Романович", + "Егорович", + "Арсеньевич", + "Иванович", + "Денисович", + "Евгеньевич", + "Даниилович", + "Тимофеевич", + "Владимирович", + "Павлович", + "Константинович", + "Тимурович", + "Олегович", + "Ярославович", + "Степанович", + "Глебович", + "Николаевич", + "Петрович", + "Викторович", + "Семенович", + "Леонидович", + "Григорьевич", + "Богданович", + "Захарович", + "Тарасович", + "Федорович", +] + +FEMALE_PATRONYMICS = [ + "Александровна", + "Дмитриевна", + "Максимовна", + "Сергеевна", + "Андреевна", + "Алексеевна", + "Артемовна", + "Ильинична", + "Кирилловна", + "Михайловна", + "Никитична", + "Матвеевна", + "Романовна", + "Егоровна", + "Арсеньевна", + "Ивановна", + "Денисовна", + "Евгеньевна", + "Данииловна", + "Тимофеевна", + "Владимировна", + "Павловна", + "Константиновна", + "Тимуровна", + "Олеговна", + "Ярославовна", + "Степановна", + "Глебовна", + "Николаевна", + "Петровна", + "Викторовна", + "Семеновна", + "Леонидовна", + "Григорьевна", + "Богдановна", + "Захаровна", + "Тарасовна", + "Федоровна", +] + + +def generate_fake_fio(): + """Генерирует случайное русское ФИО с инициалами или полным отчеством.""" + if random.choice([True, False]): + # Полное ФИО + surname = random.choice(MALE_SURNAMES + FEMALE_SURNAMES) + # Попытка согласовать род фамилии и имя/отчество + if surname.endswith("а") and not surname.endswith( + ("ина", "ова", "ева", "ына", "ая") + ): + # Может быть мужская фамилия типа Буря, но проще — выбираем случайно + is_female = random.choice([True, False]) + else: + is_female = surname.endswith( + ("ина", "ова", "ева", "ына", "ая", "ска", "цка", "ёва") + ) + + if is_female: + name = random.choice(FEMALE_NAMES) + patronymic = random.choice(FEMALE_PATRONYMICS) + else: + name = random.choice(MALE_NAMES) + patronymic = random.choice(MALE_PATRONYMICS) + return f"{surname} {name} {patronymic}" + else: + # Фамилия с инициалами + surname = random.choice(MALE_SURNAMES + FEMALE_SURNAMES) + is_female = surname.endswith( + ("ина", "ова", "ева", "ына", "ая", "ска", "цка", "ёва") + ) + if is_female: + name_initial = random.choice(FEMALE_NAMES)[0] + patronymic_initial = random.choice(FEMALE_PATRONYMICS)[0] + else: + name_initial = random.choice(MALE_NAMES)[0] + patronymic_initial = random.choice(MALE_PATRONYMICS)[0] + return f"{surname} {name_initial}.{patronymic_initial}." + + +def generate_fake_phone(): + """Генерирует случайный российский мобильный номер (10 цифр, начинается с 9).""" + return f"9{random.randint(100000000, 999999999)}" + + +def anonymize_xml(input_path: str, output_path: str = None): + input_file = Path(input_path) + if not input_file.exists(): + print(f"Ошибка: файл не найден: {input_path}") + sys.exit(1) + + # Определяем выходной путь + if output_path is None: + output_file = input_file.with_suffix(".anonymized.xml") + else: + output_file = Path(output_path) + + # Читаем исходный файл + # Пробуем cp1251 (WINDOWS-1251), если ошибка — utf-8 + raw_bytes = input_file.read_bytes() + try: + text = raw_bytes.decode("cp1251") + except UnicodeDecodeError: + text = raw_bytes.decode("utf-8", errors="replace") + + # Парсим XML + try: + root = ET.fromstring(text) + except ET.ParseError as e: + print(f"Ошибка парсинга XML: {e}") + sys.exit(1) + + # Собираем уникальные значения + fio_map = {} + tel_map = {} + fio_values = set() + tel_values = set() + + for account in root.findall("Account"): + fio = account.get("fio") + tel = account.get("tel") + if fio: + fio_values.add(fio) + if tel: + tel_values.add(tel) + + # Генерируем маппинги + random.seed(42) # Для воспроизводимости (можно убрать или изменить) + for fio in sorted(fio_values): + fio_map[fio] = generate_fake_fio() + for tel in sorted(tel_values): + tel_map[tel] = generate_fake_phone() + + # Заменяем в дереве + for account in root.findall("Account"): + original_fio = account.get("fio") + original_tel = account.get("tel") + if original_fio and original_fio in fio_map: + account.set("fio", fio_map[original_fio]) + if original_tel and original_tel in tel_map: + account.set("tel", tel_map[original_tel]) + + # Сохраняем результат + # Используем собственную сериализацию для сохранения формата + # ElementTree.write может слегка изменять формат, поэтому используем tostring + tree = ET.ElementTree(root) + # Добавляем XML declaration + xml_bytes = ET.tostring(root, encoding="unicode") + result = f'\n{xml_bytes}' + + output_file.write_text(result, encoding="utf-8") + print(f"Готово! Результат сохранён в: {output_file.resolve()}") + print(f" Уникальных ФИО заменено: {len(fio_map)}") + print(f" Уникальных телефонов заменено: {len(tel_map)}") + + # Печатаем часть маппинга для информации + print("\nПримеры замен (первые 10):") + count = 0 + for k, v in fio_map.items(): + if count >= 10: + break + tel_k = list(tel_map.keys())[count] if count < len(tel_map) else None + tel_v = tel_map[tel_k] if tel_k else "" + print(f" ФИО: '{k}' -> '{v}'") + if tel_k: + print(f" Тел: '{tel_k}' -> '{tel_v}'") + count += 1 + + +if __name__ == "__main__": + if len(sys.argv) < 2: + script_name = Path(sys.argv[0]).name + print(f"Использование: python3 {script_name} <входной_xml> [выходной_xml]") + print(f"Пример: python3 {script_name} data.xml data_anon.xml") + sys.exit(1) + + input_path = sys.argv[1] + output_path = sys.argv[2] if len(sys.argv) > 2 else None + anonymize_xml(input_path, output_path) diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index 9198117..341db88 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -1,8 +1,9 @@ import React from "react"; -import { getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow"; +import { getAvailableTransitionsByRole, getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow"; import { demoUsers } from "../../data/mockAppData"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); @@ -31,7 +32,7 @@ const splitItem = (item) => { return { name: "Позиция", quantity: "" }; }; -export const OrderDetailPanel = ({ order, users }) => { +export const OrderDetailPanel = ({ order, users, currentUser, onStatusChange, onAssignDriver }) => { if (!order) { return ( @@ -42,6 +43,10 @@ export const OrderDetailPanel = ({ order, users }) => { const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : []; const orderHistory = Array.isArray(order.history) ? order.history : []; + const role = currentUser?.role; + const availableTransitions = role ? getAvailableTransitionsByRole({ status: order.status, role }) : []; + const drivers = (Array.isArray(users) && users.length ? users : demoUsers).filter((u) => u.role === "driver"); + const canAssignDriver = role === "logistician" || role === "admin"; return (
@@ -169,6 +174,45 @@ export const OrderDetailPanel = ({ order, users }) => { ) : null} + {availableTransitions.length ? ( + + Действия +
+ {availableTransitions.map((status) => ( + + ))} +
+
+ ) : null} + + {canAssignDriver ? ( + + Назначить водителя +
+ {drivers.map((driver) => ( + + ))} + {order.assignedDriverId ? ( + + ) : null} +
+
+ ) : null} + {orderHistory.length ? ( История diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index bb270a9..381e671 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -209,6 +209,10 @@ export const AuthProvider = ({ children }) => { requestOtp, verifyOtp, signOut, + loginAsDemoUser: (demoUser) => { + setUser(demoUser); + setAuthError(""); + }, }; return {children}; diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index a803224..36f9cc6 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -51,6 +51,7 @@ export const DashboardPage = () => { filters, setFilters, updateStatus, + assignDriver, users, isLoading, loadError, @@ -204,7 +205,15 @@ export const DashboardPage = () => { Закрыть
- + + selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name) + } + onAssignDriver={assignDriver} + /> )} diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 6d73a73..a008d4a 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -1,10 +1,15 @@ import React from "react"; import { Navigate } from "react-router-dom"; -import { OtpLoginForm } from "../components/auth/OtpLoginForm"; +import { ROLE_LABELS } from "../constants/roles"; import { useAuth } from "../context/AuthContext"; +import { demoUsers } from "../data/mockAppData"; +import { Button } from "../components/UI/Button"; +import { OtpLoginForm } from "../components/auth/OtpLoginForm"; + +const DEMO_ROLE_ORDER = ["logistician", "driver", "manager", "admin"]; export const LoginPage = () => { - const { user, isOtpSent, isLoading, authError, requestOtp, verifyOtp } = useAuth(); + const { user, isOtpSent, isLoading, authError, isDemoMode, requestOtp, verifyOtp, loginAsDemoUser } = useAuth(); const [email, setEmail] = React.useState(""); const [otp, setOtp] = React.useState(""); const [error, setError] = React.useState(""); @@ -29,12 +34,37 @@ export const LoginPage = () => { setError(""); }; + const handleDemoLogin = (role) => { + const demoUser = demoUsers.find((u) => u.role === role); + if (!demoUser) { + return; + } + + if (isDemoMode) { + setEmail(demoUser.email); + requestOtp({ email: demoUser.email, roleHint: role }).then((requestResponse) => { + if (!requestResponse.success) { + setError(requestResponse.error?.message || "Ошибка демо-входа"); + return; + } + + verifyOtp({ email: demoUser.email, otp: "000000" }).then((verifyResponse) => { + if (!verifyResponse.success) { + setError(verifyResponse.error?.message || "Ошибка демо-входа"); + } + }); + }); + } else { + loginAsDemoUser(demoUser); + } + }; + if (user) { return ; } return ( -
+
{ onVerifyOtp={handleVerifyOtp} error={displayError} /> + + {(isDemoMode || import.meta.env.DEV) ? ( +
+

+ {isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"} +

+
+ {DEMO_ROLE_ORDER.map((role) => { + const demoUser = demoUsers.find((u) => u.role === role); + return ( + + ); + })} +
+
+ ) : null}
); -}; +}; \ No newline at end of file diff --git a/supabase/functions/transfer-to-logistics/index.ts b/supabase/functions/transfer-to-logistics/index.ts index e29482a..99ddc80 100644 --- a/supabase/functions/transfer-to-logistics/index.ts +++ b/supabase/functions/transfer-to-logistics/index.ts @@ -79,11 +79,11 @@ Deno.serve(async (request) => { throw invitationError; } - const { error: updateError } = await supabase + const { error: updateError } = await supabase .from("orders") .update({ status: orderUpdate?.status, - delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus, + delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, }) .eq("id", body.orderId); @@ -91,15 +91,16 @@ Deno.serve(async (request) => { throw updateError; } - const { error: historyError } = await supabase.from("order_history").insert({ + const { error: historyError } = await supabase.from("order_history").insert({ order_id: body.orderId, action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту", old_status: currentOrder.status, new_status: orderUpdate?.status, metadata: { old_delivery_agreement_status: currentOrder.delivery_agreement_status, - new_delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus, + new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, reason: body.reason || null, + note: body.note || null, target_status: targetStatus, }, }); @@ -126,7 +127,7 @@ Deno.serve(async (request) => { ok: true, orderId: body.orderId, status: orderUpdate?.status, - deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus, + deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, }, 200, corsHeaders,