455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
import { PrismaClient } from '@prisma/client';
|
|
import { createClient } from '../../app/_utils/supabase/client';
|
|
import { faker } from '@faker-js/faker';
|
|
import { generateIdWithDbCounter } from "../../app/_utils/common";
|
|
import { districtsGeoJson } from '../data/geojson/jember/districts-geojson';
|
|
import { CRegex } from '../../app/_utils/const/regex';
|
|
|
|
export class PatrolUnitsSeeder {
|
|
constructor(
|
|
private prisma: PrismaClient,
|
|
private supabase = createClient()
|
|
) { }
|
|
|
|
async run(): Promise<void> {
|
|
console.log('🚓 Seeding patrol units...');
|
|
|
|
// First, let's clear existing patrol units
|
|
try {
|
|
await this.prisma.patrol_units.deleteMany({});
|
|
// Also delete from Supabase to maintain consistency
|
|
await this.supabase.from('patrol_units').delete().neq('id', 'dummy');
|
|
console.log('✅ Removed existing patrol units');
|
|
} catch (error) {
|
|
console.error('❌ Error removing existing patrol units:', error);
|
|
return; // Exit if we can't clean up properly
|
|
}
|
|
|
|
// Make sure we have a user and event
|
|
const event = await this.ensureEventAndSession();
|
|
if (!event) {
|
|
console.error("❌ Could not create or find event");
|
|
return;
|
|
}
|
|
console.log(`✅ Using event: ${event.id} (${event.name})`);
|
|
|
|
// Get all police units to assign patrol units to
|
|
const policeUnits = await this.prisma.units.findMany({
|
|
select: {
|
|
code_unit: true,
|
|
name: true,
|
|
type: true,
|
|
district_id: true, // Include district_id directly
|
|
},
|
|
});
|
|
|
|
if (!policeUnits.length) {
|
|
console.error('❌ No police units found. Please seed units first.');
|
|
return;
|
|
}
|
|
|
|
// Patrol unit types with proper weighting
|
|
const patrolTypes = ['car', 'motorcycle', 'foot', 'mixed', 'drone'];
|
|
const weightedPatrolTypes = {
|
|
'polres': { car: 40, motorcycle: 30, foot: 10, mixed: 15, drone: 5 },
|
|
'polsek': { car: 30, motorcycle: 40, foot: 20, mixed: 10, drone: 0 },
|
|
'default': { car: 35, motorcycle: 35, foot: 15, mixed: 10, drone: 5 }
|
|
};
|
|
|
|
// Status options with proper weighting
|
|
const statusOptions = ['active', 'standby', 'maintenance', 'patrol', 'on duty', 'off duty'];
|
|
const weightedStatus = {
|
|
'active': 30,
|
|
'standby': 25,
|
|
'maintenance': 5,
|
|
'patrol': 20,
|
|
'on duty': 15,
|
|
'off duty': 5
|
|
};
|
|
|
|
// Define patrol radius ranges based on type
|
|
const getPatrolRadius = (type: string): number => {
|
|
switch (type) {
|
|
case 'car':
|
|
return parseFloat(faker.number.float({ min: 5000, max: 8000, fractionDigits: 2 }).toFixed(2));
|
|
case 'motorcycle':
|
|
return parseFloat(faker.number.float({ min: 3000, max: 5000, fractionDigits: 2 }).toFixed(2));
|
|
case 'foot':
|
|
return parseFloat(faker.number.float({ min: 500, max: 1500, fractionDigits: 2 }).toFixed(2));
|
|
case 'drone':
|
|
return parseFloat(faker.number.float({ min: 2000, max: 4000, fractionDigits: 2 }).toFixed(2));
|
|
case 'mixed':
|
|
default:
|
|
return parseFloat(faker.number.float({ min: 2000, max: 6000, fractionDigits: 2 }).toFixed(2));
|
|
}
|
|
};
|
|
|
|
// Mapping type to code
|
|
const typeCodeMap: Record<string, string> = {
|
|
car: "C",
|
|
motorcycle: "M",
|
|
foot: "F",
|
|
mixed: "X",
|
|
drone: "D",
|
|
};
|
|
|
|
// Get locations for each district to assign to patrol units
|
|
const locationsByDistrict = await this.getLocationsByDistrict();
|
|
|
|
// Generate patrol units for each police unit
|
|
const patrolUnits = [];
|
|
|
|
for (const unit of policeUnits) {
|
|
// Number of patrol units per police unit varies by type
|
|
const patrolCount = unit.type === 'polres' ?
|
|
faker.number.int({ min: 5, max: 8 }) :
|
|
faker.number.int({ min: 2, max: 5 });
|
|
|
|
const unitTypeWeights = weightedPatrolTypes[unit.type as keyof typeof weightedPatrolTypes] ||
|
|
weightedPatrolTypes.default;
|
|
|
|
for (let i = 1; i <= patrolCount; i++) {
|
|
// Select patrol type based on weighted distribution
|
|
const patrolType = this.getWeightedRandomItem(unitTypeWeights) as string;
|
|
|
|
const patrolName = `${unit.name.replace('Polsek', 'Patroli').replace('Polres', 'Patroli')} ${patrolType.charAt(0).toUpperCase() + patrolType.slice(1)
|
|
} ${i}`;
|
|
|
|
const radius = getPatrolRadius(patrolType);
|
|
const status = this.getWeightedRandomItem(weightedStatus) as string;
|
|
|
|
const districtId = unit.district_id;
|
|
if (!districtId) {
|
|
console.log(`⚠️ No district_id for unit ${unit.name}, skipping patrol unit`);
|
|
continue;
|
|
}
|
|
|
|
// Get or create a location for this patrol unit
|
|
const locationId = await this.getOrCreateLocation(districtId, locationsByDistrict, event.id);
|
|
|
|
if (!locationId) {
|
|
console.log(`⚠️ Could not get/create location for patrol unit in ${unit.name}, skipping`);
|
|
continue;
|
|
}
|
|
|
|
const typeCode = typeCodeMap[patrolType] || "P";
|
|
const codeUnitLast2 = unit.code_unit.slice(-2);
|
|
|
|
try {
|
|
const newId = await generateIdWithDbCounter(
|
|
"patrol_units",
|
|
{
|
|
prefix: "PU",
|
|
segments: {
|
|
codes: [typeCode + codeUnitLast2],
|
|
sequentialDigits: 2,
|
|
},
|
|
format: "{prefix}-{codes}{sequence}",
|
|
},
|
|
CRegex.PATROL_UNIT_ID_REGEX
|
|
);
|
|
|
|
patrolUnits.push({
|
|
id: newId,
|
|
unit_id: unit.code_unit,
|
|
location_id: locationId,
|
|
name: patrolName,
|
|
type: patrolType,
|
|
status: status,
|
|
radius: radius,
|
|
});
|
|
} catch (error) {
|
|
console.error(`Error generating ID for patrol unit: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Insert patrol units in smaller batches
|
|
if (patrolUnits.length > 0) {
|
|
await this.insertPatrolUnitsInBatches(patrolUnits);
|
|
console.log(`🚓 Created ${patrolUnits.length} patrol units for ${policeUnits.length} police units`);
|
|
} else {
|
|
console.warn('⚠️ No patrol unit data to insert');
|
|
}
|
|
}
|
|
|
|
// Helper: Ensure we have a user, event and session
|
|
private async ensureEventAndSession(): Promise<any> {
|
|
// Find or create a user
|
|
let user = await this.prisma.users.findFirst({
|
|
where: {
|
|
email: "sigapcompany@gmail.com"
|
|
}
|
|
});
|
|
|
|
if (!user) {
|
|
// Get the system admin role
|
|
const adminRole = await this.prisma.roles.findFirst({
|
|
where: {
|
|
name: "admin"
|
|
}
|
|
});
|
|
|
|
if (!adminRole) {
|
|
console.error("❌ Admin role not found. Please seed roles first.");
|
|
return null;
|
|
}
|
|
|
|
// Create a user if none exists
|
|
try {
|
|
user = await this.prisma.users.create({
|
|
data: {
|
|
email: "sigapcompany@gmail.com",
|
|
roles_id: adminRole.id,
|
|
is_anonymous: false,
|
|
email_confirmed_at: new Date(),
|
|
confirmed_at: new Date()
|
|
}
|
|
});
|
|
console.log("✅ Created user for patrol units");
|
|
} catch (error) {
|
|
console.error("❌ Error creating user:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Find or create an event
|
|
let event = await this.prisma.events.findFirst({
|
|
where: {
|
|
user_id: user.id
|
|
}
|
|
});
|
|
|
|
if (!event) {
|
|
try {
|
|
event = await this.prisma.events.create({
|
|
data: {
|
|
name: "Patrol Operations",
|
|
description: "System-generated event for patrol units",
|
|
user_id: user.id
|
|
}
|
|
});
|
|
console.log("✅ Created event for patrol units");
|
|
|
|
// Create a session for this event
|
|
const session = await this.prisma.sessions.create({
|
|
data: {
|
|
user_id: user.id,
|
|
event_id: event.id,
|
|
status: "active"
|
|
}
|
|
});
|
|
console.log("✅ Created session for patrol units");
|
|
} catch (error) {
|
|
console.error("❌ Error creating event or session:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
// Helper: Get locations organized by district
|
|
private async getLocationsByDistrict(): Promise<Record<string, any[]>> {
|
|
const locationsData = await this.prisma.locations.findMany({
|
|
select: {
|
|
id: true,
|
|
district_id: true,
|
|
latitude: true,
|
|
longitude: true,
|
|
},
|
|
take: 500 // Limit the number of locations to query
|
|
});
|
|
|
|
return locationsData.reduce((acc, location) => {
|
|
if (!acc[location.district_id]) {
|
|
acc[location.district_id] = [];
|
|
}
|
|
acc[location.district_id].push(location);
|
|
return acc;
|
|
}, {} as Record<string, any[]>);
|
|
}
|
|
|
|
// Helper: Get a random coordinate from district GeoJSON
|
|
private getRandomDistrictCoordinate(districtId: string): { latitude: number, longitude: number } | null {
|
|
console.log(`Trying to find coordinates for district ID: ${districtId}`);
|
|
|
|
// Check if districtsGeoJson is properly loaded
|
|
if (!districtsGeoJson || !districtsGeoJson.features || !Array.isArray(districtsGeoJson.features)) {
|
|
console.error("GeoJSON data is missing or malformed:", districtsGeoJson);
|
|
return null;
|
|
}
|
|
|
|
// Try to find the district feature using multiple property checks
|
|
const feature = districtsGeoJson.features.find(f => {
|
|
if (!f.properties) return false;
|
|
|
|
// Try different property names that might contain the district ID
|
|
return (
|
|
f.properties.kode_kec === districtId
|
|
);
|
|
});
|
|
|
|
if (!feature) {
|
|
console.error(`No matching district found for ID: ${districtId}`);
|
|
console.log("Available district properties:", districtsGeoJson.features.slice(0, 2).map(f => f.properties));
|
|
return null;
|
|
}
|
|
|
|
if (!feature.geometry) {
|
|
console.error(`District found but has no geometry: ${districtId}`);
|
|
return null;
|
|
}
|
|
|
|
console.log(`Found district: ${feature.properties?.kecamatan || 'Unknown'}`);
|
|
|
|
// Extract coordinates based on geometry type
|
|
let allCoords: number[][] = [];
|
|
|
|
if (feature.geometry.type === "Polygon") {
|
|
// For Polygon, get all coordinate points from all rings
|
|
allCoords = feature.geometry.coordinates.flat(2);
|
|
}
|
|
else if (feature.geometry.type === "MultiPolygon") {
|
|
// For MultiPolygon, flatten to get all points from all polygons
|
|
// MultiPolygon structure: [[[[x,y,z], [x,y,z]]], [[[x,y,z], [x,y,z]]]]
|
|
allCoords = feature.geometry.coordinates.flat(2);
|
|
}
|
|
|
|
// Filter out any invalid coordinates and handle 3D coordinates (x,y,z)
|
|
const validCoords = allCoords.filter(coord =>
|
|
Array.isArray(coord) &&
|
|
coord.length >= 2 &&
|
|
typeof coord[0] === 'number' &&
|
|
typeof coord[1] === 'number'
|
|
);
|
|
|
|
if (validCoords.length === 0) {
|
|
console.error(`No valid coordinates found in the geometry for district: ${districtId}`);
|
|
console.log("Geometry structure:", JSON.stringify(feature.geometry, null, 2));
|
|
return null;
|
|
}
|
|
|
|
// Get a random coordinate pair, handling 3D coordinates if present
|
|
const randomCoord = validCoords[Math.floor(Math.random() * validCoords.length)];
|
|
// Get longitude (x) and latitude (y) from the coordinate
|
|
const lng = randomCoord[0];
|
|
const lat = randomCoord[1];
|
|
|
|
console.log(`Generated coordinates: ${lat}, ${lng} for district ${districtId}`);
|
|
return { latitude: lat, longitude: lng };
|
|
}
|
|
|
|
// Helper: Get or create a location for the patrol unit
|
|
private async getOrCreateLocation(
|
|
districtId: string,
|
|
locationsByDistrict: Record<string, any[]>,
|
|
eventId?: string
|
|
): Promise<string | undefined> {
|
|
// Try to use an existing location for this district
|
|
const districtLocations = locationsByDistrict[districtId] || [];
|
|
|
|
if (districtLocations.length > 0) {
|
|
const randomLocation = faker.helpers.arrayElement(districtLocations);
|
|
return randomLocation.id;
|
|
}
|
|
|
|
// Find the event to use
|
|
if (!eventId) {
|
|
const event = await this.prisma.events.findFirst();
|
|
if (!event) {
|
|
console.error("❌ No event found. Cannot create location.");
|
|
return undefined;
|
|
}
|
|
eventId = event.id;
|
|
}
|
|
|
|
// Generate a new location using districtGeoJson if no existing locations
|
|
const coord = this.getRandomDistrictCoordinate(districtId);
|
|
|
|
if (!coord) {
|
|
console.warn(`Could not generate coordinates from GeoJSON for district ${districtId}, using fallback...`);
|
|
return undefined;
|
|
}
|
|
|
|
// Create location in both databases for consistency
|
|
try {
|
|
const newLocation = {
|
|
district_id: districtId,
|
|
event_id: eventId,
|
|
address: `Generated Patrol Location, District ${districtId}`,
|
|
type: "patrol",
|
|
latitude: coord.latitude,
|
|
longitude: coord.longitude,
|
|
land_area: null,
|
|
location: `POINT(${coord.longitude} ${coord.latitude})`,
|
|
};
|
|
|
|
// Insert to both databases for consistency
|
|
const { data, error } = await this.supabase
|
|
.from("locations")
|
|
.insert([newLocation])
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
console.error("Failed to insert location to Supabase:", error);
|
|
return undefined;
|
|
}
|
|
|
|
// Update our local cache
|
|
if (!locationsByDistrict[districtId]) {
|
|
locationsByDistrict[districtId] = [];
|
|
}
|
|
locationsByDistrict[districtId].push({ ...newLocation, id: data.id });
|
|
|
|
return data.id;
|
|
} catch (err) {
|
|
console.error("Failed to create location:", err);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Helper: Insert patrol units in batches with better error handling
|
|
private async insertPatrolUnitsInBatches(patrolUnits: any[]): Promise<void> {
|
|
const batchSize = 50; // Smaller batch size for better reliability
|
|
|
|
for (let i = 0; i < patrolUnits.length; i += batchSize) {
|
|
const batch = patrolUnits.slice(i, i + batchSize);
|
|
try {
|
|
// Insert to Supabase
|
|
const { error } = await this.supabase
|
|
.from('patrol_units')
|
|
.insert(batch);
|
|
|
|
if (error) {
|
|
console.error(`Error inserting patrol units batch ${Math.floor(i / batchSize) + 1}:`, error);
|
|
}
|
|
|
|
// Small delay between batches to avoid rate limiting
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
} catch (err) {
|
|
console.error(`Exception when inserting patrol units batch ${Math.floor(i / batchSize) + 1}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: Get a weighted random item from a weighted object
|
|
private getWeightedRandomItem(weightedItems: Record<string, number>): string | number {
|
|
const entries = Object.entries(weightedItems);
|
|
const weights = entries.map(([_, weight]) => weight);
|
|
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
|
|
let random = Math.random() * totalWeight;
|
|
|
|
for (const [item, weight] of entries) {
|
|
random -= weight;
|
|
if (random < 0) {
|
|
return item;
|
|
}
|
|
}
|
|
|
|
// Fallback to first item if something goes wrong
|
|
return entries[0][0];
|
|
}
|
|
} |