feat(places): remember the Amap id a place was found by

The provider produced amap_poi_id, but nothing stored it, so a saved
Amap place could never be reopened against Amap. The column now travels
with google_place_id and osm_id through the shared contract, the place
insert and update, duplicate matching, the assignment projections, the
trip copy and the three place MCP tools.
This commit is contained in:
Maurice
2026-09-12 13:26:28 +02:00
parent 1944bdf0a5
commit 43a6c30325
10 changed files with 45 additions and 31 deletions
@@ -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
+1
View File
@@ -42,6 +42,7 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
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
+3 -2
View File
@@ -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
+1 -1
View File
@@ -126,7 +126,7 @@ export async function reclaimPhotoCache(cache: PlacePhotoCacheService, googlePla
export interface DedupSet {
names: Set<string>;
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<string>;
}
+13 -10
View File
@@ -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 });
+13 -11
View File
@@ -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<string>();
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
+4 -4
View File
@@ -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);
}
+2
View File
@@ -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;
+2 -1
View File
@@ -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());
}
+4
View File
@@ -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