diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 6c2d62b..952741f 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -218,7 +218,9 @@ export function GpsMap({ } return ( -
+ // isolate создаёт stacking context: z-index'ы Leaflet-панов (до 1000) + // не выныривают поверх диалогов shadcn (z-50) снаружи карты +
{provider === 'yandex' ? ( {asset.externalId || '—'} - {formatDateTime(asset.lastSeenAt)} + +
{formatDateTime(asset.lastSeenAt)}
+ {/* Время точки по устройству, если сильно отстаёт от приёма сервером */} + {asset.lastSeenAt && + asset.lastRecordedAt && + Math.abs(new Date(asset.lastSeenAt).getTime() - new Date(asset.lastRecordedAt).getTime()) > + 3_600_000 && ( +
Точка от: {formatDateTime(asset.lastRecordedAt)}
+ )} +
{(asset.groups || []).map((g) => ( diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx index 46a4fb6..b5b3c67 100644 --- a/client/src/pages/gps/MapTab.tsx +++ b/client/src/pages/gps/MapTab.tsx @@ -2,18 +2,34 @@ import { useEffect, useMemo, useState } from 'react'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { X, Route } from 'lucide-react'; -import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap'; +import { X, Route, Ruler } from 'lucide-react'; +import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap'; import { AssetTreePanel } from '@/components/gps/AssetTreePanel'; import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps'; import { useToast } from '@/hooks/use-toast'; import type { GpsAsset, GpsPosition } from './types'; -function formatDateTime(value: string | null): string { +function formatDateTime(value: string | null | undefined): string { if (!value) return '—'; return new Date(value).toLocaleString('ru-RU'); } +/** Расстояние между двумя точками в км (формула гаверсинуса) */ +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +/** Форматирование расстояния линейки: «1.24 км» / «850 м» */ +function formatDistance(km: number): string { + return km >= 1 ? `${km.toFixed(2)} км` : `${Math.round(km * 1000)} м`; +} + /** Вид карты: либо автоподгонка по bounds, либо центрирование на точке */ interface MapView { bounds?: [[number, number], [number, number]]; @@ -23,7 +39,7 @@ interface MapView { /** * Таб «Карта»: слева панель-дерево объектов (группы/поиск/фильтр), - * справа — текущие позиции на карте, геозоны, попап объекта. + * справа — текущие позиции на карте, геозоны, попап объекта, линейка. */ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) { const { toast } = useToast(); @@ -76,6 +92,38 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v } }, [positions, selected]); + // === Линейка: измерение расстояния кликами по карте === + const [rulerMode, setRulerMode] = useState(false); + const [rulerPoints, setRulerPoints] = useState>([]); + + const toggleRuler = () => { + setRulerMode((m) => !m); + setRulerPoints([]); + setSelected(null); + }; + + // Выход из режима линейки по Esc + useEffect(() => { + if (!rulerMode) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setRulerMode(false); + setRulerPoints([]); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [rulerMode]); + + // Суммарное расстояние по точкам линейки + const rulerDistanceKm = useMemo(() => { + let total = 0; + for (let i = 1; i < rulerPoints.length; i++) { + total += haversineKm(rulerPoints[i - 1][0], rulerPoints[i - 1][1], rulerPoints[i][0], rulerPoints[i][1]); + } + return total; + }, [rulerPoints]); + // Клик по объекту в дереве: центрируем карту и открываем попап; // объект без координат — только подсказка const handleSelectAsset = (asset: GpsAsset) => { @@ -91,17 +139,33 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v const selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null; const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null; - // Маркеры объектов и полигоны геозон для провайдер-агностичной карты - const mapMarkers: GpsMapMarker[] = visiblePositions.map((p) => ({ - id: p.assetId, - lat: p.lat as number, - lng: p.lng as number, - color: p.color, - kind: p.type, - label: p.name, - offline: !p.isOnline, - onClick: () => setSelected(p), - })); + // Маркеры объектов, полигоны геозон и элементы линейки для карты. + // В режиме линейки клики по маркерам объектов отключены — клики идут в линейку. + const mapMarkers: GpsMapMarker[] = [ + ...visiblePositions.map((p) => ({ + id: p.assetId, + lat: p.lat as number, + lng: p.lng as number, + color: p.color, + kind: p.type, + label: p.name, + offline: !p.isOnline, + onClick: rulerMode ? undefined : () => setSelected(p), + })), + ...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({ + id: `ruler-${idx}`, + lat, + lng, + kind: 'dot', + color: '#3b82f6', + noCluster: true, + zIndex: 15, + // У последней точки линейки — суммарное расстояние + ...(idx === rulerPoints.length - 1 && rulerPoints.length > 1 + ? { label: formatDistance(rulerDistanceKm) } + : {}), + })), + ]; const mapPolygons: GpsMapPolygon[] = geozones.map((g) => ({ id: `gz-${g.id}`, @@ -109,6 +173,16 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v color: g.color, })); + const mapPolylines: GpsMapPolyline[] = + rulerPoints.length >= 2 ? [{ id: 'ruler', points: rulerPoints, color: '#3b82f6' }] : []; + + // Время в попапе: «Обновлено» — приём точки сервером (реальная активность); + // «Точка от» — время по устройству, если отстаёт больше чем на час + const seenAt = selectedLive ? selectedLive.lastSeenAt ?? selectedLive.recordedAt : null; + const recordedAt = selectedLive?.lastRecordedAt ?? null; + const recordedStale = + seenAt && recordedAt && Math.abs(new Date(seenAt).getTime() - new Date(recordedAt).getTime()) > 3_600_000; + return (
v />
-
- Объектов на карте: {visiblePositions.length} +
+ + {rulerMode ? ( + + Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход + + ) : ( + + Объектов на карте: {visiblePositions.length} + + )}
v zoom={view.zoom ?? 10} markers={mapMarkers} polygons={mapPolygons} + polylines={mapPolylines} + onMapClick={rulerMode ? (lat, lng) => setRulerPoints((p) => [...p, [lat, lng]]) : undefined} className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border" /> @@ -162,7 +255,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v Скорость:{' '} {selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
-
Последняя точка: {formatDateTime(selectedLive.recordedAt)}
+
Обновлено: {formatDateTime(seenAt)}
+ {recordedStale &&
Точка от: {formatDateTime(recordedAt)}
} {selectedAsset?.externalId &&
Трекер: {selectedAsset.externalId}
}
+
@@ -246,6 +304,19 @@ export function GpsRoutesTab() { className="h-[55vh] min-h-[350px] w-full rounded-md overflow-hidden border border-border" /> + {/* Легенда раскраски трека по скорости */} + {colorizeEnabled && ( +
+ {SPEED_STOPS.map((s) => ( + + + {s.label} + + ))} + км/ч +
+ )} + {applied && !isFetching && points.length === 0 && (

За выбранный период точек нет diff --git a/client/src/pages/gps/SettingsTab.tsx b/client/src/pages/gps/SettingsTab.tsx new file mode 100644 index 0000000..e25f02a --- /dev/null +++ b/client/src/pages/gps/SettingsTab.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Loader2, Save, SlidersHorizontal } from 'lucide-react'; +import { apiRequest } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; + +interface GpsSettings { + success: boolean; + jitterMeters: number; +} + +/** + * Таб «Настройки» GPS-модуля. + * Секция «Трекинг»: фильтр дрожания позиции (джиттер) — сервер не записывает + * точки ближе N метров при скорости < 5 км/ч. Сюда же позже добавятся + * другие настройки модуля — структура секций-Card под это готова. + */ +export function GpsSettingsTab() { + const { toast } = useToast(); + const { data, isLoading } = useQuery({ + queryKey: ['/api/gps/settings'], + }); + + const [jitterMeters, setJitterMeters] = useState('0'); + + useEffect(() => { + if (data) setJitterMeters(String(data.jitterMeters)); + }, [data]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const value = Number(jitterMeters); + const res = await apiRequest('PUT', '/api/gps/settings', { jitterMeters: value }); + return res.json(); + }, + onSuccess: () => { + toast({ title: 'Настройки сохранены' }); + }, + onError: (err: any) => { + toast({ + title: 'Ошибка', + description: err?.message || 'Не удалось сохранить настройки', + variant: 'destructive', + }); + }, + }); + + const jitterValid = /^\d+$/.test(jitterMeters.trim()) && Number(jitterMeters) >= 0 && Number(jitterMeters) <= 1000; + + return ( +

+ + + + + Трекинг + + + + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ + setJitterMeters(e.target.value)} + /> +

+ 0 = выключен. Точки ближе N метров при скорости < 5 км/ч не записываются — убирает + «кучу мелких перемещений» у стоящего объекта. Действует на новые точки. +

+ {!jitterValid && ( +

Целое число от 0 до 1000

+ )} +
+ + + )} +
+
+
+ ); +} diff --git a/client/src/pages/gps/index.tsx b/client/src/pages/gps/index.tsx index 9f8ec83..9d2542f 100644 --- a/client/src/pages/gps/index.tsx +++ b/client/src/pages/gps/index.tsx @@ -1,6 +1,6 @@ import { useLocation } from 'wouter'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Map, Truck, Hexagon, Route, Folder, BookOpen } from 'lucide-react'; +import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings } from 'lucide-react'; import Layout from '@/components/Layout'; import { GpsMapTab } from './MapTab'; import { GpsAssetsTab } from './AssetsTab'; @@ -8,6 +8,7 @@ import { GpsGeozonesTab } from './GeozonesTab'; import { GpsRoutesTab } from './RoutesTab'; import { GpsGroupsTab } from './GroupsTab'; import { GpsGuidesTab } from './GuidesTab'; +import { GpsSettingsTab } from './SettingsTab'; const TABS = [ { value: 'map', label: 'Карта', icon: Map }, @@ -16,6 +17,7 @@ const TABS = [ { value: 'routes', label: 'Маршруты', icon: Route }, { value: 'groups', label: 'Группы', icon: Folder }, { value: 'guides', label: 'Инструкции', icon: BookOpen }, + { value: 'settings', label: 'Настройки', icon: Settings }, ]; /** @@ -62,6 +64,9 @@ export default function GpsPage() { + + +
diff --git a/client/src/pages/gps/types.ts b/client/src/pages/gps/types.ts index 1fab008..8ef7edb 100644 --- a/client/src/pages/gps/types.ts +++ b/client/src/pages/gps/types.ts @@ -20,6 +20,8 @@ export interface GpsAsset { lastSpeed: number | null; lastCourse: number | null; lastSeenAt: string | null; + /** Время последней точки по устройству (может быть старым без GPS-фикса) */ + lastRecordedAt?: string | null; createdAt: string | null; /** Группы объекта (добавляет GET /api/gps/assets) */ groups?: Array<{ id: number; name: string }>; @@ -38,7 +40,12 @@ export interface GpsPosition { lng: number | null; speed: number | null; course: number | null; + /** Время приёма последней точки сервером (= реальная активность объекта) */ recordedAt: string | null; + /** Время приёма последней точки сервером */ + lastSeenAt?: string | null; + /** Время последней точки по устройству (может быть старым без GPS-фикса) */ + lastRecordedAt?: string | null; } /** Точка трека (GET /api/gps/assets/:id/track) */ diff --git a/migrations/0070_gps_settings.sql b/migrations/0070_gps_settings.sql new file mode 100644 index 0000000..87c2480 --- /dev/null +++ b/migrations/0070_gps_settings.sql @@ -0,0 +1,11 @@ +-- GPS-модуль: настройки организации (фильтр джиттера) и время последней точки по устройству. +-- last_seen_at в gps_assets теперь всегда = время ПРИЁМА точки сервером (онлайн-индикатор), +-- а last_recorded_at хранит время точки по устройству (fixTime трекера). + +ALTER TABLE gps_assets ADD COLUMN IF NOT EXISTS last_recorded_at TIMESTAMP; + +CREATE TABLE IF NOT EXISTS gps_settings ( + organization_id INTEGER PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE, + jitter_meters INTEGER NOT NULL DEFAULT 25, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index 24cfed1..bef56b2 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -31,6 +31,18 @@ export function pointInPolygon(lat: number, lng: number, polygon: Array<[number, return inside; } +/** Расстояние между двумя точками (haversine), в метрах. Без зависимостей. */ +export function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371000; // радиус Земли, м + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + export interface ProcessPositionInput { lat: number; lng: number; @@ -43,6 +55,16 @@ export interface ProcessPositionInput { /** * Основная точка входа: сохраняет позицию, обновляет last_* объекта, * проверяет геозоны и рассылает SSE/уведомления. + * + * Семантика времени: + * - last_seen_at = ВСЕГДА время ПРИЁМА сервером (онлайн-индикатор на карте); + * - last_recorded_at = время точки по устройству (fixTime трекера); + * - gps_positions.recorded_at = время устройства (для треков). + * + * Фильтр джиттера: если в настройках jitterMeters > 0, у объекта есть + * предыдущая точка, расстояние до неё < jitterMeters и скорость мала + * (null или < 5 км/ч) — точка отбрасывается: не пишется в gps_positions, + * координаты и геозоны не трогаются, но last_seen_at/is_online обновляются. */ export async function processPosition( assetId: number, @@ -51,7 +73,26 @@ export async function processPosition( ): Promise { const recordedAt = input.recordedAt ?? new Date(); - // 1. Сохраняем точку в историю + const asset = await gpsStorage.getAsset(assetId, organizationId); + if (!asset) return; + + // Фильтр джиттера (округление мелких перемещений) + const settings = await gpsStorage.getSettings(organizationId); + if ( + settings.jitterMeters > 0 && + asset.lastLat !== null && + asset.lastLng !== null + ) { + const distance = haversineMeters(asset.lastLat, asset.lastLng, input.lat, input.lng); + const speed = input.speed ?? null; + if (distance < settings.jitterMeters && (speed === null || speed < 5)) { + // Устройство живо — фиксируем факт приёма, точку не сохраняем + await gpsStorage.touchAssetSeen(assetId); + return; + } + } + + // 1. Сохраняем точку в историю (recorded_at — время устройства, для треков) await gpsStorage.insertPosition({ assetId, organizationId, @@ -63,7 +104,7 @@ export async function processPosition( recordedAt, }); - // 2. Обновляем последнюю позицию объекта (и переводим в online) + // 2. Обновляем последнюю позицию объекта (last_seen_at = время приёма, online) await gpsStorage.updateAssetLastPosition(assetId, { lat: input.lat, lng: input.lng, @@ -88,21 +129,19 @@ export async function processPosition( }); // 4. Проверка геозон - await checkGeozones(assetId, organizationId, input, recordedAt); + await checkGeozones(asset, input, recordedAt); } async function checkGeozones( - assetId: number, - organizationId: number, + asset: GpsAsset, input: ProcessPositionInput, recordedAt: Date ): Promise { + const { id: assetId, organizationId } = asset; const geozones = await gpsStorage.listActiveGeozones(organizationId); if (geozones.length === 0) return; const states = await gpsStorage.getGeozoneStatesForAsset(assetId); - const asset = await gpsStorage.getAsset(assetId, organizationId); - if (!asset) return; for (const geozone of geozones) { try { diff --git a/server/gps/routes.ts b/server/gps/routes.ts index 7340295..299d4dd 100644 --- a/server/gps/routes.ts +++ b/server/gps/routes.ts @@ -167,6 +167,24 @@ export function registerGpsRoutes(app: Express) { res.json({ success: true, yandexMapsKey }); }); + // ===================== + // Настройки GPS организации (фильтр джиттера) + // ===================== + + router.get("/settings", async (req: AuthenticatedRequest, res: Response) => { + const settings = await gpsStorage.getSettings(req.user!.organizationId); + res.json({ success: true, jitterMeters: settings.jitterMeters }); + }); + + router.put("/settings", async (req: AuthenticatedRequest, res: Response) => { + const jitterMeters = Number(req.body?.jitterMeters); + if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) { + return badRequest(res, "jitterMeters: целое число от 0 до 1000"); + } + const row = await gpsStorage.upsertSettings(req.user!.organizationId, jitterMeters); + res.json({ success: true, jitterMeters: row.jitterMeters }); + }); + // ===================== // Объекты (assets) // ===================== @@ -267,7 +285,9 @@ export function registerGpsRoutes(app: Express) { lng: a.lastLng, speed: a.lastSpeed, course: a.lastCourse, - recordedAt: a.lastSeenAt, + recordedAt: a.lastSeenAt, // время приёма последней точки сервером + lastSeenAt: a.lastSeenAt, + lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству })), }); }); diff --git a/server/gps/storage.ts b/server/gps/storage.ts index c1b214a..faf2b3e 100644 --- a/server/gps/storage.ts +++ b/server/gps/storage.ts @@ -8,12 +8,14 @@ import { gpsGroups, gpsGroupAssets, gpsAssetSubscribers, + gpsSettings, type GpsAsset, type GpsPosition, type GpsGeozone, type GpsGeozoneEvent, type GpsGroup, type GpsAssetSubscriber, + type GpsSettings, type InsertGpsAsset, type InsertGpsGeozone, type InsertGpsPosition, @@ -140,7 +142,11 @@ export class GpsStorage { return deleted.length > 0; } - /** Обновление последней позиции объекта (и перевод в online) */ + /** + * Обновление последней позиции объекта (и перевод в online). + * last_seen_at = время ПРИЁМА сервером (new Date() здесь), + * last_recorded_at = время точки по устройству (recordedAt). + */ async updateAssetLastPosition( assetId: number, data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date } @@ -152,12 +158,24 @@ export class GpsStorage { lastLng: data.lng, lastSpeed: data.speed ?? null, lastCourse: data.course ?? null, - lastSeenAt: data.recordedAt, + lastSeenAt: new Date(), + lastRecordedAt: data.recordedAt, isOnline: true, }) .where(eq(gpsAssets.id, assetId)); } + /** + * Отметка «устройство живо» без обновления координат + * (точка отброшена фильтром джиттера, но приём был). + */ + async touchAssetSeen(assetId: number): Promise { + await db + .update(gpsAssets) + .set({ lastSeenAt: new Date(), isOnline: true }) + .where(eq(gpsAssets.id, assetId)); + } + async setAssetOnline(assetId: number, isOnline: boolean): Promise { await db.update(gpsAssets).set({ isOnline }).where(eq(gpsAssets.id, assetId)); } @@ -438,6 +456,31 @@ export class GpsStorage { ) .returning(); } + + // ===================== + // Настройки организации + // ===================== + + /** Настройки GPS организации; если строки нет — дефолт */ + async getSettings(organizationId: number): Promise<{ jitterMeters: number }> { + const [row] = await db + .select() + .from(gpsSettings) + .where(eq(gpsSettings.organizationId, organizationId)); + return { jitterMeters: row?.jitterMeters ?? 25 }; + } + + async upsertSettings(organizationId: number, jitterMeters: number): Promise { + const [row] = await db + .insert(gpsSettings) + .values({ organizationId, jitterMeters, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: gpsSettings.organizationId, + set: { jitterMeters, updatedAt: new Date() }, + }) + .returning(); + return row; + } } export const gpsStorage = new GpsStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index bc16360..7bfe34b 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -3988,7 +3988,8 @@ export const gpsAssets = pgTable("gps_assets", { lastLng: doublePrecision("last_lng"), lastSpeed: doublePrecision("last_speed"), lastCourse: doublePrecision("last_course"), - lastSeenAt: timestamp("last_seen_at"), + lastSeenAt: timestamp("last_seen_at"), // время ПРИЁМА последней точки сервером (онлайн-индикатор) + lastRecordedAt: timestamp("last_recorded_at"), // время последней точки по устройству (fixTime) createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("gps_assets_org_idx").on(table.organizationId), @@ -4106,3 +4107,14 @@ export const gpsAssetSubscribers = pgTable("gps_asset_subscribers", { export type GpsAssetSubscriber = typeof gpsAssetSubscribers.$inferSelect; export type InsertGpsAssetSubscriber = typeof gpsAssetSubscribers.$inferInsert; + + +// Настройки GPS-модуля организации (фильтр джиттера и т.п.) +export const gpsSettings = pgTable("gps_settings", { + organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }), + jitterMeters: integer("jitter_meters").notNull().default(25), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export type GpsSettings = typeof gpsSettings.$inferSelect; +export type InsertGpsSettings = typeof gpsSettings.$inferInsert;