ver. 0.296: REST API for ordersPRO — orders management, dictionaries, API key auth
- New API layer: ApiRouter, OrdersApiController, DictionariesApiController - Orders API: list (with filters/pagination/updated_since), details, change status, set paid/unpaid - Dictionaries API: order statuses, transport methods, payment methods - X-Api-Key authentication via pp_settings.api_key - OrderRepository: listForApi(), findForApi(), touchUpdatedAt() - updated_at column on pp_shop_orders for polling support - api.php: skip session for API requests, route to ApiRouter - SettingsController: api_key field in system tab - 30 new tests (666 total, 1930 assertions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -311,6 +311,7 @@ class OrderRepository
|
||||
}
|
||||
|
||||
$this->db->update('pp_shop_orders', ['notes' => $notes], ['id' => $orderId]);
|
||||
$this->touchUpdatedAt($orderId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -370,6 +371,8 @@ class OrderRepository
|
||||
'id' => $orderId,
|
||||
]);
|
||||
|
||||
$this->touchUpdatedAt($orderId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -688,6 +691,7 @@ class OrderRepository
|
||||
'coupon_id' => $coupon ? $coupon->id : null,
|
||||
'message' => $basket_message ? $basket_message : null,
|
||||
'apilo_order_status_date' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => $order_date,
|
||||
]);
|
||||
|
||||
$order_id = $this->db->id();
|
||||
@@ -832,16 +836,22 @@ class OrderRepository
|
||||
public function setAsPaid(int $orderId): void
|
||||
{
|
||||
$this->db->update('pp_shop_orders', ['paid' => 1], ['id' => $orderId]);
|
||||
$this->touchUpdatedAt($orderId);
|
||||
}
|
||||
|
||||
public function setAsUnpaid(int $orderId): void
|
||||
{
|
||||
$this->db->update('pp_shop_orders', ['paid' => 0], ['id' => $orderId]);
|
||||
$this->touchUpdatedAt($orderId);
|
||||
}
|
||||
|
||||
public function updateOrderStatus(int $orderId, int $status): bool
|
||||
{
|
||||
return (bool)$this->db->update('pp_shop_orders', ['status' => $status], ['id' => $orderId]);
|
||||
$result = (bool)$this->db->update('pp_shop_orders', ['status' => $status], ['id' => $orderId]);
|
||||
if ($result) {
|
||||
$this->touchUpdatedAt($orderId);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function insertStatusHistory(int $orderId, int $statusId, int $mail): void
|
||||
@@ -858,6 +868,145 @@ class OrderRepository
|
||||
$this->db->update('pp_shop_orders', ['apilo_order_status_date' => $date], ['id' => $orderId]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// API methods (for ordersPRO)
|
||||
// =========================================================================
|
||||
|
||||
public function listForApi(array $filters, int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$perPage = min(self::MAX_PER_PAGE, max(1, $perPage));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = trim((string)($filters['status'] ?? ''));
|
||||
if ($status !== '' && is_numeric($status)) {
|
||||
$where[] = 'o.status = :status';
|
||||
$params[':status'] = (int)$status;
|
||||
}
|
||||
|
||||
$paid = trim((string)($filters['paid'] ?? ''));
|
||||
if ($paid !== '' && is_numeric($paid)) {
|
||||
$where[] = 'o.paid = :paid';
|
||||
$params[':paid'] = (int)$paid;
|
||||
}
|
||||
|
||||
$dateFrom = $this->normalizeDateFilter($filters['date_from'] ?? '');
|
||||
if ($dateFrom !== null) {
|
||||
$where[] = 'o.date_order >= :date_from';
|
||||
$params[':date_from'] = $dateFrom . ' 00:00:00';
|
||||
}
|
||||
|
||||
$dateTo = $this->normalizeDateFilter($filters['date_to'] ?? '');
|
||||
if ($dateTo !== null) {
|
||||
$where[] = 'o.date_order <= :date_to';
|
||||
$params[':date_to'] = $dateTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$updatedSince = trim((string)($filters['updated_since'] ?? ''));
|
||||
if ($updatedSince !== '' && preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/', $updatedSince)) {
|
||||
$where[] = 'o.updated_at >= :updated_since';
|
||||
$params[':updated_since'] = $updatedSince;
|
||||
}
|
||||
|
||||
$number = $this->normalizeTextFilter($filters['number'] ?? '');
|
||||
if ($number !== '') {
|
||||
$where[] = 'o.number LIKE :number';
|
||||
$params[':number'] = '%' . $number . '%';
|
||||
}
|
||||
|
||||
$client = $this->normalizeTextFilter($filters['client'] ?? '');
|
||||
if ($client !== '') {
|
||||
$where[] = "(o.client_name LIKE :client OR o.client_surname LIKE :client2 OR o.client_email LIKE :client3)";
|
||||
$params[':client'] = '%' . $client . '%';
|
||||
$params[':client2'] = '%' . $client . '%';
|
||||
$params[':client3'] = '%' . $client . '%';
|
||||
}
|
||||
|
||||
$whereSql = '';
|
||||
if (!empty($where)) {
|
||||
$whereSql = ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
|
||||
$sqlCount = 'SELECT COUNT(0) FROM pp_shop_orders AS o' . $whereSql;
|
||||
$stmtCount = $this->db->query($sqlCount, $params);
|
||||
$countRows = $stmtCount ? $stmtCount->fetchAll() : [];
|
||||
$total = 0;
|
||||
if (is_array($countRows) && isset($countRows[0]) && is_array($countRows[0])) {
|
||||
$firstValue = reset($countRows[0]);
|
||||
$total = $firstValue !== false ? (int)$firstValue : 0;
|
||||
}
|
||||
|
||||
$sql = 'SELECT o.id, o.number, o.date_order, o.updated_at, o.status, o.paid,'
|
||||
. ' o.client_name, o.client_surname, o.client_email, o.client_phone,'
|
||||
. ' o.client_street, o.client_postal_code, o.client_city,'
|
||||
. ' o.firm_name, o.firm_nip,'
|
||||
. ' o.transport, o.transport_cost, o.payment_method, o.summary'
|
||||
. ' FROM pp_shop_orders AS o'
|
||||
. $whereSql
|
||||
. ' ORDER BY o.updated_at DESC, o.id DESC'
|
||||
. ' LIMIT ' . $perPage . ' OFFSET ' . $offset;
|
||||
|
||||
$stmt = $this->db->query($sql, $params);
|
||||
$items = ($stmt) ? $stmt->fetchAll() : [];
|
||||
if (!is_array($items)) {
|
||||
$items = [];
|
||||
}
|
||||
|
||||
foreach ($items as &$item) {
|
||||
$item['id'] = (int)($item['id'] ?? 0);
|
||||
$item['status'] = (int)($item['status'] ?? 0);
|
||||
$item['paid'] = (int)($item['paid'] ?? 0);
|
||||
$item['summary'] = (float)($item['summary'] ?? 0);
|
||||
$item['transport_cost'] = (float)($item['transport_cost'] ?? 0);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
];
|
||||
}
|
||||
|
||||
public function findForApi(int $orderId): ?array
|
||||
{
|
||||
if ($orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = $this->db->get('pp_shop_orders', '*', ['id' => $orderId]);
|
||||
if (!is_array($order)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order['id'] = (int)($order['id'] ?? 0);
|
||||
$order['status'] = (int)($order['status'] ?? 0);
|
||||
$order['paid'] = (int)($order['paid'] ?? 0);
|
||||
$order['summary'] = (float)($order['summary'] ?? 0);
|
||||
$order['transport_cost'] = (float)($order['transport_cost'] ?? 0);
|
||||
$order['products'] = $this->orderProducts($orderId);
|
||||
$order['statuses'] = $this->orderStatusHistory($orderId);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
public function touchUpdatedAt(int $orderId): void
|
||||
{
|
||||
if ($orderId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('pp_shop_orders', [
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], [
|
||||
'id' => $orderId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
@@ -444,6 +444,10 @@ class SettingsController
|
||||
'label' => 'Htaccess cache',
|
||||
'tab' => 'system',
|
||||
]),
|
||||
FormField::text('api_key', [
|
||||
'label' => 'Klucz API (ordersPRO)',
|
||||
'tab' => 'system',
|
||||
]),
|
||||
|
||||
FormField::text('google_tag_manager_id', [
|
||||
'label' => 'Google Tag Manager - ID',
|
||||
|
||||
144
autoload/api/ApiRouter.php
Normal file
144
autoload/api/ApiRouter.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
namespace api;
|
||||
|
||||
use Domain\Settings\SettingsRepository;
|
||||
|
||||
class ApiRouter
|
||||
{
|
||||
private $db;
|
||||
private $settingsRepo;
|
||||
|
||||
public function __construct($db, SettingsRepository $settingsRepo)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->settingsRepo = $settingsRepo;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->authenticate()) {
|
||||
self::sendError('UNAUTHORIZED', 'Invalid or missing API key', 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$endpoint = trim((string)($_GET['endpoint'] ?? ''));
|
||||
$action = trim((string)($_GET['action'] ?? ''));
|
||||
|
||||
if ($endpoint === '' || $action === '') {
|
||||
self::sendError('BAD_REQUEST', 'Missing endpoint or action parameter', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$controller = $this->resolveController($endpoint);
|
||||
if ($controller === null) {
|
||||
self::sendError('NOT_FOUND', 'Unknown endpoint: ' . $endpoint, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!method_exists($controller, $action)) {
|
||||
self::sendError('NOT_FOUND', 'Unknown action: ' . $action, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$controller->$action();
|
||||
} catch (\Exception $e) {
|
||||
self::sendError('INTERNAL_ERROR', 'Internal server error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function authenticate(): bool
|
||||
{
|
||||
$headerKey = isset($_SERVER['HTTP_X_API_KEY']) ? $_SERVER['HTTP_X_API_KEY'] : '';
|
||||
if ($headerKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$storedKey = $this->settingsRepo->getSingleValue('api_key');
|
||||
if ($storedKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($storedKey, $headerKey);
|
||||
}
|
||||
|
||||
private function resolveController(string $endpoint)
|
||||
{
|
||||
$factories = $this->getControllerFactories();
|
||||
|
||||
if (!isset($factories[$endpoint])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $factories[$endpoint]();
|
||||
}
|
||||
|
||||
private function getControllerFactories(): array
|
||||
{
|
||||
$db = $this->db;
|
||||
|
||||
return [
|
||||
'orders' => function () use ($db) {
|
||||
$orderRepo = new \Domain\Order\OrderRepository($db);
|
||||
$settingsRepo = new \Domain\Settings\SettingsRepository($db);
|
||||
$productRepo = new \Domain\Product\ProductRepository($db);
|
||||
$transportRepo = new \Domain\Transport\TransportRepository($db);
|
||||
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
|
||||
return new Controllers\OrdersApiController($service, $orderRepo);
|
||||
},
|
||||
'dictionaries' => function () use ($db) {
|
||||
$statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db);
|
||||
$transportRepo = new \Domain\Transport\TransportRepository($db);
|
||||
$paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db);
|
||||
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Static response helpers
|
||||
// =========================================================================
|
||||
|
||||
public static function sendSuccess($data): void
|
||||
{
|
||||
http_response_code(200);
|
||||
echo json_encode(['status' => 'ok', 'data' => $data], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public static function sendError(string $code, string $message, int $httpCode = 400): void
|
||||
{
|
||||
http_response_code($httpCode);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public static function getJsonBody(): ?array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === '' || $raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public static function requireMethod(string $method): bool
|
||||
{
|
||||
$requestMethod = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
|
||||
if ($requestMethod !== strtoupper($method)) {
|
||||
self::sendError('METHOD_NOT_ALLOWED', 'Method ' . $requestMethod . ' not allowed, expected ' . strtoupper($method), 405);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
82
autoload/api/Controllers/DictionariesApiController.php
Normal file
82
autoload/api/Controllers/DictionariesApiController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
namespace api\Controllers;
|
||||
|
||||
use api\ApiRouter;
|
||||
use Domain\ShopStatus\ShopStatusRepository;
|
||||
use Domain\Transport\TransportRepository;
|
||||
use Domain\PaymentMethod\PaymentMethodRepository;
|
||||
|
||||
class DictionariesApiController
|
||||
{
|
||||
private $statusRepo;
|
||||
private $transportRepo;
|
||||
private $paymentRepo;
|
||||
|
||||
public function __construct(
|
||||
ShopStatusRepository $statusRepo,
|
||||
TransportRepository $transportRepo,
|
||||
PaymentMethodRepository $paymentRepo
|
||||
) {
|
||||
$this->statusRepo = $statusRepo;
|
||||
$this->transportRepo = $transportRepo;
|
||||
$this->paymentRepo = $paymentRepo;
|
||||
}
|
||||
|
||||
public function statuses(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = $this->statusRepo->allStatuses();
|
||||
|
||||
$result = [];
|
||||
foreach ($statuses as $id => $name) {
|
||||
$result[] = [
|
||||
'id' => (int)$id,
|
||||
'name' => (string)$name,
|
||||
];
|
||||
}
|
||||
|
||||
ApiRouter::sendSuccess($result);
|
||||
}
|
||||
|
||||
public function transports(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$transports = $this->transportRepo->allActive();
|
||||
|
||||
$result = [];
|
||||
foreach ($transports as $transport) {
|
||||
$result[] = [
|
||||
'id' => (int)($transport['id'] ?? 0),
|
||||
'name' => (string)($transport['name_visible'] ?? $transport['name'] ?? ''),
|
||||
'cost' => (float)($transport['cost'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
ApiRouter::sendSuccess($result);
|
||||
}
|
||||
|
||||
public function payment_methods(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$methods = $this->paymentRepo->allActive();
|
||||
|
||||
$result = [];
|
||||
foreach ($methods as $method) {
|
||||
$result[] = [
|
||||
'id' => (int)($method['id'] ?? 0),
|
||||
'name' => (string)($method['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
ApiRouter::sendSuccess($result);
|
||||
}
|
||||
}
|
||||
154
autoload/api/Controllers/OrdersApiController.php
Normal file
154
autoload/api/Controllers/OrdersApiController.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
namespace api\Controllers;
|
||||
|
||||
use api\ApiRouter;
|
||||
use Domain\Order\OrderAdminService;
|
||||
use Domain\Order\OrderRepository;
|
||||
|
||||
class OrdersApiController
|
||||
{
|
||||
private $service;
|
||||
private $orderRepo;
|
||||
|
||||
public function __construct(OrderAdminService $service, OrderRepository $orderRepo)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->orderRepo = $orderRepo;
|
||||
}
|
||||
|
||||
public function list(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filters = [
|
||||
'status' => isset($_GET['status']) ? $_GET['status'] : '',
|
||||
'paid' => isset($_GET['paid']) ? $_GET['paid'] : '',
|
||||
'date_from' => isset($_GET['date_from']) ? $_GET['date_from'] : '',
|
||||
'date_to' => isset($_GET['date_to']) ? $_GET['date_to'] : '',
|
||||
'updated_since' => isset($_GET['updated_since']) ? $_GET['updated_since'] : '',
|
||||
'number' => isset($_GET['number']) ? $_GET['number'] : '',
|
||||
'client' => isset($_GET['client']) ? $_GET['client'] : '',
|
||||
];
|
||||
|
||||
$page = max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1));
|
||||
$perPage = max(1, min(100, (int)(isset($_GET['per_page']) ? $_GET['per_page'] : 50)));
|
||||
|
||||
$result = $this->orderRepo->listForApi($filters, $page, $perPage);
|
||||
|
||||
ApiRouter::sendSuccess($result);
|
||||
}
|
||||
|
||||
public function get(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
|
||||
if ($id <= 0) {
|
||||
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $this->orderRepo->findForApi($id);
|
||||
if ($order === null) {
|
||||
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
ApiRouter::sendSuccess($order);
|
||||
}
|
||||
|
||||
public function change_status(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('PUT')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
|
||||
if ($id <= 0) {
|
||||
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$body = ApiRouter::getJsonBody();
|
||||
if ($body === null || !isset($body['status_id'])) {
|
||||
ApiRouter::sendError('BAD_REQUEST', 'Missing status_id in request body', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$statusId = (int)$body['status_id'];
|
||||
$sendEmail = !empty($body['send_email']);
|
||||
|
||||
$order = $this->orderRepo->findRawById($id);
|
||||
if ($order === null) {
|
||||
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->service->changeStatus($id, $statusId, $sendEmail);
|
||||
|
||||
ApiRouter::sendSuccess([
|
||||
'order_id' => $id,
|
||||
'status_id' => $statusId,
|
||||
'changed' => !empty($result['result']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function set_paid(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('PUT')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
|
||||
if ($id <= 0) {
|
||||
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $this->orderRepo->findRawById($id);
|
||||
if ($order === null) {
|
||||
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$body = ApiRouter::getJsonBody();
|
||||
$sendEmail = ($body !== null && !empty($body['send_email']));
|
||||
|
||||
$this->service->setOrderAsPaid($id, $sendEmail);
|
||||
|
||||
ApiRouter::sendSuccess([
|
||||
'order_id' => $id,
|
||||
'paid' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function set_unpaid(): void
|
||||
{
|
||||
if (!ApiRouter::requireMethod('PUT')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
|
||||
if ($id <= 0) {
|
||||
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $this->orderRepo->findRawById($id);
|
||||
if ($order === null) {
|
||||
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->service->setOrderAsUnpaid($id);
|
||||
|
||||
ApiRouter::sendSuccess([
|
||||
'order_id' => $id,
|
||||
'paid' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user