diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 56af252fe..0021283db 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -60,7 +60,7 @@ export class AssignmentsService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -96,7 +96,7 @@ export class AssignmentsService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index d4a5f2062..800f2c80a 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -42,6 +42,7 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[] google_place_id: a.google_place_id, google_ftid: a.google_ftid, osm_id: a.osm_id, + amap_poi_id: a.amap_poi_id, website: a.website, phone: a.phone, // The rail marks a fuel stop as one; without it here every stop would need diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index c73fc28b8..202394eff 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -91,7 +91,7 @@ export class DaysService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -134,6 +134,7 @@ export class DaysService { google_place_id: a.google_place_id, google_ftid: a.google_ftid, osm_id: a.osm_id, + amap_poi_id: a.amap_poi_id, website: a.website, phone: a.phone, // Hand-built here rather than through `formatAssignmentWithPlace`, so every @@ -172,7 +173,7 @@ export class DaysService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts index 0a3043f00..a22f38242 100644 --- a/server/src/nest/places/places.helpers.ts +++ b/server/src/nest/places/places.helpers.ts @@ -126,7 +126,7 @@ export async function reclaimPhotoCache(cache: PlacePhotoCacheService, googlePla export interface DedupSet { names: Set; coords: Array<{ lat: number; lng: number }>; - /** Provider ids (google_place_id, google_ftid, osm_id) of the places already in the trip. */ + /** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */ externalIds: Set; } diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index bf3dbc4c8..2672b6c64 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -61,7 +61,7 @@ export class PlacesMcp { @Tool({ name: 'create_place', - description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.', + description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, osm_id or amap_poi_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), @@ -73,6 +73,7 @@ export class PlacesMcp { google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'), + amap_poi_id: z.string().optional().describe('Amap (高德地图) POI ID from search_place, amap:-prefixed, enables opening hours on an install using Amap'), notes: z.string().max(2000).optional(), website: placeWebsiteSchema.optional(), phone: z.string().max(50).optional(), @@ -85,9 +86,9 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async createPlace( - { tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type }: { + { tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type }: { tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string; - category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; + category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string; stop_type?: RoadtripStopType; }, @@ -96,7 +97,7 @@ export class PlacesMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); - const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type }); + const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type }); this.guards.safeBroadcast(tripId, 'place:created', { place }); return ok({ place }); } @@ -116,6 +117,7 @@ export class PlacesMcp { google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), + amap_poi_id: z.string().optional().describe('Amap (高德地图) POI ID from search_place, amap:-prefixed'), place_notes: z.string().max(2000).optional().describe('Notes for the place'), website: placeWebsiteSchema.optional(), phone: z.string().max(50).optional(), @@ -129,9 +131,9 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async createAndAssignPlace( - { tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, image_url, assignment_notes, price, currency, stop_type }: { + { tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, place_notes, website, phone, image_url, assignment_notes, price, currency, stop_type }: { tripId: number; dayId: number; name: string; description?: string; lat?: number; lng?: number; address?: string; - category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; + category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; place_notes?: string; website?: string; phone?: string; image_url?: string; assignment_notes?: string; price?: number; currency?: string; stop_type?: RoadtripStopType; }, @@ -143,7 +145,7 @@ export class PlacesMcp { if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; try { const result = this.db.transaction(() => { - const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, image_url, price, currency, stop_type }); + const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type }); const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null); return { place, assignment }; }); @@ -181,6 +183,7 @@ export class PlacesMcp { osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'), + amap_poi_id: z.string().optional().describe('Amap (高德地图) POI ID, amap:-prefixed (e.g. "amap:B000A83M61")'), stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'), fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, @@ -188,13 +191,13 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async updatePlace( - { tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent }: { + { tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }: { tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number; address?: string; category_id?: number; price?: number; currency?: string; place_time?: string | null; end_time?: string | null; duration_minutes?: number | null; notes?: string; website?: string; phone?: string; image_url?: string | null; transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string; - google_place_id?: string; google_ftid?: string; stop_type?: RoadtripStopType | null; + google_place_id?: string; google_ftid?: string; amap_poi_id?: string; stop_type?: RoadtripStopType | null; fill_percent?: number | null; }, ctx: McpContext, @@ -202,7 +205,7 @@ export class PlacesMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); - const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent }); + const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'place:updated', { place }); return ok({ place }); diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index c9229212d..644614f48 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -67,7 +67,7 @@ export interface PlaceCreateInput { category_id?: number; price?: number; currency?: string; place_time?: string; end_time?: string; duration_minutes?: number; notes?: string; image_url?: string; - google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; + google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; website?: string; phone?: string; /** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */ stop_type?: string | null; /** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */ @@ -81,7 +81,7 @@ export interface PlaceUpdateInput { category_id?: number; price?: number; currency?: string; place_time?: string; end_time?: string; duration_minutes?: number; notes?: string; image_url?: string; - google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; + google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; website?: string; phone?: string; /** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */ stop_type?: string | null; /** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */ @@ -232,16 +232,16 @@ export class PlacesService { const { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone, transport_mode, route_geometry, route_color, stop_type, fill_percent, tags = [], } = body; const result = this.dbs.run(` INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone, transport_mode, route_geometry, route_color, stop_type, fill_percent) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, // lat/lng/price/duration_minutes use an explicit undefined check, not `||`: // 0 is a legitimate value for all four (Null Island, a free entry, a @@ -255,7 +255,7 @@ export class PlacesService { tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, price ?? null, currency || null, place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null, - google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', + google_place_id || null, google_ftid || null, osm_id || null, amap_poi_id || null, website || null, phone || null, transport_mode || 'walking', route_geometry || null, route_color || null, stop_type || null, // `?? null` rather than `|| null`, the same reason lat/lng have it: the column is a // percentage and the falsy check would be a silent floor. @@ -323,7 +323,7 @@ export class PlacesService { const { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone, transport_mode, route_color, stop_type, fill_percent, tags, } = body; @@ -345,6 +345,7 @@ export class PlacesService { google_place_id = ?, google_ftid = ?, osm_id = ?, + amap_poi_id = ?, website = ?, phone = ?, transport_mode = COALESCE(?, transport_mode), @@ -372,6 +373,7 @@ export class PlacesService { google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, google_ftid !== undefined ? google_ftid : existingPlace.google_ftid, osm_id !== undefined ? osm_id : existingPlace.osm_id, + amap_poi_id !== undefined ? amap_poi_id : existingPlace.amap_poi_id, website !== undefined ? website : existingPlace.website, phone !== undefined ? phone : existingPlace.phone, transport_mode || null, @@ -518,9 +520,9 @@ export class PlacesService { private buildDedupSet(tripId: string): DedupSet { const rows = this.dbs.all<{ name: string | null; lat: number | null; lng: number | null; - google_place_id: string | null; google_ftid: string | null; osm_id: string | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null; }>( - 'SELECT name, lat, lng, google_place_id, google_ftid, osm_id FROM places WHERE trip_id = ?', tripId, + 'SELECT name, lat, lng, google_place_id, google_ftid, osm_id, amap_poi_id FROM places WHERE trip_id = ?', tripId, ); const names = new Set(); const coords: Array<{ lat: number; lng: number }> = []; @@ -585,10 +587,10 @@ export class PlacesService { if (strategy.by === 'externalId') { hit = this.dbs.get<{ id: number; google_ftid: string | null }>(` SELECT id, google_ftid FROM places - WHERE trip_id = ? AND (google_place_id = ? OR google_ftid = ? OR osm_id = ?) + WHERE trip_id = ? AND (google_place_id = ? OR google_ftid = ? OR osm_id = ? OR amap_poi_id = ?) ORDER BY id ASC LIMIT 1 - `, tripId, strategy.id, strategy.id, strategy.id); + `, tripId, strategy.id, strategy.id, strategy.id, strategy.id); } else if (strategy.by === 'name') { hit = this.dbs.get<{ id: number; google_ftid: string | null }>(` SELECT id, google_ftid FROM places diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index c43158511..0de1d230d 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -552,15 +552,15 @@ export class TripsService { INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, reservation_status, reservation_notes, reservation_datetime, place_time, end_time, duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id, - route_geometry, route_color, stop_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + amap_poi_id, route_geometry, route_color, stop_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const p of oldPlaces) { const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, - p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id, p.route_geometry, p.route_color, - p.stop_type); + p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id, p.amap_poi_id, p.route_geometry, + p.route_color, p.stop_type); placeMap.set(p.id, r.lastInsertRowid); } diff --git a/server/src/types.ts b/server/src/types.ts index 365799e1d..6b566c5fa 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -75,6 +75,7 @@ export interface Place { google_place_id?: string | null; google_ftid?: string | null; osm_id?: string | null; + amap_poi_id?: string | null; route_geometry?: string | null; route_color?: string | null; website?: string | null; @@ -350,6 +351,7 @@ export interface AssignmentRow extends DayAssignment { google_place_id: string | null; google_ftid: string | null; osm_id: string | null; + amap_poi_id: string | null; website: string | null; phone: string | null; stop_type: string | null; diff --git a/shared/src/place/place-match.ts b/shared/src/place/place-match.ts index e81086513..d2dc5650e 100644 --- a/shared/src/place/place-match.ts +++ b/shared/src/place/place-match.ts @@ -35,6 +35,7 @@ export interface PlaceMatchCandidate { google_place_id?: string | null; google_ftid?: string | null; osm_id?: string | null; + amap_poi_id?: string | null; } /** One way to look for an existing place, to be tried in order. */ @@ -51,7 +52,7 @@ export function normalizePlaceName(name: string | null | undefined): string | nu /** The provider ids a candidate carries, trimmed, in provider order, blanks dropped. */ export function externalIdsOf(candidate: PlaceMatchCandidate): string[] { - return [candidate.google_place_id, candidate.google_ftid, candidate.osm_id] + return [candidate.google_place_id, candidate.google_ftid, candidate.osm_id, candidate.amap_poi_id] .filter((id): id is string => typeof id === 'string' && id.trim() !== '') .map((id) => id.trim()); } diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 08f5bd5b2..43db81962 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -135,6 +135,9 @@ export const placeSchema = z.object({ google_place_id: z.string().nullable().optional(), google_ftid: z.string().nullable().optional(), osm_id: z.string().nullable().optional(), + // The Amap (高德) POI id a place was found by, `amap:`-prefixed. null for every + // place that came from another provider. + amap_poi_id: z.string().nullable().optional(), route_geometry: z.string().nullable().optional(), // Manual track colour (#776). null = inherit the category colour like before. route_color: hexColorSchema.nullable().optional(), @@ -199,6 +202,7 @@ export const assignmentPlaceSchema = z.object({ // Carried on the embedded place so the day-plan thumbnail can auto-fetch an // OSM photo the same way the sidebar/inspector do (#1136 follow-up). osm_id: z.string().nullable().optional(), + amap_poi_id: z.string().nullable().optional(), website: z.string().nullable().optional(), phone: z.string().nullable().optional(), // Carried through so the road-trip rail can mark a fuel stop as one without a