Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update dataset + handle multiple cities and cantons per zipcode #47

Merged
merged 7 commits into from
May 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ $canton = $cantonManager->getByAbbreviation('GR');

### `getByName()`

Search for a Canton by it's name. The name must exactly match one of the translations of the Canton (German, French, Italian, Romansh or English).
Search for a Canton by its name. The name must exactly match one of the translations of the Canton (German, French, Italian, Romansh or English).

```php
$cantonManager = new Wnx\SwissCantons\CantonManager();
Expand All @@ -64,13 +64,25 @@ $canton = $cantonManager->getByName('Zürich');

### `getByZipcode()`

Search for a Canton by a zipcode.
Returns an array of possible Cantons for a given Zipcode. (Some zipcodes are shared between multiple Cantons).

```php
$cantonManager = new Wnx\SwissCantons\CantonManager();

/** @var \Wnx\SwissCantons\Canton[] $cantons */
$cantons = $cantonManager->getByZipcode(3005);
```

### `getByZipcodeAndCity()`

Find Canton by a given zipcode and optionally by a city name.

```php
$cantonManager = new Wnx\SwissCantons\CantonManager();

/** @var \Wnx\SwissCantons\Canton $canton */
$canton = $cantonManager->getByZipcode(3005);
$canton = $cantonManager->getByZipcodeAndCity(1003);
$canton = $cantonManager->getByZipcodeAndCity(1290, 'Lausanne');
```

## `Canton`
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
],
"require": {
"php": "^8.2",
"ext-json": "*"
"ext-json": "*",
"ext-zip": "*",
"symfony/http-client": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^10.1",
Expand Down
73 changes: 58 additions & 15 deletions src/CantonManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,30 @@

namespace Wnx\SwissCantons;

use Wnx\SwissCantons\Exceptions\CantonException;
use Wnx\SwissCantons\Exceptions\CantonNotFoundException;

class CantonManager
{
protected CantonSearch $search;

protected ZipcodeSearch $zipcodeSearch;
protected CitySearch $citySearch;

public function __construct()
{
$this->search = new CantonSearch();
$this->zipcodeSearch = new ZipcodeSearch();
$this->citySearch = new CitySearch();
}

/**
* Get Canton by abbreviation.
*
* @throws CantonException
* @throws CantonNotFoundException
*/
public function getByAbbreviation(string $abbreviation): Canton
{
$result = $this->search->findByAbbreviation($abbreviation);

if (is_null($result)) {
throw CantonException::notFoundForAbbreviation($abbreviation);
throw CantonNotFoundException::notFoundForAbbreviation($abbreviation);
}

return $result;
Expand All @@ -35,32 +34,76 @@ public function getByAbbreviation(string $abbreviation): Canton
/**
* Get Canton by Name.
*
* @throws CantonException
* @throws CantonNotFoundException
*/
public function getByName(string $name): Canton
{
$result = $this->search->findByName($name);

if (is_null($result)) {
throw CantonException::notFoundForName($name);
throw CantonNotFoundException::notFoundForName($name);
}

return $result;
}

/**
* Get Canton by Zipcode.
* Get possible Cantons with a Zipcode.
*
* @throws CantonException
* @param int $zipcode
* @return Canton[]
* @throws CantonNotFoundException
*/
public function getByZipcode(int $zipcode): Canton
public function getByZipcode(int $zipcode): array
{
$result = $this->zipcodeSearch->findByZipcode($zipcode);
$cities = $this->citySearch->findByZipcode($zipcode);

if (is_null($result)) {
throw CantonException::notFoundForZipcode($zipcode);
// Get cantons abbreviations
$cantonAbbreviations = array_column($cities, 'canton');

// Remove duplicates
$cantonAbbreviations = array_unique($cantonAbbreviations);

// Search cantons by abbreviation
$cantons = array_map(fn (string $abbreviation) => $this->search->findByAbbreviation($abbreviation), $cantonAbbreviations);

// Call 'array_filter' without callback to remove null values
$cantons = array_filter($cantons);

if (empty($cantons)) {
throw CantonNotFoundException::notFoundForZipcode($zipcode);
}

return $cantons;
}

/**
* @param int $zipcode
* @param ?string $cityName
* @return Canton
* @throws CantonNotFoundException
*/
public function getByZipcodeAndCity(int $zipcode, ?string $cityName = null): Canton
{
$cities = $this->citySearch->findByZipcode($zipcode);

if (1 === count($cities)) {
return $this->search->findByAbbreviation($cities[0]['canton'])
?? throw CantonNotFoundException::notFoundForZipcode($zipcode);
}

return $this->getByAbbreviation($result['canton']);
if (null !== $cityName) {
foreach ($cities as $city) {
if ($city['city'] === $cityName) {
return $this->search->findByAbbreviation($city['canton'])
?? throw CantonNotFoundException::notFoundForZipcodeAndCity($zipcode, $cityName);
}
}

throw CantonNotFoundException::notFoundForZipcodeAndCity($zipcode, $cityName);
}

throw CantonNotFoundException::notFoundForZipcode($zipcode);
}

}
8 changes: 4 additions & 4 deletions src/CantonSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

class CantonSearch
{
protected array $dataSet;
protected array $dataset;

public function __construct()
{
$this->dataSet = (new Cantons())->getAll();
$this->dataset = (new Cantons())->getAll();
}

public function findByAbbreviation(string $abbreviation): ?Canton
{
$result = array_filter($this->dataSet, fn (Canton $canton) => $canton->getAbbreviation() === strtoupper($abbreviation));
$result = array_filter($this->dataset, fn (Canton $canton) => $canton->getAbbreviation() === strtoupper($abbreviation));

if (count($result) === 0) {
return null;
Expand All @@ -24,7 +24,7 @@ public function findByAbbreviation(string $abbreviation): ?Canton

public function findByName(string $name): ?Canton
{
$result = array_filter($this->dataSet, fn (Canton $canton) => in_array($name, $canton->getNamesArray()));
$result = array_filter($this->dataset, fn (Canton $canton) => in_array($name, $canton->getNamesArray()));

if (count($result) === 0) {
return null;
Expand Down
44 changes: 44 additions & 0 deletions src/CitySearch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types=1);

namespace Wnx\SwissCantons;

/**
* @phpstan-type City array{canton: string, city: string, zipcode: int}
*/
class CitySearch
{
/** @var City[] */
protected array $dataset;

public function __construct()
{
$this->dataset = $this->loadDataset();
}

/**
* Find Data Set for a City by Zipcode.
*
* @return City[]
*/
public function findByZipcode(int $zipcode): array
{
return array_values(array_filter($this->dataset, fn (array $city) => $city['zipcode'] === $zipcode));
}

/**
* @return City[]
* @throws \JsonException
*/
private function loadDataset(): array
{
return json_decode(file_get_contents(__DIR__.'/data/cities.json'), true, 512, JSON_THROW_ON_ERROR);
}

/**
* @return City[]
*/
public function getDataSet(): array
{
return $this->dataset;
}
}
88 changes: 70 additions & 18 deletions src/Console/UpdateZipcodeDatasetCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,34 @@
use League\Csv\Reader;
use League\Csv\Statement;
use League\Csv\TabularDataReader;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use ZipArchive;

class UpdateZipcodeDatasetCommand extends Command
{
final public const PATH_TO_CSV = __DIR__ . '/../data/zipcodes.csv';
final public const PATH_TO_JSON = __DIR__ . '/../data/zipcodes.json';
final public const PATH_TO_CSV = __DIR__ . '/../data/cities.csv';
final public const PATH_TO_JSON = __DIR__ . '/../data/cities.json';

private HttpClientInterface $httpClient;

public function __construct(?HttpClientInterface $httpClient = null)
{
parent::__construct();
$this->httpClient = $httpClient ?? HttpClient::create();
}

protected function configure(): void
{
$this
->setName('update-zipcode-dataset')
->setDescription('Fetch dataset from Swiss Post and create zipcodes.json file');
->setName('update-cities-dataset')
->setDescription('Fetch dataset from Swiss Post and create cities.json file');
}

/**
Expand All @@ -32,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln('🚧 Fetch dataset');
$this->fetchDataset();

$output->writeln('🔮 Create zipcodes.json');
$output->writeln('🔮 Create cities.json');
$records = $this->parseCsvDataset();
$this->generateZipcodesFiles($records);

Expand All @@ -44,13 +58,56 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 0;
}

/**
* @return void
* @throws \Throwable
*/
protected function fetchDataset(): void
{
$urlToDataset = "https://swisspost.opendatasoft.com/explore/dataset/plz_verzeichnis_v2/download/?format=csv&timezone=Europe/Berlin&lang=de&use_labels_for_header=true&csv_separator=%3B";
$urlToDataset = "https://data.geo.admin.ch/ch.swisstopo-vd.ortschaftenverzeichnis_plz/ortschaftenverzeichnis_plz/ortschaftenverzeichnis_plz_2056.csv.zip";

$response = $this->httpClient->request('GET', $urlToDataset);

$response = file_get_contents($urlToDataset);
// Check for successful response (200 OK)
if ($response->getStatusCode() !== 200) {
throw new RuntimeException("Failed to download file. Status code: " . $response->getStatusCode());
}

// Open the destination file for writing in binary mode
$fileHandler = fopen('/tmp/dataset.zip', 'wb');
if (!$fileHandler) {
throw new RuntimeException("Failed to open file for writing: '/tmp/dataset.zip'");
}

file_put_contents(self::PATH_TO_CSV, $response);
foreach ($this->httpClient->stream($response) as $chunk) {
fwrite($fileHandler, $chunk->getContent());
}

fclose($fileHandler);

$zip = new ZipArchive();
$res = $zip->open('/tmp/dataset.zip');
if ($res) {
$zip->extractTo('/tmp/extracted_dataset');
$zip->close();
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator('/tmp/extracted_dataset'),
RecursiveIteratorIterator::SELF_FIRST
);

foreach ($iterator as $file) {
if ($file->isFile() && pathinfo($file->getPathname(), PATHINFO_EXTENSION) === 'csv') {
$destinationFile = self::PATH_TO_CSV;
if (!rename($file->getPathname(), $destinationFile)) {
// Handle potential errors during move operation
throw new \Exception("Error moving file: " . $file->getPathname());
}

return;
}
}
}

/**
Expand All @@ -64,13 +121,7 @@ protected function parseCsvDataset(): TabularDataReader
$csv->setHeaderOffset(0);

return Statement::create()
->where(fn ($record) => $record['KANTON'] !== 'FL')
->orderBy(function (array $recordA, array $recordB): int {
if ($recordA['POSTLEITZAHL'] === $recordB["POSTLEITZAHL"]) {
return $recordA['ORTBEZ27'] <=> $recordB['ORTBEZ27'];
}
return $recordA['POSTLEITZAHL'] <=> $recordB['POSTLEITZAHL'];
})
->where(fn ($record) => $record['Kantonskürzel'] !== '')
->process($csv);
}

Expand All @@ -80,9 +131,9 @@ protected function generateZipcodesFiles(TabularDataReader $records): void

foreach ($records as $zipcodeRecord) {
$data[] = [
'city' => $zipcodeRecord['ORTBEZ27'],
'zipcode' => (int) $zipcodeRecord['POSTLEITZAHL'],
'canton' => $zipcodeRecord['KANTON'],
'city' => $zipcodeRecord['Ortschaftsname'],
'zipcode' => (int) $zipcodeRecord['PLZ'],
'canton' => $zipcodeRecord['Kantonskürzel'],
];
}

Expand All @@ -91,6 +142,7 @@ protected function generateZipcodesFiles(TabularDataReader $records): void

protected function cleanup(): void
{
unlink('/tmp/dataset.zip');
unlink(self::PATH_TO_CSV);
}
}
Loading
Loading