Skip to content

Commit

Permalink
Update dataset + handle multiple cities and cantons per zipcode (#47)
Browse files Browse the repository at this point in the history
* Fix UpdateZipcodeDatasetCommand.php

* Change methods in order to retrieve list of cities based of a zipcode

* New findOnBy method in CantonManager.php

* Exit loop when csv file has been found in UpdateZipcodeDatasetCommand

* Add Docs for new findOneBy Method

* Rename Method

* Use "Ortschaftsname" as city name

---------

Co-authored-by: Stefan Zweifel <[email protected]>
  • Loading branch information
kira0269 and stefanzweifel authored May 26, 2024
1 parent f895d8e commit 9ea3da5
Show file tree
Hide file tree
Showing 12 changed files with 18,052 additions and 15,660 deletions.
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

0 comments on commit 9ea3da5

Please sign in to comment.