// 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) } } // stringOf renders an attribute value as a string. The important case is // numeric dates: fmt's %v turns a float64 into scientific notation ("1.79e+12") // which the date parser cannot read, so integers are formatted explicitly. func stringOf(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) } } // 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 }