From e72e9cd2524611154bc8bceb1619d11e68198033 Mon Sep 17 00:00:00 2001 From: Tomix1 Date: Wed, 1 Oct 2025 22:16:05 +0200 Subject: [PATCH] added endpoint api.procurement.optimal, added tests --- app/Dto/Parameters/OfferParameters.php | 14 +++ .../OptimalProcurementParameters.php | 35 ++++++ app/Dto/Results/OptimalProcurementDto.php | 12 +++ .../V1/Controllers/ProcurementController.php | 24 +++++ .../V1/Requests/OptimalProcurementRequest.php | 20 ++++ .../Resources/OptimalProcurementResource.php | 26 +++++ app/Services/ProcurementService.php | 46 ++++++++ bootstrap/app.php | 1 + routes/api.php | 9 ++ tests/Feature/ProcurementControllerTest.php | 69 ++++++++++++ tests/Unit/ProcurementServiceTest.php | 100 ++++++++++++++++++ 11 files changed, 356 insertions(+) create mode 100644 app/Dto/Parameters/OfferParameters.php create mode 100644 app/Dto/Parameters/OptimalProcurementParameters.php create mode 100644 app/Dto/Results/OptimalProcurementDto.php create mode 100644 app/Http/Api/V1/Controllers/ProcurementController.php create mode 100644 app/Http/Api/V1/Requests/OptimalProcurementRequest.php create mode 100644 app/Http/Api/V1/Resources/OptimalProcurementResource.php create mode 100644 app/Services/ProcurementService.php create mode 100644 routes/api.php create mode 100644 tests/Feature/ProcurementControllerTest.php create mode 100644 tests/Unit/ProcurementServiceTest.php diff --git a/app/Dto/Parameters/OfferParameters.php b/app/Dto/Parameters/OfferParameters.php new file mode 100644 index 0000000..19b8a06 --- /dev/null +++ b/app/Dto/Parameters/OfferParameters.php @@ -0,0 +1,14 @@ + $offers + */ + public function __construct( + public int $need, + public Collection $offers, + ) { + } + + public static function fromRequest(OptimalProcurementRequest $request): static + { + $data = $request->validated(); + + return new static( + need: $data['need'], + offers: collect($data['offers'])->map( + static fn($offer) => new OfferParameters( + id: $offer['id'], + count: $offer['count'], + price: $offer['price'], + pack: $offer['pack'], + ) + ) + ); + } +} diff --git a/app/Dto/Results/OptimalProcurementDto.php b/app/Dto/Results/OptimalProcurementDto.php new file mode 100644 index 0000000..12a8398 --- /dev/null +++ b/app/Dto/Results/OptimalProcurementDto.php @@ -0,0 +1,12 @@ +calculateOptimal($parameters); + + return OptimalProcurementResource::collection($optimalProcurement); + } +} diff --git a/app/Http/Api/V1/Requests/OptimalProcurementRequest.php b/app/Http/Api/V1/Requests/OptimalProcurementRequest.php new file mode 100644 index 0000000..5e8a835 --- /dev/null +++ b/app/Http/Api/V1/Requests/OptimalProcurementRequest.php @@ -0,0 +1,20 @@ + ['required', 'integer', 'min:1'], + 'offers' => ['required', 'array'], + 'offers.*.id' => ['required', 'integer'], + 'offers.*.count' => ['required', 'integer', 'min:1'], + 'offers.*.price' => ['required', 'integer', 'min:1'], + 'offers.*.pack' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Api/V1/Resources/OptimalProcurementResource.php b/app/Http/Api/V1/Resources/OptimalProcurementResource.php new file mode 100644 index 0000000..3f59cfd --- /dev/null +++ b/app/Http/Api/V1/Resources/OptimalProcurementResource.php @@ -0,0 +1,26 @@ +resource; + + return [ + 'id' => $dto->id, + 'qty' => $dto->qty, + ]; + } +} diff --git a/app/Services/ProcurementService.php b/app/Services/ProcurementService.php new file mode 100644 index 0000000..2cce75c --- /dev/null +++ b/app/Services/ProcurementService.php @@ -0,0 +1,46 @@ + + */ + public function calculateOptimal(OptimalProcurementParameters $parameters): Collection + { + $offers = $parameters->offers->sortBy('price'); + + $result = collect(); + $need = $parameters->need; + + foreach ($offers as $offer) { + if ($need <= 0) { + break; + } + + $maxNeedFromOffer = min($offer->count, $need); + + $maxByPack = (int)(floor($maxNeedFromOffer / $offer->pack) * $offer->pack); + + if ($maxByPack > 0) { + $result[] = new OptimalProcurementDto( + id: $offer->id, + qty: $maxByPack, + ); + + $need -= $maxByPack; + } + } + + if ($need > 0) { + return collect(); + } + + return $result->sortBy('id'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..d77e031 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,6 +9,7 @@ web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', + api: __DIR__ . '/../routes/api.php' ) ->withMiddleware(function (Middleware $middleware): void { // diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..851c382 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,9 @@ +group(function () { + Route::get('procurement/optimal', [ProcurementController::class, 'optimal']) + ->name('api.procurement.optimal'); +}); + diff --git a/tests/Feature/ProcurementControllerTest.php b/tests/Feature/ProcurementControllerTest.php new file mode 100644 index 0000000..e2f85cd --- /dev/null +++ b/tests/Feature/ProcurementControllerTest.php @@ -0,0 +1,69 @@ + 76, + 'offers' => [ + ['id' => 111, 'count' => 42, 'price' => 13, 'pack' => 1], + ['id' => 222, 'count' => 77, 'price' => 11, 'pack' => 10], + ['id' => 333, 'count' => 103, 'price' => 10, 'pack' => 50], + ['id' => 444, 'count' => 65, 'price' => 12, 'pack' => 5], + ], + ]; + + $response = $this->getJson(route('api.procurement.optimal', $data)); + + $response->assertStatus(200) + ->assertJson( + [ + 'data' => [ + ['id' => 111, 'qty' => 1], + ['id' => 222, 'qty' => 20], + ['id' => 333, 'qty' => 50], + ['id' => 444, 'qty' => 5], + ], + ] + ); + } + + public function test_invalid_count_procurement_optimal() + { + $data = [ + 'need' => 300, + 'offers' => [ + ['id' => 111, 'count' => 42, 'price' => 13, 'pack' => 1], + ['id' => 222, 'count' => 77, 'price' => 11, 'pack' => 10], + ['id' => 333, 'count' => 103, 'price' => 10, 'pack' => 50], + ['id' => 444, 'count' => 65, 'price' => 12, 'pack' => 5], + ], + ]; + + $response = $this->getJson(route('api.procurement.optimal', $data)); + + $response->assertStatus(200) + ->assertJson([]); + } + + public function test_inappropriate_pack_multiplicity() + { + $data = [ + 'need' => 12, + 'offers' => [ + ['id' => 111, 'count' => 20, 'price' => 10, 'pack' => 5], + ['id' => 222, 'count' => 10, 'price' => 12, 'pack' => 3], + ], + ]; + + $response = $this->getJson(route('api.procurement.optimal', $data)); + + $response->assertStatus(200) + ->assertJson([]); + } +} diff --git a/tests/Unit/ProcurementServiceTest.php b/tests/Unit/ProcurementServiceTest.php new file mode 100644 index 0000000..fa8e632 --- /dev/null +++ b/tests/Unit/ProcurementServiceTest.php @@ -0,0 +1,100 @@ +service = app(ProcurementService::class); + } + + public function test_calculate_optimal_procurement() + { + $need = 76; + $offers = [ + ['id' => 111, 'count' => 42, 'price' => 13, 'pack' => 1], + ['id' => 222, 'count' => 77, 'price' => 11, 'pack' => 10], + ['id' => 333, 'count' => 103, 'price' => 10, 'pack' => 50], + ['id' => 444, 'count' => 65, 'price' => 12, 'pack' => 5], + ]; + + $params = $this->createOptimalProcurementParameters($need, $offers); + + $result = $this->service->calculateOptimal($params); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(4, $result); + + $this->assertContainsOnlyInstancesOf(OptimalProcurementDto::class, $result); + + $expectedOrder = [111, 222, 333, 444]; + $actualOrder = $result->pluck('id')->toArray(); + $this->assertEquals($expectedOrder, $actualOrder); + + $this->assertEquals(1, $result->first(fn($dto) => $dto->id === 111)->qty); + $this->assertEquals(20, $result->first(fn($dto) => $dto->id === 222)->qty); + $this->assertEquals(50, $result->first(fn($dto) => $dto->id === 333)->qty); + $this->assertEquals(5, $result->first(fn($dto) => $dto->id === 444)->qty); + } + + public function test_invalid_count_procurement_optimal() + { + $need = 300; + $offers = [ + ['id' => 111, 'count' => 42, 'price' => 13, 'pack' => 1], + ['id' => 222, 'count' => 77, 'price' => 11, 'pack' => 10], + ['id' => 333, 'count' => 103, 'price' => 10, 'pack' => 50], + ['id' => 444, 'count' => 65, 'price' => 12, 'pack' => 5], + ]; + + $params = $this->createOptimalProcurementParameters($need, $offers); + + $result = $this->service->calculateOptimal($params); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + public function test_inappropriate_pack_multiplicity() + { + $need = 12; + $offers = [ + ['id' => 111, 'count' => 20, 'price' => 10, 'pack' => 5], + ['id' => 222, 'count' => 10, 'price' => 12, 'pack' => 3], + ]; + + $params = $this->createOptimalProcurementParameters($need, $offers); + + $result = $this->service->calculateOptimal($params); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + private function createOptimalProcurementParameters(int $need, array $offers): OptimalProcurementParameters + { + return new OptimalProcurementParameters( + need: $need, + offers: collect($offers)->map( + static fn($offer) => new OfferParameters( + id: $offer['id'], + count: $offer['count'], + price: $offer['price'], + pack: $offer['pack'], + ) + ) + ); + } +}