likePattern padded every token containing a digit, including ordinals like "3-я". When the ordinal starts the address, " 3-я " never matches, so "3-я Механическая улица 24а" returned no outages. Pad only pure-numeric house tokens; precision is enforced by the local house filter regardless.
249 lines
7.0 KiB
Go
249 lines
7.0 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. Only pure
|
||
// numeric tokens (house numbers) are padded with spaces, so "1" does not
|
||
// incidentally match "10". Ordinals ("3-я") and houses with a letter ("24а")
|
||
// are left as-is: padding an ordinal breaks the match when it starts the
|
||
// string, and precision is guaranteed by the local house filter anyway.
|
||
func likePattern(canonical string) string {
|
||
parts := strings.Fields(canonical)
|
||
for i, part := range parts {
|
||
if isPureDigits(part) {
|
||
parts[i] = " " + part + " "
|
||
}
|
||
}
|
||
return "%" + strings.Join(parts, "%") + "%"
|
||
}
|
||
|
||
func isPureDigits(s string) bool {
|
||
if s == "" {
|
||
return false
|
||
}
|
||
for _, r := range s {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|