addrkit: deterministic address resolution service (Go)
- domain: address normalization/parsing, matching with explicit statuses - adapters: SQLite registry store, ArcGIS outages client, optional LLM rewriter, HTTP API - seed pipeline with duplicate handling and unparsed reporting - CLI (seed/verify/resolve), systemd unit, OpenAPI contract, tests
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// Package outages contains the deterministic, pure logic for selecting outages
|
||||
// that belong to a concrete house unit. It does not talk to the network: the
|
||||
// portal client lives in an adapter and feeds this package.
|
||||
package outages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
// Outage is a single outage record as returned by the external portal.
|
||||
type Outage struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
System string `json:"system,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// Source is anything that can answer "what outages are there for this
|
||||
// canonical address". It is implemented by the ArcGIS portal adapter and by
|
||||
// fakes in tests.
|
||||
type Source interface {
|
||||
Query(ctx context.Context, canonical string) ([]Outage, error)
|
||||
}
|
||||
|
||||
// MatchingHouse keeps only the outages that address the same house unit as the
|
||||
// target. This is the fix for the classic bug where a substring match returned
|
||||
// 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 {
|
||||
out := make([]Outage, 0, len(list))
|
||||
for _, o := range list {
|
||||
op := address.Parse(o.Address)
|
||||
if houseUnitEqual(target, op) {
|
||||
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).
|
||||
func Current(list []Outage, now time.Time, loc *time.Location) []Outage {
|
||||
out := make([]Outage, 0, len(list))
|
||||
for _, o := range list {
|
||||
start, okStart := parsePortalTime(o.StartDate, loc)
|
||||
end, okEnd := parsePortalTime(o.EndDate, loc)
|
||||
if !okStart || !okEnd {
|
||||
// Undated records are kept: better to report a possibly-current
|
||||
// outage than to silently drop it.
|
||||
out = append(out, o)
|
||||
continue
|
||||
}
|
||||
if (now.Equal(start) || now.After(start)) && (now.Equal(end) || now.Before(end)) {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parsePortalTime accepts the shapes the portal may produce: epoch
|
||||
// milliseconds, epoch seconds, RFC3339 and a few common date formats. All
|
||||
// results are interpreted in loc.
|
||||
func parsePortalTime(s string, loc *time.Location) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
// Heuristic: 13+ digits is milliseconds, otherwise seconds.
|
||||
if n > 1e12 {
|
||||
return time.UnixMilli(n).In(loc), true
|
||||
}
|
||||
return time.Unix(n, 0).In(loc), true
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, s, loc); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package outages
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
func TestMatchingHouse(t *testing.T) {
|
||||
list := []Outage{
|
||||
{Address: "Станционная улица 1/1", System: "Теплоснабжение"},
|
||||
{Address: "Станционная улица 1/2", System: "Теплоснабжение"},
|
||||
{Address: "Станционная улица 1/1 к1", System: "Теплоснабжение"},
|
||||
{Address: "Станционная улица 10", System: "Теплоснабжение"},
|
||||
}
|
||||
target := address.Parse("2-я Станционная улица 1/1")
|
||||
|
||||
got := MatchingHouse(list, target)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly 1 matching outage, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Address != "Станционная улица 1/1" {
|
||||
t.Errorf("matched wrong outage: %q", got[0].Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchingHouseIgnoresStructure(t *testing.T) {
|
||||
list := []Outage{{Address: "Станционная улица 1/1 стр.2"}}
|
||||
target := address.Parse("станционная 1/1 стр.3")
|
||||
if got := MatchingHouse(list, target); len(got) != 1 {
|
||||
t.Fatalf("structure must not affect house matching; 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)
|
||||
within := now.Add(-time.Hour).UnixMilli()
|
||||
after := now.Add(time.Hour).UnixMilli()
|
||||
before := now.Add(-48 * time.Hour).UnixMilli()
|
||||
|
||||
list := []Outage{
|
||||
{Address: "A", StartDate: itoa(within), EndDate: itoa(after)}, // current
|
||||
{Address: "B", StartDate: itoa(before), EndDate: itoa(before)}, // past
|
||||
{Address: "C", StartDate: "", EndDate: ""}, // undated -> kept
|
||||
}
|
||||
got := Current(list, now, loc)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 current outages, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
Reference in New Issue
Block a user