fix(outages): parse portal address at the boundary; strip district suffix

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
This commit is contained in:
Maksim Totmin
2026-10-09 22:05:18 +07:00
parent 59a8b2c164
commit 50f180ea10
10 changed files with 156 additions and 29 deletions
+14
View File
@@ -1,6 +1,7 @@
package address
import (
"regexp"
"strings"
"unicode"
@@ -12,6 +13,11 @@ import (
// alike, which is what we want for matching.
var fold = cases.Fold()
// reTrailingParenthetical matches a trailing "(...)" group, e.g. the district
// suffix that the outages portal appends: "Медкадры улица, 1 (Заельцовский
// район)". Such administrative noise must not be treated as part of the house.
var reTrailingParenthetical = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
// NormalizeText folds a free-form string into a canonical form suitable for
// parsing and matching.
//
@@ -28,11 +34,19 @@ func NormalizeText(s string) string {
s = norm.NFKC.String(s)
s = fold.String(s)
s = strings.ReplaceAll(s, "ё", "е")
s = stripTrailingParenthetical(s)
s = mapSeparators(s)
s = strings.Join(strings.Fields(s), " ")
return strings.TrimSpace(s)
}
// stripTrailingParenthetical removes a single trailing "(...)" group. It runs
// before separator mapping because that step turns parentheses into spaces and
// the group would no longer be recognisable.
func stripTrailingParenthetical(s string) string {
return strings.TrimSpace(reTrailingParenthetical.ReplaceAllString(s, ""))
}
// mapSeparators unifies characters that carry no semantic meaning for address
// matching. It never removes digits, letters or the '/' house separator.
func mapSeparators(s string) string {