feat: admin tool for manual Google Place ID matching
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

New page /admin/gbp-audit/match-places shows companies without
google_place_id. Admin searches Google Maps, reviews results, and
confirms the correct match. Adds search_places_raw() to return all
results without name filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-13 12:57:54 +01:00
parent 76ae1d4fd0
commit d3fa2d7516
4 changed files with 439 additions and 0 deletions

View File

@ -1059,6 +1059,143 @@ def admin_gbp_audit_batch_discard():
}) })
# ============================================================
# GBP PLACE ID MATCHING (manual review)
# ============================================================
@bp.route('/gbp-audit/match-places')
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_match_places():
"""Show companies without google_place_id for manual matching."""
if not is_audit_owner():
abort(404)
db = SessionLocal()
try:
# Get companies without place_id
subq = db.query(CompanyWebsiteAnalysis.company_id).filter(
CompanyWebsiteAnalysis.google_place_id.isnot(None)
).subquery()
companies = db.query(
Company.id, Company.name, Company.address_city, Company.website
).filter(
Company.status == 'active',
~Company.id.in_(subq)
).order_by(Company.name).all()
return render_template('admin/gbp_match_places.html', companies=companies)
finally:
db.close()
@bp.route('/gbp-audit/search-place', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_search_place():
"""Search Google Places for a company (no name filter — raw results)."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
company_id = request.form.get('company_id', type=int)
if not company_id:
return jsonify({'error': 'Brak company_id'}), 400
db = SessionLocal()
try:
company = db.get(Company, company_id)
if not company:
return jsonify({'error': 'Firma nie znaleziona'}), 404
try:
from google_places_service import GooglePlacesService
places_service = GooglePlacesService()
except (ImportError, ValueError) as e:
return jsonify({'error': f'Places API niedostępne: {e}'}), 500
city = company.address_city or 'Wejherowo'
query = f'{company.name} {city}'
location_bias = {'latitude': 54.6059, 'longitude': 18.2350, 'radius': 50000.0}
# Search via raw API call — return ALL results for manual review
results = places_service.search_places_raw(query, location_bias=location_bias)
if not results:
# Try broader search with just company name
results = places_service.search_places_raw(company.name, location_bias=location_bias)
if not results:
return jsonify({'results': [], 'query': query})
places = []
for p in results:
place_id = p.get('id', '')
if place_id.startswith('places/'):
place_id = place_id.replace('places/', '')
places.append({
'place_id': place_id,
'name': p.get('displayName', {}).get('text', ''),
'address': p.get('formattedAddress', ''),
'types': ', '.join((p.get('types') or [])[:3]),
'rating': p.get('rating'),
'reviews_count': p.get('userRatingCount'),
})
return jsonify({'results': places, 'query': query})
finally:
db.close()
@bp.route('/gbp-audit/confirm-place', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_confirm_place():
"""Save confirmed google_place_id for a company."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
company_id = request.form.get('company_id', type=int)
place_id = request.form.get('place_id', '').strip()
google_name = request.form.get('google_name', '').strip()
if not company_id or not place_id:
return jsonify({'error': 'Brak company_id lub place_id'}), 400
db = SessionLocal()
try:
company = db.get(Company, company_id)
if not company:
return jsonify({'error': 'Firma nie znaleziona'}), 404
analysis = db.query(CompanyWebsiteAnalysis).filter(
CompanyWebsiteAnalysis.company_id == company_id
).first()
if not analysis:
analysis = CompanyWebsiteAnalysis(
company_id=company_id,
url=company.website,
analyzed_at=datetime.now()
)
db.add(analysis)
analysis.google_place_id = place_id
if google_name:
analysis.google_name = google_name
analysis.analyzed_at = datetime.now()
db.commit()
logger.info(f"Place ID confirmed for company {company_id} ({company.name}): {place_id}")
return jsonify({'status': 'ok', 'message': f'Place ID zapisany dla {company.name}'})
except Exception as e:
db.rollback()
logger.error(f"Failed to confirm place_id for company {company_id}: {e}")
return jsonify({'error': str(e)[:100]}), 500
finally:
db.close()
# ============================================================ # ============================================================
# DIGITAL MATURITY DASHBOARD # DIGITAL MATURITY DASHBOARD
# ============================================================ # ============================================================

View File

@ -280,6 +280,41 @@ class GooglePlacesService:
logger.error(f"Places search error for '{query}': {e}") logger.error(f"Places search error for '{query}': {e}")
return None return None
def search_places_raw(self, query: str, location_bias: Dict = None) -> List[Dict[str, Any]]:
"""
Search for places and return ALL results (no name filtering).
Used for manual review/matching in admin panel.
"""
body = {
"textQuery": query,
"languageCode": "pl",
"maxResultCount": 5
}
if location_bias:
body["locationBias"] = {
"circle": {
"center": {
"latitude": location_bias["latitude"],
"longitude": location_bias["longitude"]
},
"radius": location_bias.get("radius", 5000.0)
}
}
field_mask = ','.join(f'places.{f}' for f in [
'id', 'displayName', 'formattedAddress', 'types',
'rating', 'userRatingCount', 'googleMapsUri'
])
headers = {'X-Goog-FieldMask': field_mask}
try:
response = self.session.post(PLACES_SEARCH_URL, json=body, headers=headers, timeout=15)
response.raise_for_status()
return response.json().get('places', [])
except requests.exceptions.RequestException as e:
logger.error(f"Places raw search error for '{query}': {e}")
return []
def search_nearby(self, latitude: float, longitude: float, def search_nearby(self, latitude: float, longitude: float,
radius: float = 5000.0, radius: float = 5000.0,
included_types: List[str] = None, included_types: List[str] = None,

View File

@ -450,6 +450,13 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<a href="{{ url_for('admin.admin_gbp_match_places') }}" class="btn btn-outline btn-sm" title="Dopasuj firmy bez Place ID do Google Maps">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Dopasuj Place ID
</a>
<button id="gbpBatchBtn" class="btn btn-primary btn-sm" onclick="startGbpBatch()" title="Uruchom audyt GBP dla wszystkich firm"> <button id="gbpBatchBtn" class="btn btn-primary btn-sm" onclick="startGbpBatch()" title="Uruchom audyt GBP dla wszystkich firm">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>

View File

@ -0,0 +1,260 @@
{% extends "base.html" %}
{% block title %}Dopasuj firmy do Google Maps - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.match-page { max-width: 1100px; margin: 0 auto; }
.back-link {
display: inline-flex; align-items: center; gap: var(--spacing-xs);
color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.back-link:hover { color: var(--primary); }
.match-header {
margin-bottom: var(--spacing-xl);
}
.match-header h1 { font-size: var(--font-size-2xl); color: var(--text-primary); margin-bottom: var(--spacing-xs); }
.match-header p { color: var(--text-secondary); }
.company-card {
background: white; border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-md); overflow: hidden;
}
.company-card.matched { opacity: 0.5; }
.company-header {
display: flex; justify-content: space-between; align-items: center;
padding: var(--spacing-md) var(--spacing-lg);
cursor: default;
}
.company-info { flex: 1; }
.company-name { font-weight: 600; font-size: var(--font-size-lg); color: var(--text-primary); }
.company-city { font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: 2px; }
.btn-search {
background: var(--primary); color: white; border: none; padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md); cursor: pointer; font-size: var(--font-size-sm); font-weight: 500;
white-space: nowrap;
}
.btn-search:hover { opacity: 0.9; }
.btn-search:disabled { background: var(--secondary); cursor: not-allowed; }
.btn-skip {
background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md); cursor: pointer; font-size: var(--font-size-sm);
margin-left: var(--spacing-sm);
}
.btn-skip:hover { background: var(--bg-secondary); }
.search-results {
padding: 0 var(--spacing-lg) var(--spacing-lg);
display: none;
}
.search-results.visible { display: block; }
.search-loading {
text-align: center; padding: var(--spacing-lg); color: var(--text-secondary);
}
.place-result {
display: flex; justify-content: space-between; align-items: center;
padding: var(--spacing-md); border: 1px solid var(--border-color);
border-radius: var(--radius-md); margin-bottom: var(--spacing-sm);
gap: var(--spacing-md);
}
.place-result:hover { border-color: var(--primary); background: var(--bg-primary); }
.place-info { flex: 1; }
.place-name { font-weight: 600; color: var(--text-primary); }
.place-address { font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: 2px; }
.place-meta { font-size: var(--font-size-xs); color: var(--text-tertiary); margin-top: 4px; }
.place-meta .rating { color: #f59e0b; font-weight: 600; }
.btn-confirm {
background: #16a34a; color: white; border: none; padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md); cursor: pointer; font-size: var(--font-size-sm); font-weight: 500;
white-space: nowrap;
}
.btn-confirm:hover { background: #15803d; }
.btn-confirm:disabled { background: var(--secondary); cursor: not-allowed; }
.no-results {
text-align: center; padding: var(--spacing-lg); color: var(--text-secondary);
font-style: italic;
}
.match-badge {
display: inline-block; background: #16a34a; color: white; font-size: var(--font-size-xs);
padding: 2px 8px; border-radius: var(--radius-full); font-weight: 600;
}
.counter {
background: var(--bg-secondary); padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl);
display: flex; gap: var(--spacing-xl); flex-wrap: wrap;
}
.counter-item { font-size: var(--font-size-sm); }
.counter-value { font-weight: 700; font-size: var(--font-size-lg); }
</style>
{% endblock %}
{% block content %}
<div class="match-page">
<a href="{{ url_for('admin.admin_gbp_audit') }}" class="back-link">&larr; GBP Audit Dashboard</a>
<div class="match-header">
<h1>Dopasuj firmy do Google Maps</h1>
<p>{{ companies|length }} firm bez identyfikatora Google Place ID. Wyszukaj i potwierdz dopasowanie.</p>
</div>
<div class="counter">
<div class="counter-item">Pozostalo: <span class="counter-value" id="remainingCount">{{ companies|length }}</span></div>
<div class="counter-item">Dopasowano: <span class="counter-value" id="matchedCount" style="color: #16a34a;">0</span></div>
</div>
{% for c in companies %}
<div class="company-card" id="card-{{ c.id }}" data-company-id="{{ c.id }}">
<div class="company-header">
<div class="company-info">
<div class="company-name">{{ c.name }}</div>
<div class="company-city">{{ c.address_city or 'brak miasta' }}{% if c.website %} &middot; {{ c.website }}{% endif %}</div>
</div>
<div>
<button class="btn-search" onclick="searchPlace({{ c.id }}, '{{ c.name|e }}')">Szukaj w Google</button>
<button class="btn-skip" onclick="skipCompany({{ c.id }})">Brak w Google</button>
</div>
</div>
<div class="search-results" id="results-{{ c.id }}"></div>
</div>
{% endfor %}
{% if not companies %}
<div style="text-align: center; padding: 60px 20px; color: var(--text-secondary);">
Wszystkie aktywne firmy maja przypisany Google Place ID.
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
var csrfToken = '{{ csrf_token() }}';
var matchedCount = 0;
var totalCompanies = {{ companies|length }};
function searchPlace(companyId, companyName) {
var resultsDiv = document.getElementById('results-' + companyId);
var btn = event.target;
btn.disabled = true;
btn.textContent = 'Szukam...';
resultsDiv.innerHTML = '<div class="search-loading">Szukam "' + companyName + '" w Google Maps...</div>';
resultsDiv.classList.add('visible');
var formData = new FormData();
formData.append('company_id', companyId);
formData.append('csrf_token', csrfToken);
fetch('{{ url_for("admin.admin_gbp_search_place") }}', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.textContent = 'Szukaj w Google';
if (data.error) {
resultsDiv.innerHTML = '<div class="no-results">Blad: ' + data.error + '</div>';
return;
}
if (!data.results || data.results.length === 0) {
resultsDiv.innerHTML = '<div class="no-results">Nie znaleziono wynikow dla "' + data.query + '"</div>';
return;
}
var html = '';
data.results.forEach(function(p) {
var meta = [];
if (p.rating) meta.push('<span class="rating">' + p.rating + ' &#9733;</span>');
if (p.reviews_count) meta.push(p.reviews_count + ' opinii');
if (p.types) meta.push(p.types);
html += '<div class="place-result">';
html += ' <div class="place-info">';
html += ' <div class="place-name">' + p.name + '</div>';
html += ' <div class="place-address">' + p.address + '</div>';
if (meta.length) html += ' <div class="place-meta">' + meta.join(' &middot; ') + '</div>';
html += ' </div>';
html += ' <button class="btn-confirm" onclick="confirmPlace(' + companyId + ', \'' + p.place_id + '\', \'' + p.name.replace(/'/g, "\\'") + '\')">Potwierdz</button>';
html += '</div>';
});
resultsDiv.innerHTML = html;
})
.catch(function(err) {
btn.disabled = false;
btn.textContent = 'Szukaj w Google';
resultsDiv.innerHTML = '<div class="no-results">Blad polaczenia</div>';
});
}
function confirmPlace(companyId, placeId, googleName) {
var btn = event.target;
btn.disabled = true;
btn.textContent = 'Zapisuje...';
var formData = new FormData();
formData.append('company_id', companyId);
formData.append('place_id', placeId);
formData.append('google_name', googleName);
formData.append('csrf_token', csrfToken);
fetch('{{ url_for("admin.admin_gbp_confirm_place") }}', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
btn.disabled = false;
btn.textContent = 'Potwierdz';
alert('Blad: ' + data.error);
return;
}
var card = document.getElementById('card-' + companyId);
card.classList.add('matched');
var header = card.querySelector('.company-header');
var nameDiv = header.querySelector('.company-name');
nameDiv.innerHTML += ' <span class="match-badge">' + googleName + '</span>';
var resultsDiv = document.getElementById('results-' + companyId);
resultsDiv.classList.remove('visible');
var buttons = header.querySelectorAll('button');
buttons.forEach(function(b) { b.style.display = 'none'; });
matchedCount++;
document.getElementById('matchedCount').textContent = matchedCount;
document.getElementById('remainingCount').textContent = totalCompanies - matchedCount;
})
.catch(function(err) {
btn.disabled = false;
btn.textContent = 'Potwierdz';
alert('Blad polaczenia');
});
}
function skipCompany(companyId) {
var card = document.getElementById('card-' + companyId);
card.style.display = 'none';
totalCompanies--;
document.getElementById('remainingCount').textContent = totalCompanies - matchedCount;
}
{% endblock %}