diff --git a/Lib/GetReport.php b/Lib/GetReport.php index ead95e7..bcac589 100644 --- a/Lib/GetReport.php +++ b/Lib/GetReport.php @@ -308,6 +308,7 @@ private function prepareCdrData($selectedRecords):array 'uniqid' => $provider->uniqid, 'username' => $provider->username, 'description' => $provider->description, + 'host' => $provider->host, ]; } $trunkResolver = new TrunkResolver($providerRows); diff --git a/Lib/TrunkResolver.php b/Lib/TrunkResolver.php index 311ac49..44d297f 100644 --- a/Lib/TrunkResolver.php +++ b/Lib/TrunkResolver.php @@ -4,10 +4,12 @@ final class TrunkResolver { - /** @var array */ + /** @var array */ private array $byId = []; - /** @var array> */ - private array $byUsername = []; + /** @var array */ + private array $hostProviderCount = []; + /** @var array>> */ + private array $byHostAndUsername = []; public function __construct(iterable $providers) { @@ -18,11 +20,16 @@ public function __construct(iterable $providers) if ($id === '' || $name === '') { continue; } - $candidate = ['name' => $name, 'id' => $id]; + $host = self::normalizeHost((string)($provider['host'] ?? '')); + $candidate = ['name' => $name, 'id' => $id, 'host' => $host]; $this->byId[$id] = $candidate; + if ($host === '') { + continue; + } + $this->hostProviderCount[$host] = ($this->hostProviderCount[$host] ?? 0) + 1; $username = self::normalizeNumber((string)($provider['username'] ?? '')); if ($username !== '') { - $this->byUsername[$username][] = $candidate; + $this->byHostAndUsername[$host][$username][] = $candidate; } } } @@ -32,24 +39,17 @@ public function resolve(array $record, string $callType): array { $technical = (string)($record['line'] ?? ''); if (isset($this->byId[$technical])) { - return $this->resolved($this->byId[$technical], 'line_id'); - } - - if ($callType === 'incoming' || $callType === '2') { - $did = self::normalizeNumber((string)($record['did'] ?? '')); - $candidates = $did === '' ? [] : ($this->byUsername[$did] ?? []); - if (count($candidates) === 1) { - return $this->resolved($candidates[0], 'did_username'); - } - if (count($candidates) > 1) { - return [ - 'name' => $technical, - 'id' => $technical, - 'status' => 'ambiguous', - 'source' => 'did_username', - 'candidates' => array_column($candidates, 'id'), - ]; + $lineProvider = $this->byId[$technical]; + $host = $lineProvider['host']; + $isIncoming = in_array($callType, ['incoming', '2', '3'], true); + if ($isIncoming && $host !== '' && ($this->hostProviderCount[$host] ?? 0) > 1) { + $did = self::normalizeNumber((string)($record['did'] ?? '')); + $candidates = $did === '' ? [] : ($this->byHostAndUsername[$host][$did] ?? []); + if (count($candidates) === 1) { + return $this->resolved($candidates[0], 'did_username'); + } } + return $this->resolved($lineProvider, 'line_id'); } return [ @@ -76,4 +76,9 @@ private static function normalizeNumber(string $number): string { return preg_replace('/\D+/', '', $number) ?: ''; } + + private static function normalizeHost(string $host): string + { + return strtolower(trim($host)); + } } diff --git a/docs/superpowers/plans/2026-08-13-shared-host-trunk-resolution.md b/docs/superpowers/plans/2026-08-13-shared-host-trunk-resolution.md new file mode 100644 index 0000000..78f1f25 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-shared-host-trunk-resolution.md @@ -0,0 +1,68 @@ +# Shared-host Trunk Resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve incoming and missed calls to the correct SIP account by DID when the technical line belongs to a host shared by several providers. + +**Architecture:** Keep the existing single provider query in `GetReport`, include `host` in each provider row, and build immutable hash indexes in `TrunkResolver`. Resolution remains line-first, with an O(1) host-and-DID refinement only for inbound call types and shared non-empty hosts. + +**Tech Stack:** PHP 7.4-compatible production code, standalone PHP regression tests. + +## Global Constraints + +- Do not add per-CDR or per-call database queries. +- Treat call types `2` and `3` as inbound for shared-host refinement. +- Never group empty hosts. +- Retain the line provider when DID refinement is unavailable or ambiguous. +- Normalize hosts with trim plus lowercase and numbers by retaining digits only. + +--- + +### Task 1: Shared-host resolver and report integration + +**Files:** +- Modify: `tests/TrunkResolverTest.php` +- Modify: `Lib/TrunkResolver.php` +- Modify: `Lib/GetReport.php` + +**Interfaces:** +- Consumes: provider arrays containing `uniqid`, `description`, `host`, and `username`; CDR arrays containing `line` and `did`. +- Produces: the existing `TrunkResolver::resolve(array $record, string $callType): array` contract, with `source=did_username` for successful shared-host refinement and `source=line_id` for fallback. + +- [x] **Step 1: Add failing shared-host regression tests** + +Extend provider fixtures with `host`. Assert that an incoming call whose line is Provider A and whose DID matches Provider B resolves to Provider B only when both share the same non-empty host. Add the same assertion for call type `3`. Assert fallback to Provider A for different hosts, empty hosts, outgoing calls, missing DID, and duplicate host-and-username candidates. + +- [x] **Step 2: Run the focused test and verify RED** + +Run: `php tests/TrunkResolverTest.php` + +Expected: failure where a shared-host incoming call returns Provider A instead of Provider B. + +- [x] **Step 3: Build constructor hash indexes** + +In `TrunkResolver`, retain provider host in `byId`, count providers by normalized non-empty host, and build candidates indexed by normalized host then normalized username. Add a PHP 7.4-compatible host normalization helper using `strtolower(trim($host))`. + +- [x] **Step 4: Implement line-first shared-host refinement** + +In `resolve`, first look up `line`. If found, refine only when call type is `incoming`, `2`, or `3`, the selected provider has a non-empty host with count greater than one, and exactly one candidate matches that host plus normalized DID. Otherwise return the selected line provider. If line is unresolved, retain the existing unresolved technical result without a global DID lookup. + +- [x] **Step 5: Pass provider host from the existing query result** + +Add `'host' => $provider->host` to the provider row built by `GetReport::prepareCdrData`. Do not add another model lookup. + +- [x] **Step 6: Run focused and full tests** + +Run: `php tests/TrunkResolverTest.php` + +Expected: `TrunkResolverTest: OK`. + +Run every standalone PHP test under `tests/` using the repository's existing test loop. + +Expected: exit code 0 and no failed test. + +- [x] **Step 7: Review the diff and commit** + +Run: `git diff --check && git diff -- Lib/TrunkResolver.php Lib/GetReport.php tests/TrunkResolverTest.php` + +Commit only the three implementation files and this plan with message `fix: resolve providers sharing a SIP host`. diff --git a/docs/superpowers/specs/2026-08-13-shared-host-trunk-resolution-design.md b/docs/superpowers/specs/2026-08-13-shared-host-trunk-resolution-design.md new file mode 100644 index 0000000..267df43 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-shared-host-trunk-resolution-design.md @@ -0,0 +1,62 @@ +# Shared-host trunk resolution + +## Problem + +Several SIP provider accounts can use the same host. Asterisk records the +technical `line` of one account for calls received through that shared host, +while the call `did` identifies the actual account. Resolving only by `line` +therefore assigns some incoming calls to the wrong provider. + +## Resolution rule + +Provider metadata is loaded once with the existing `Sip::find("type='friend'")` +query. `GetReport` passes `uniqid`, `description`, `host`, and `username` to +`TrunkResolver`. + +`TrunkResolver` builds these in-memory indexes in its constructor: + +- provider by `uniqid`; +- number of providers per normalized non-empty host; +- providers by normalized host and normalized username. + +Resolution starts with `line -> uniqid`. If no provider matches `line`, the +technical value remains unresolved; the resolver must not search globally by +DID. + +For incoming and missed calls (`typeCall` `2` and `3`), when the provider found +by `line` has a non-empty host shared by multiple provider records, the resolver +uses `did` as a username within that host. One matching account overrides the +technical-line provider. No match or multiple matches retain the provider found +by `line`. + +Internal and outgoing calls always retain line-based resolution. Empty hosts +are never treated as a shared-host group. + +Hosts are normalized by trimming whitespace and converting to lowercase. +Usernames and DIDs are normalized by retaining digits only. + +## Performance + +There are no database queries during per-CDR resolution. Provider metadata is +queried once and all resolver operations use hash lookups. Construction is +linear in the number of providers and each resolution is constant-time apart +from the small candidate list for an exact host-and-username key. + +## Compatibility and fallback + +Existing line-based resolution remains the fallback in every ambiguous or +incomplete-data case. Providers with missing identifiers or descriptions keep +the existing exclusion behavior. Provider IDs returned after a successful +shared-host refinement belong to the DID-matched account. + +## Tests + +Regression tests will cover: + +- a unique DID match overriding a different technical line on a shared host; +- the same behavior for missed calls (`typeCall=3`); +- no override when hosts differ; +- no grouping or override for empty hosts; +- no DID refinement for outgoing calls; +- fallback to the line provider for missing and ambiguous DID matches; +- preservation of existing normalization behavior. diff --git a/tests/TrunkResolverTest.php b/tests/TrunkResolverTest.php index 48bea0c..b168805 100644 --- a/tests/TrunkResolverTest.php +++ b/tests/TrunkResolverTest.php @@ -15,30 +15,58 @@ function trunkAssert($expected, $actual, string $message): void } $resolver = new TrunkResolver([ - ['uniqid' => 'SIP-A', 'username' => '+7 (495) 111-22-33', 'description' => 'Provider A'], - ['uniqid' => 'SIP-B', 'username' => '74952223344', 'description' => 'Provider B'], + [ + 'uniqid' => 'SIP-A', + 'username' => '+7 (495) 111-22-33', + 'description' => 'Provider A', + 'host' => ' Shared.Example.com ', + ], + [ + 'uniqid' => 'SIP-B', + 'username' => '74952223344', + 'description' => 'Provider B', + 'host' => 'shared.example.com', + ], ]); $line = $resolver->resolve(['line' => 'SIP-A', 'did' => '74952223344'], 'incoming'); -trunkAssert('Provider A', $line['name'], 'stable line id has highest priority'); -trunkAssert('line_id', $line['source'], 'line id evidence source'); +trunkAssert('Provider B', $line['name'], 'DID refines provider on shared host'); +trunkAssert('SIP-B', $line['id'], 'refined provider id'); +trunkAssert('did_username', $line['source'], 'shared host DID evidence source'); -$did = $resolver->resolve(['line' => 'unknown-peer', 'did' => '7 495 222-33-44'], 'incoming'); -trunkAssert('Provider B', $did['name'], 'incoming unique DID resolves provider'); -trunkAssert('did_username', $did['source'], 'DID evidence source'); -$storedType = $resolver->resolve(['line' => 'unknown-peer', 'did' => '74952223344'], '2'); -trunkAssert('Provider B', $storedType['name'], 'stored incoming call type resolves DID'); +$missed = $resolver->resolve(['line' => 'SIP-A', 'did' => '7 495 222-33-44'], '3'); +trunkAssert('Provider B', $missed['name'], 'missed call refines provider on shared host'); -$outgoing = $resolver->resolve(['line' => 'unknown-peer', 'did' => '74952223344'], 'outgoing'); -trunkAssert('unresolved', $outgoing['status'], 'outgoing call does not use DID'); +$outgoing = $resolver->resolve(['line' => 'SIP-A', 'did' => '74952223344'], 'outgoing'); +trunkAssert('Provider A', $outgoing['name'], 'outgoing call retains line provider'); +trunkAssert('line_id', $outgoing['source'], 'outgoing line evidence source'); + +$unknown = $resolver->resolve(['line' => 'unknown-peer', 'did' => '74952223344'], 'incoming'); +trunkAssert('unresolved', $unknown['status'], 'unknown line does not trigger global DID lookup'); + +$differentHosts = new TrunkResolver([ + ['uniqid' => 'SIP-A', 'username' => '100', 'description' => 'Provider A', 'host' => 'a.example.com'], + ['uniqid' => 'SIP-B', 'username' => '200', 'description' => 'Provider B', 'host' => 'b.example.com'], +]); +$differentHost = $differentHosts->resolve(['line' => 'SIP-A', 'did' => '200'], '2'); +trunkAssert('Provider A', $differentHost['name'], 'DID on another host does not override line'); + +$emptyHosts = new TrunkResolver([ + ['uniqid' => 'SIP-A', 'username' => '100', 'description' => 'Provider A', 'host' => ''], + ['uniqid' => 'SIP-B', 'username' => '200', 'description' => 'Provider B', 'host' => ''], +]); +$emptyHost = $emptyHosts->resolve(['line' => 'SIP-A', 'did' => '200'], '2'); +trunkAssert('Provider A', $emptyHost['name'], 'empty hosts are not grouped'); $ambiguous = new TrunkResolver([ - ['uniqid' => 'SIP-A', 'username' => '100500', 'description' => 'Provider A'], - ['uniqid' => 'SIP-B', 'username' => '100500', 'description' => 'Provider B'], + ['uniqid' => 'SIP-A', 'username' => '100500', 'description' => 'Provider A', 'host' => 'shared.example.com'], + ['uniqid' => 'SIP-B', 'username' => '100500', 'description' => 'Provider B', 'host' => 'shared.example.com'], ]); -$result = $ambiguous->resolve(['line' => 'shared-peer', 'did' => '100500'], 'incoming'); -trunkAssert('ambiguous', $result['status'], 'duplicate usernames are ambiguous'); -trunkAssert('shared-peer', $result['name'], 'ambiguous result preserves technical value'); -trunkAssert(2, count($result['candidates']), 'ambiguous candidates exposed'); +$result = $ambiguous->resolve(['line' => 'SIP-A', 'did' => '100500'], 'incoming'); +trunkAssert('Provider A', $result['name'], 'ambiguous shared-host DID retains line provider'); +trunkAssert('line_id', $result['source'], 'ambiguous match falls back to line evidence'); + +$missingDid = $resolver->resolve(['line' => 'SIP-A', 'did' => ''], 'incoming'); +trunkAssert('Provider A', $missingDid['name'], 'missing DID retains line provider'); echo "TrunkResolverTest: OK\n";