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:
@@ -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 {
|
||||
|
||||
@@ -95,6 +95,12 @@ func TestParseHouseBlock(t *testing.T) {
|
||||
wantName: "кошурникова", wantType: "улица",
|
||||
wantHouse: "11", wantFrac: "1", wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "portal district suffix in parentheses",
|
||||
raw: "Медкадры улица, 1 (Заельцовский район)",
|
||||
wantName: "медкадры", wantType: "улица",
|
||||
wantHouse: "1", wantHas: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -154,6 +160,7 @@ func TestNormalizeIdempotent(t *testing.T) {
|
||||
" 2-Я Станционная\u00a0улица 1/1 стр. ",
|
||||
"ЁЛКИ д. 5, кв. 3",
|
||||
"СТАНИЦИОННАЯ — улица, 30а",
|
||||
"Медкадры улица, 1 (Заельцовский район)",
|
||||
"",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
|
||||
@@ -21,6 +21,35 @@ type Outage struct {
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
|
||||
// Unit is the structured house unit, filled by the source adapter so the
|
||||
// domain never has to parse the raw external address format. Not exported
|
||||
// to JSON: it is an internal matching key.
|
||||
Unit HouseUnit `json:"-"`
|
||||
}
|
||||
|
||||
// HouseUnit identifies a concrete house within a street: number, optional
|
||||
// fraction and letter, and the corpus (building) with its own fraction and
|
||||
// letter. "Outages by house" matches on exactly this.
|
||||
type HouseUnit struct {
|
||||
House string
|
||||
HouseFrac string
|
||||
HouseLetter string
|
||||
Building string
|
||||
BuildingFrac string
|
||||
BuildingLetter string
|
||||
}
|
||||
|
||||
// HouseUnitOf extracts the unit from a parsed address.
|
||||
func HouseUnitOf(p address.ParsedAddress) HouseUnit {
|
||||
return HouseUnit{
|
||||
House: p.House,
|
||||
HouseFrac: p.HouseFrac,
|
||||
HouseLetter: p.HouseLetter,
|
||||
Building: p.Building,
|
||||
BuildingFrac: p.BuildingFrac,
|
||||
BuildingLetter: p.BuildingLetter,
|
||||
}
|
||||
}
|
||||
|
||||
// Source is anything that can answer "what outages are there for this
|
||||
@@ -35,29 +64,23 @@ type Source interface {
|
||||
// a neighbouring house's outage: matching is done on the parsed structure, not
|
||||
// on the raw text.
|
||||
func MatchingHouse(list []Outage, target address.ParsedAddress) []Outage {
|
||||
want := HouseUnitOf(target)
|
||||
out := make([]Outage, 0, len(list))
|
||||
for _, o := range list {
|
||||
op := address.Parse(o.Address)
|
||||
if houseUnitEqual(target, op) {
|
||||
unit := o.Unit
|
||||
if unit == (HouseUnit{}) {
|
||||
// The source did not pre-parse the address (older/alternative
|
||||
// adapters); fall back to parsing it here. Adapters should set
|
||||
// Unit so the domain stays independent of external formats.
|
||||
unit = HouseUnitOf(address.Parse(o.Address))
|
||||
}
|
||||
if unit == want {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// houseUnitEqual compares two parsed house units. House number, fraction,
|
||||
// letter, building (corpus) and its fraction/letter must all match. The
|
||||
// structure (строение) is deliberately ignored: it is frequently omitted by
|
||||
// users and does not change which house an outage refers to.
|
||||
func houseUnitEqual(a, b address.ParsedAddress) bool {
|
||||
return a.House == b.House &&
|
||||
a.HouseFrac == b.HouseFrac &&
|
||||
a.HouseLetter == b.HouseLetter &&
|
||||
a.Building == b.Building &&
|
||||
a.BuildingFrac == b.BuildingFrac &&
|
||||
a.BuildingLetter == b.BuildingLetter
|
||||
}
|
||||
|
||||
// Current keeps outages whose [start, end] range covers now. The portal
|
||||
// already filters by date, but we re-check locally because the portal's notion
|
||||
// of "today" and ours must agree (Asia/Novosibirsk, UTC+7).
|
||||
|
||||
@@ -34,6 +34,28 @@ func TestMatchingHouseIgnoresStructure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchingHouseDistrictSuffix covers the fallback path (Unit not set by the
|
||||
// adapter): the parser must itself drop the portal's "(<district> район)".
|
||||
func TestMatchingHouseDistrictSuffix(t *testing.T) {
|
||||
list := []Outage{{Address: "Медкадры улица, 1 (Заельцовский район)"}}
|
||||
target := address.Parse("Медкадры улица 1")
|
||||
if got := MatchingHouse(list, target); len(got) != 1 {
|
||||
t.Fatalf("district suffix must not break house matching; got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchingHouseUsesStructuredUnit verifies the domain matches on the unit
|
||||
// the adapter parsed, not on the raw address text.
|
||||
func TestMatchingHouseUsesStructuredUnit(t *testing.T) {
|
||||
list := []Outage{{Address: "неважно", Unit: HouseUnit{House: "5", HouseFrac: "1"}}}
|
||||
if got := MatchingHouse(list, address.Parse("что угодно 5/1")); len(got) != 1 {
|
||||
t.Fatalf("structured unit not used for matching; got %d", len(got))
|
||||
}
|
||||
if got := MatchingHouse(list, address.Parse("что угодно 5/2")); len(got) != 0 {
|
||||
t.Fatalf("different unit must not match; got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrent(t *testing.T) {
|
||||
loc := time.FixedZone("NSK", 7*3600)
|
||||
now := time.Date(2026, 10, 9, 12, 0, 0, 0, loc)
|
||||
|
||||
Reference in New Issue
Block a user