B17/E5: ингестия полевых данных КОНТУРа
- POST /operations/{id}/field-data/sync: тянет GET /api/v1/vector/areas-checked
→ идемпотентный upsert в areas_checked (dedupe по contour_area_id:
geom/checked_at/result/coverage_pct/team_label, source=contour)
- GET /operations/{id}/areas-checked: теперь из локальной БД после sync
(source=db), фолбэк на живой прокси КОНТУРа если пусто (source=contour/empty)
- миграция 011_b17_field_data: areas_checked +contour_area_id/team_label/source
(+check ck_areas_checked_source)
- аудит field_data_sync; лог vector.contour field_sync_*
- фронт: кнопка «⟳ Синхронизировать полевые данные» в КОНТУР-панели,
слой проверенных квадратов на карте (зелёный=clear ≥99.5%, жёлтый=partial)
- тесты: +9 (267 passed) — sync/идемпотентность/502/аудит/локальное чтение
This commit is contained in:
@@ -11,7 +11,7 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures, fieldAreas }) => {
|
||||
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
|
||||
const distance = maxDistance || 5.0;
|
||||
|
||||
@@ -152,6 +152,27 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
|
||||
};
|
||||
const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`);
|
||||
|
||||
// B17: слой проверенных квадратов из КОНТУРа (fieldAreas = areas_checked items).
|
||||
// Геометрия — GeoJSON Polygon (dict или JSON-строка). Зелёный = clear, жёлтый = partial.
|
||||
const toLatLngPoly = (coords) => Array.isArray(coords[0][0][0])
|
||||
? coords.map((poly) => poly[0].map((c) => [c[1], c[0]]))
|
||||
: coords[0].map((c) => [c[1], c[0]]);
|
||||
const fieldGeoJSON = (fieldAreas || [])
|
||||
.filter((a) => a.geom)
|
||||
.map((a) => {
|
||||
let geom = a.geom;
|
||||
if (typeof geom === 'string') {
|
||||
try { geom = JSON.parse(geom); } catch { return null; }
|
||||
}
|
||||
const coords = geom?.coordinates;
|
||||
if (!coords) return null;
|
||||
return { area: a, positions: toLatLngPoly(coords) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
const fieldStyle = (area) => area.result === 'clear'
|
||||
? { color: '#3fb950', fillColor: '#3fb950', fillOpacity: 0.30, weight: 1.5 }
|
||||
: { color: '#ffd166', fillColor: '#ffd166', fillOpacity: 0.22, weight: 1, dashArray: '4 3' };
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={tnpPosition}
|
||||
@@ -194,6 +215,23 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
|
||||
</Polyline>
|
||||
))}
|
||||
|
||||
{/* B17: проверенные квадраты КОНТУРа (поверх гео, под зонами) */}
|
||||
{fieldGeoJSON.map(({ area, positions }, idx) => (
|
||||
<Polygon
|
||||
key={`field-${idx}`}
|
||||
positions={positions}
|
||||
pathOptions={fieldStyle(area)}
|
||||
>
|
||||
<Popup>
|
||||
<strong>Проверенный участок {area.team_id ? `— ${area.team_id}` : ''}</strong><br/>
|
||||
Статус: {area.result === 'clear' ? '✅ прочесано (≥99.5%)' : '🟡 частично'}<br/>
|
||||
{area.coverage_pct != null && <>Покрытие: {Math.round(area.coverage_pct)}%<br/></>}
|
||||
{area.checked_at && <>Проверено: {new Date(area.checked_at).toLocaleString('ru-RU')}<br/></>}
|
||||
{area.synced && <span style={{ opacity: 0.7 }}>данные синхронизированы из КОНТУРа</span>}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* Базовый слой: концентрические круги возможной зоны поиска */}
|
||||
{defaultZones.map((zone, idx) => (
|
||||
<Polygon
|
||||
|
||||
@@ -699,7 +699,7 @@
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
}
|
||||
.contour-save, .contour-send {
|
||||
.contour-save, .contour-send, .contour-sync {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -711,6 +711,8 @@
|
||||
.contour-save { background: #21262d; color: #e6edf3; }
|
||||
.contour-send { background: #238636; }
|
||||
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
|
||||
.contour-sync { background: #1f6feb; }
|
||||
.contour-sync:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
|
||||
.contour-ok { font-size: 12px; color: #3fb950; }
|
||||
.contour-err { font-size: 12px; color: #f85149; }
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ const AnalysisResult = () => {
|
||||
const [operation, setOperation] = useState(null);
|
||||
const [contourId, setContourId] = useState('');
|
||||
const [contourStatus, setContourStatus] = useState(null); // {ok, message}
|
||||
const [fieldAreas, setFieldAreas] = useState(null); // B17: {source, items}
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [showComplete, setShowComplete] = useState(false);
|
||||
const [completeForm, setCompleteForm] = useState({
|
||||
found_alive: 'true', found_distance_km: '', found_direction: '',
|
||||
@@ -89,6 +91,12 @@ const AnalysisResult = () => {
|
||||
/* водоёмы — опциональный слой, не блокируют анализ */
|
||||
}
|
||||
|
||||
// B17: полевые данные (если есть contour_operation_id)
|
||||
try {
|
||||
const fieldResp = await apiFetch(`/api/v1/operations/${caseId}/areas-checked`);
|
||||
if (fieldResp.ok) setFieldAreas(await fieldResp.json());
|
||||
} catch { /* слой опциональный */ }
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error loading case:", err);
|
||||
setError(err.message);
|
||||
@@ -250,6 +258,7 @@ const AnalysisResult = () => {
|
||||
tnpLat={parseFloat(tnpLat)}
|
||||
tnpLon={parseFloat(tnpLon)}
|
||||
waterFeatures={waterData?.features || []}
|
||||
fieldAreas={fieldAreas?.items || []}
|
||||
/>
|
||||
) : (
|
||||
<div className="map-empty">ТНП не задана — карта недоступна</div>
|
||||
@@ -290,6 +299,25 @@ const AnalysisResult = () => {
|
||||
}}
|
||||
>Отправить зоны в КОНТУР</button>
|
||||
</div>
|
||||
<div className="contour-row">
|
||||
<button
|
||||
className="contour-sync"
|
||||
disabled={!contourId || syncing}
|
||||
onClick={async () => {
|
||||
setSyncing(true);
|
||||
const r = await apiFetch(`/api/v1/operations/${operation.id}/field-data/sync`, { method: 'POST' });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
setSyncing(false);
|
||||
if (r.ok) {
|
||||
setContourStatus({ ok: true, message: `Полевые данные: квадратов ${body.synced_areas} (новых ${body.new_areas}, обновлено ${body.updated_areas})` });
|
||||
const fr = await apiFetch(`/api/v1/operations/${operation.id}/areas-checked`);
|
||||
if (fr.ok) setFieldAreas(await fr.json());
|
||||
} else {
|
||||
setContourStatus({ ok: false, message: body.detail || 'Ошибка синхронизации' });
|
||||
}
|
||||
}}
|
||||
>{syncing ? 'Синхронизация…' : '⟳ Синхронизировать полевые данные'}</button>
|
||||
</div>
|
||||
{contourStatus && (
|
||||
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
|
||||
{contourStatus.message}
|
||||
|
||||
Reference in New Issue
Block a user