The outages portal appends the district in parentheses, e.g. "Медкадры улица, 1 (Заельцовский район)". That suffix hid the house number from the parser, so every portal address failed house matching and /v1/outages always returned empty. - address: NormalizeText drops a trailing "(...)" group (root fix, benefits every source) - outages: introduce HouseUnit; the ArcGIS adapter parses the (cleaned) address at the boundary and stores the structured unit; MatchingHouse matches on the unit (with a parse fallback for sources that only provide text) - config: portal.url is now optional (core runs without the events module) - httpapi: always return "outages": [] (no more omitempty); report "detail" when the events module is not configured - tests: regression coverage for the portal district format
244 lines
6.8 KiB
Go
244 lines
6.8 KiB
Go
// Package arcgis is the adapter for the external outages portal exposed as an
|
|
// ArcGIS MapServer /query endpoint.
|
|
//
|
|
// The portal only supports substring (LIKE) filtering, so precision is
|
|
// guaranteed locally: whatever the portal returns is re-filtered by the
|
|
// outages domain using the parsed house unit. The client adds the reliability
|
|
// the portal lacks: a hard timeout, bounded retries with backoff and context
|
|
// cancellation.
|
|
package arcgis
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
|
"git.totmin.ru/en2zmax/addrkit/internal/domain/outages"
|
|
)
|
|
|
|
// reDistrictSuffix matches the administrative suffix the portal appends to its
|
|
// display address, e.g. "Медкадры улица, 1 (Заельцовский район)". It must be
|
|
// removed before the address is parsed, otherwise the trailing district text
|
|
// hides the house number. The district is available separately as a field.
|
|
var reDistrictSuffix = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
|
|
|
|
// cleanAddress strips the portal's district suffix and surrounding spaces.
|
|
func cleanAddress(s string) string {
|
|
return strings.TrimSpace(reDistrictSuffix.ReplaceAllString(s, ""))
|
|
}
|
|
|
|
// Client is a bounded HTTP client for the portal.
|
|
type Client struct {
|
|
baseURL string
|
|
http *http.Client
|
|
retries int
|
|
}
|
|
|
|
// New builds a client. timeout applies to a single attempt; retries is the
|
|
// number of *additional* attempts after the first.
|
|
func New(baseURL string, timeout time.Duration, retries int) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
http: &http.Client{Timeout: timeout},
|
|
retries: retries,
|
|
}
|
|
}
|
|
|
|
// Query asks the portal for outages matching the canonical address. The
|
|
// returned records are raw portal rows; callers must still filter by house.
|
|
func (c *Client) Query(ctx context.Context, canonical string) ([]outages.Outage, error) {
|
|
params := url.Values{}
|
|
params.Set("where", "address like '"+likePattern(canonical)+"'")
|
|
params.Set("outFields", "")
|
|
params.Set("returnGeometry", "false")
|
|
params.Set("f", "json")
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt <= c.retries; attempt++ {
|
|
if attempt > 0 {
|
|
backoff := time.Duration(attempt) * 150 * time.Millisecond
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
rows, err := c.do(ctx, params)
|
|
if err == nil {
|
|
return rows, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("portal query failed after %d attempts: %w", c.retries+1, lastErr)
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, params url.Values) ([]outages.Outage, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"?"+params.Encode(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("portal status %d", resp.StatusCode)
|
|
}
|
|
// Bound the response size to protect the service from a misbehaving portal.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseResponse(body)
|
|
}
|
|
|
|
// portalResponse mirrors the subset of the ArcGIS /query envelope we use.
|
|
type portalResponse struct {
|
|
Features []struct {
|
|
Value string `json:"value"`
|
|
Attributes map[string]any `json:"attributes"`
|
|
} `json:"features"`
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Domain struct {
|
|
CodedValues []struct {
|
|
Code any `json:"code"`
|
|
Name string `json:"name"`
|
|
} `json:"codedValues"`
|
|
} `json:"domain"`
|
|
} `json:"fields"`
|
|
}
|
|
|
|
func parseResponse(body []byte) ([]outages.Outage, error) {
|
|
var pr portalResponse
|
|
if err := json.Unmarshal(body, &pr); err != nil {
|
|
return nil, fmt.Errorf("decode portal response: %w", err)
|
|
}
|
|
|
|
// Build code -> name maps from the coded-value domains.
|
|
maps := map[string]map[string]string{}
|
|
for _, f := range pr.Fields {
|
|
if len(f.Domain.CodedValues) == 0 {
|
|
continue
|
|
}
|
|
m := make(map[string]string, len(f.Domain.CodedValues))
|
|
for _, cv := range f.Domain.CodedValues {
|
|
m[codeKey(cv.Code)] = cv.Name
|
|
}
|
|
maps[f.Name] = m
|
|
}
|
|
|
|
out := make([]outages.Outage, 0, len(pr.Features))
|
|
for _, feat := range pr.Features {
|
|
addr := cleanAddress(addressOf(feat.Attributes, feat.Value))
|
|
o := outages.Outage{
|
|
Address: addr,
|
|
Type: lookup(maps, "type_id", feat.Attributes),
|
|
System: lookup(maps, "system_id", feat.Attributes),
|
|
Time: lookup(maps, "time_category", feat.Attributes),
|
|
StartDate: stringOf(feat.Attributes["start_date"]),
|
|
EndDate: stringOf(feat.Attributes["end_date"]),
|
|
}
|
|
o.Action = actionFor(o.System)
|
|
// Parse the address at the boundary so the domain matches on structure
|
|
// and never depends on this portal's text format.
|
|
o.Unit = outages.HouseUnitOf(address.Parse(addr))
|
|
out = append(out, o)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func addressOf(attrs map[string]any, fallback string) string {
|
|
if v, ok := attrs["address"]; ok {
|
|
if s := stringOf(v); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func lookup(maps map[string]map[string]string, field string, attrs map[string]any) string {
|
|
m, ok := maps[field]
|
|
if !ok {
|
|
return stringOf(attrs[field])
|
|
}
|
|
return m[codeKey(attrs[field])]
|
|
}
|
|
|
|
// codeKey normalises a coded value coming from JSON (float64) or a string.
|
|
func codeKey(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return strconv.FormatInt(int64(t), 10)
|
|
case json.Number:
|
|
return t.String()
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
func stringOf(v any) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
// actionFor maps a portal system name to the canonical action code used by the
|
|
// scenario (kept for parity with the existing integration).
|
|
func actionFor(system string) string {
|
|
switch system {
|
|
case "Горячее водоснабжение":
|
|
return "waterWarm"
|
|
case "Холодное водоснабжение и канализация", "Холодное водоснабжение":
|
|
return "waterCold"
|
|
case "Электроснабжение":
|
|
return "electricitySupply"
|
|
case "Газоснабжение":
|
|
return "gasSupply"
|
|
case "Теплоснабжение":
|
|
return "heating"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// likePattern builds the substring pattern the portal supports. Numeric tokens
|
|
// are padded with spaces, matching the historical integration, so that "1" does
|
|
// not incidentally match "10".
|
|
func likePattern(canonical string) string {
|
|
parts := strings.Fields(canonical)
|
|
for i, part := range parts {
|
|
if containsDigit(part) {
|
|
parts[i] = " " + part + " "
|
|
}
|
|
}
|
|
return "%" + strings.Join(parts, "%") + "%"
|
|
}
|
|
|
|
func containsDigit(s string) bool {
|
|
for _, r := range s {
|
|
if r >= '0' && r <= '9' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|