Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae1fc3fcf | ||
|
|
f262047476 | ||
|
|
b8390ac0d3 | ||
|
|
0d4a5470e5 | ||
|
|
845ca6e48e | ||
|
|
9f797bf6b8 | ||
|
|
d345db927f | ||
|
|
eca7a81f9d | ||
|
|
62f2c80dda | ||
|
|
430c90cbca |
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class customer_order_product_policy
|
||||||
|
{
|
||||||
|
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||||
|
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||||
|
|
||||||
|
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||||
|
{
|
||||||
|
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||||
|
if ($message !== null) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||||
|
{
|
||||||
|
$context = self::loadOrderProductContext($orderId, $productId);
|
||||||
|
if ($context === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||||
|
? self::ONLY_TANKCLEANING_MESSAGE
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||||
|
{
|
||||||
|
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isTankCleaningProductRow(array $row): bool
|
||||||
|
{
|
||||||
|
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||||
|
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if ($orderId < 1 || $productId < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
o.id AS order_id,
|
||||||
|
o.customer_id AS customer_number,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
p.category AS product_category,
|
||||||
|
c.name AS category_name,
|
||||||
|
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN products p ON p.id = {$productId}
|
||||||
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
|
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||||
|
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||||
|
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||||
|
WHERE o.id = {$orderId}
|
||||||
|
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result || $result->num_rows < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||||
|
{
|
||||||
|
$haystack = strtolower(trim(
|
||||||
|
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||||
|
(string)($row['category_name'] ?? '')
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2050,8 +2050,7 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function rowIsTankCleaningProduct(array $row): bool
|
private function rowIsTankCleaningProduct(array $row): bool
|
||||||
{
|
{
|
||||||
return (int)($row['product_category'] ?? 0) === 5
|
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isIncludedOrderItem(array $row): bool
|
private function isIncludedOrderItem(array $row): bool
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\customer_order_product_policy;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -93,6 +94,7 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
try {
|
try {
|
||||||
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Avoid SQL injection
|
// Avoid SQL injection
|
||||||
$reference = $db->escape_string($reference);
|
$reference = $db->escape_string($reference);
|
||||||
$notes = $db->escape_string($notes);
|
$notes = $db->escape_string($notes);
|
||||||
@@ -167,6 +169,7 @@ class order_items_o extends db
|
|||||||
try {
|
try {
|
||||||
// Get the order
|
// Get the order
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Get the product price
|
// Get the product price
|
||||||
$product = (new products_o())->getProductById($product_id);
|
$product = (new products_o())->getProductById($product_id);
|
||||||
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
||||||
|
|||||||
@@ -12523,6 +12523,40 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: {}
|
schema: {}
|
||||||
|
|
||||||
|
/superuser/departments/{id}/overview:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Departments
|
||||||
|
summary: Get superuser department overview
|
||||||
|
description: Returns the selected department metadata and operational overview metrics for a superuser without requiring scoped department access.
|
||||||
|
operationId: getSuperuserDepartmentOverview
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: {type: integer}
|
||||||
|
- name: date
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: {type: string}
|
||||||
|
- name: date_to
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: {type: string}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Superuser department overview loaded successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuperuserDepartmentOverviewResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
/superuser/department/branding:
|
/superuser/department/branding:
|
||||||
put:
|
put:
|
||||||
tags:
|
tags:
|
||||||
@@ -21551,6 +21585,21 @@ components:
|
|||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
|
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
|
||||||
|
|
||||||
|
SuperuserDepartmentOverviewPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
department:
|
||||||
|
$ref: '#/components/schemas/Department'
|
||||||
|
overview:
|
||||||
|
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
|
||||||
|
|
||||||
|
SuperuserDepartmentOverviewResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/SuperuserDepartmentOverviewPayload'
|
||||||
|
|
||||||
DepartmentDailyReportTransactionCountPayload:
|
DepartmentDailyReportTransactionCountPayload:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -812,6 +812,53 @@ class departmentDailyReportsRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->get('/superuser/departments/{id}/overview', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requirePermission('superuser_fetch_department');
|
||||||
|
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$department_id_param = (string)($this->fromRoute('id') ?? '');
|
||||||
|
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
|
||||||
|
$response->error('Parameter id must be a positive integer', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requireParameters([
|
||||||
|
'date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::validateDateLocally();
|
||||||
|
$date_to = $this->getDate_to();
|
||||||
|
$department_id = (int)$department_id_param;
|
||||||
|
$department = (new departments_o())->select($department_id);
|
||||||
|
|
||||||
|
if (!$department->exists()) {
|
||||||
|
$response->error('Department not found', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
|
||||||
|
|
||||||
|
$response->success([
|
||||||
|
'department' => $department->asArray(['slack_webhook' => false]),
|
||||||
|
'overview' => $this->buildDailyReportOverview(
|
||||||
|
[$department_id],
|
||||||
|
(string)self::getParameter('date'),
|
||||||
|
$date_to
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'superuser_fetch_department' => 'Get the superuser department overview'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->get('/departments/daily-reports/overview', function () {
|
$this->get('/departments/daily-reports/overview', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('list_department_daily_reports');
|
$this->requirePermission('list_department_daily_reports');
|
||||||
|
|||||||
@@ -250,11 +250,17 @@ class departmentsRoute
|
|||||||
$this->get('/departments/categories', function () {
|
$this->get('/departments/categories', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('list_department_categories');
|
$auth = new authentication();
|
||||||
// Get the user object
|
$user = $auth->get_user();
|
||||||
$user = (new authentication())->get_user();
|
$subuser = $auth->get_subuser();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user || $subuser) {
|
||||||
|
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
|
||||||
|
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
|
||||||
|
$this->emitForbidden(['list_department_categories']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$responsibleUserId = $user ? (int)$user->id : 0;
|
||||||
// Require the department id
|
// Require the department id
|
||||||
self::requireParameters(['id']);
|
self::requireParameters(['id']);
|
||||||
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
|
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
|
||||||
@@ -263,14 +269,14 @@ class departmentsRoute
|
|||||||
// Validate the department categories object
|
// Validate the department categories object
|
||||||
if (!$department->exists()) {
|
if (!$department->exists()) {
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
||||||
// Return an error
|
// Return an error
|
||||||
$response->error('Department categories not found', 400);
|
$response->error('Department categories not found', 400);
|
||||||
}
|
}
|
||||||
// Get the department categories
|
// Get the department categories
|
||||||
$department_categories = new department_categories_o();
|
$department_categories = new department_categories_o();
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
||||||
// Return the list of department categories
|
// Return the list of department categories
|
||||||
$response->success(
|
$response->success(
|
||||||
$department_categories
|
$department_categories
|
||||||
@@ -295,7 +301,7 @@ class departmentsRoute
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'list_department_categories' => 'List all department categories'
|
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -41,18 +41,9 @@ class orderBookingRoute
|
|||||||
$po = self::getTargetPo(); // String | Null
|
$po = self::getTargetPo(); // String | Null
|
||||||
$pickup = self::getTargetPickup(); // Bool | Null
|
$pickup = self::getTargetPickup(); // Bool | Null
|
||||||
$items = self::getTargetItems(); // Array of order_items_o objects
|
$items = self::getTargetItems(); // Array of order_items_o objects
|
||||||
/**
|
$this->requireOrderBookingCreateAccess(
|
||||||
* Permissions (clean helper)
|
|
||||||
*/
|
|
||||||
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
|
|
||||||
$permission_other = self::definePermission('add_bookings');
|
|
||||||
self::allowOwnOrDepartmentAccess(
|
|
||||||
$permission_own,
|
|
||||||
$permission_other,
|
|
||||||
(int)$customer_number->customer_number->value(),
|
(int)$customer_number->customer_number->value(),
|
||||||
(int)$department->id,
|
(int)$department->id
|
||||||
null,
|
|
||||||
'You do not have permission to create this order booking.'
|
|
||||||
);
|
);
|
||||||
/**
|
/**
|
||||||
* Input data
|
* Input data
|
||||||
@@ -96,8 +87,7 @@ class orderBookingRoute
|
|||||||
$response->success($order_bookings_o->asArray());
|
$response->success($order_bookings_o->asArray());
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
|
'add_bookings' => 'Permission to create order bookings for another customer or department scope.'
|
||||||
'add_bookings' => 'Permission to create department order bookings.'
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -659,6 +649,34 @@ class orderBookingRoute
|
|||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
|
||||||
|
{
|
||||||
|
if ($this->isOrderBookingCustomerSession() && $this->isOwnCustomerContext($targetCustomerNumber)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionOther = self::definePermission('add_bookings');
|
||||||
|
if (!self::hasPermission($permissionOther)) {
|
||||||
|
$this->emitForbidden([$permissionOther]);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requireDepartmentAccess((string)$departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isOrderBookingCustomerSession(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$auth = new authentication();
|
||||||
|
if ($auth->get_subuser() !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $auth->get_user() !== false && self::hasPermission('user');
|
||||||
|
} catch (Exception) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception If the Department is invalid.
|
* @throws Exception If the Department is invalid.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -612,21 +612,46 @@ class orderInvoicesRoute
|
|||||||
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
|
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
|
||||||
);
|
);
|
||||||
|
|
||||||
$date_from = $db->escape_string($date_range['dateFrom']);
|
|
||||||
$date_to = $db->escape_string($date_range['dateTo']);
|
|
||||||
$sql = "SELECT DISTINCT invoice_collection_id
|
|
||||||
FROM orders
|
|
||||||
WHERE created_at BETWEEN '$date_from' AND '$date_to'
|
|
||||||
AND invoice_collection_id IS NOT NULL
|
|
||||||
AND invoice_collection_id > 0
|
|
||||||
AND deleted_at IS NULL";
|
|
||||||
$query_result = $db->query($sql);
|
|
||||||
$invoice_collection_ids = [];
|
$invoice_collection_ids = [];
|
||||||
while ($row = $query_result->fetch_assoc()) {
|
if (self::isParametersSet(['invoice_collection_ids'])) {
|
||||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
|
||||||
if ($invoice_collection_id > 0) {
|
if (!is_array($invoice_collection_ids_raw)) {
|
||||||
|
$response->error('invoice_collection_ids must be an array', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
|
||||||
|
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
|
||||||
|
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice_collection_id = (int)$invoice_collection_id_raw;
|
||||||
|
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
|
||||||
|
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||||
|
}
|
||||||
|
|
||||||
$invoice_collection_ids[] = $invoice_collection_id;
|
$invoice_collection_ids[] = $invoice_collection_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
|
||||||
|
if (empty($invoice_collection_ids)) {
|
||||||
|
$response->error('invoice_collection_ids must contain at least one id', 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$date_from = $db->escape_string($date_range['dateFrom']);
|
||||||
|
$date_to = $db->escape_string($date_range['dateTo']);
|
||||||
|
$sql = "SELECT DISTINCT invoice_collection_id
|
||||||
|
FROM orders
|
||||||
|
WHERE created_at BETWEEN '$date_from' AND '$date_to'
|
||||||
|
AND invoice_collection_id IS NOT NULL
|
||||||
|
AND invoice_collection_id > 0
|
||||||
|
AND deleted_at IS NULL";
|
||||||
|
$query_result = $db->query($sql);
|
||||||
|
while ($row = $query_result->fetch_assoc()) {
|
||||||
|
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
||||||
|
if ($invoice_collection_id > 0) {
|
||||||
|
$invoice_collection_ids[] = $invoice_collection_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|||||||
@@ -85,6 +85,65 @@ it('previews monthly split changes without moving orders or creating collections
|
|||||||
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
|
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('previews only explicit monthly split invoice collection ids', function (): void {
|
||||||
|
api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
]);
|
||||||
|
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
]);
|
||||||
|
$targetMarchOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2096-03-15 10:00:00',
|
||||||
|
]);
|
||||||
|
$targetAprilOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2096-04-02 10:00:00',
|
||||||
|
]);
|
||||||
|
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $ignoredCollection['id'],
|
||||||
|
'created_at' => '2096-03-16 10:00:00',
|
||||||
|
]);
|
||||||
|
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $ignoredCollection['id'],
|
||||||
|
'created_at' => '2096-04-03 10:00:00',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||||
|
'dateFrom' => '2096-03-01',
|
||||||
|
'dateTo' => '2096-04-30',
|
||||||
|
'invoice_collection_ids' => [$targetCollection['id']],
|
||||||
|
'preview' => true,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$payload = $response->data();
|
||||||
|
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||||
|
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||||
|
->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||||
|
});
|
||||||
|
|
||||||
it('splits a selected March and April collected invoice into monthly collections', function (): void {
|
it('splits a selected March and April collected invoice into monthly collections', function (): void {
|
||||||
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
|
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
|
||||||
|
|
||||||
@@ -139,6 +198,74 @@ it('splits a selected March and April collected invoice into monthly collections
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('splits only explicit monthly split invoice collection ids', function (): void {
|
||||||
|
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'created_at' => '2096-03-01 00:00:01',
|
||||||
|
]);
|
||||||
|
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'created_at' => '2096-03-01 00:00:01',
|
||||||
|
]);
|
||||||
|
$targetMarchOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2096-03-15 10:00:00',
|
||||||
|
]);
|
||||||
|
$targetAprilOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2096-04-02 10:00:00',
|
||||||
|
]);
|
||||||
|
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $ignoredCollection['id'],
|
||||||
|
'created_at' => '2096-03-16 10:00:00',
|
||||||
|
]);
|
||||||
|
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $ignoredCollection['id'],
|
||||||
|
'created_at' => '2096-04-03 10:00:00',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||||
|
$createdCollectionIds = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||||
|
'dateFrom' => '2096-03-01',
|
||||||
|
'dateTo' => '2096-04-30',
|
||||||
|
'invoice_collection_ids' => [$targetCollection['id']],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$payload = $response->data();
|
||||||
|
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
|
||||||
|
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
|
||||||
|
|
||||||
|
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||||
|
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||||
|
->and($aprilCollectionId)->toBeGreaterThan(0)
|
||||||
|
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
|
||||||
|
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||||
|
} finally {
|
||||||
|
monthly_split_cleanup_collections($createdCollectionIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('sets closed_at to month end when split month has ended', function (): void {
|
it('sets closed_at to month end when split month has ended', function (): void {
|
||||||
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
|
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
|
||||||
|
|
||||||
@@ -345,3 +472,27 @@ it('rejects invalid monthly split date ranges', function (): void {
|
|||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false);
|
->assertSuccess(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid explicit monthly split invoice collection ids', function (): void {
|
||||||
|
api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||||
|
|
||||||
|
api_client()->post('/collected-invoices/split-by-month', [
|
||||||
|
'dateFrom' => '2096-03-01',
|
||||||
|
'dateTo' => '2096-04-30',
|
||||||
|
'invoice_collection_ids' => ['not-a-number'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false);
|
||||||
|
|
||||||
|
api_client()->post('/collected-invoices/split-by-month', [
|
||||||
|
'dateFrom' => '2096-03-01',
|
||||||
|
'dateTo' => '2096-04-30',
|
||||||
|
'invoice_collection_ids' => [],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -301,6 +301,42 @@ it('lists department categories for a department', function (): void {
|
|||||||
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets customer booking sessions list department categories without the management permission', function (): void {
|
||||||
|
api_test_covers('GET /departments/categories', 'auth');
|
||||||
|
|
||||||
|
$customerSession = api_fixtures()->createUserSession(['user']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$category = api_fixtures()->createCategory([
|
||||||
|
'name' => 'Customer Department Category',
|
||||||
|
]);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
|
||||||
|
$customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']);
|
||||||
|
|
||||||
|
$customerResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($customerResponse->data())
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveCount(1)
|
||||||
|
->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||||
|
|
||||||
|
$subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []);
|
||||||
|
$subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']);
|
||||||
|
|
||||||
|
$subuserResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($subuserResponse->data())
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveCount(1)
|
||||||
|
->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid department category requests', function (): void {
|
it('rejects invalid department category requests', function (): void {
|
||||||
api_test_covers('GET /departments/categories', 'failure');
|
api_test_covers('GET /departments/categories', 'failure');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
function order_booking_create_payload(array $customer, array $department, array $product, string $reference): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'customer_number' => (int)$customer['customer_number'],
|
||||||
|
'department' => (int)$department['id'],
|
||||||
|
'reg_1' => $reference,
|
||||||
|
'datetime' => '2026-07-07 10:00:00',
|
||||||
|
'note' => '',
|
||||||
|
'reference' => $reference,
|
||||||
|
'po' => '',
|
||||||
|
'pickup' => false,
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'id' => (int)$product['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function order_booking_create_department(string $name): array
|
||||||
|
{
|
||||||
|
$branding = api_fixtures()->createBranding([
|
||||||
|
'name' => $name . ' Brand',
|
||||||
|
'address' => 'API Booking Street 1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return api_fixtures()->createDepartment([
|
||||||
|
'name' => $name,
|
||||||
|
'branding' => (int)$branding['id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lets customers create their own order bookings without booking permissions', function (): void {
|
||||||
|
api_test_covers('POST /order-bookings', 'auth');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['user']);
|
||||||
|
$department = order_booking_create_department('Own Booking Department');
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Own Booking Product']);
|
||||||
|
|
||||||
|
$response = api_client()->post(
|
||||||
|
'/order-bookings',
|
||||||
|
order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'),
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($bookingId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets subusers create own customer order bookings without the bookings add node', function (): void {
|
||||||
|
api_test_covers('POST /order-bookings', 'auth');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
|
||||||
|
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []);
|
||||||
|
$department = order_booking_create_department('Subuser Booking Department');
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']);
|
||||||
|
|
||||||
|
$response = api_client()->post(
|
||||||
|
'/order-bookings',
|
||||||
|
order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'),
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($bookingId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still requires elevated access for creating another customer order booking', function (): void {
|
||||||
|
api_test_covers('POST /order-bookings', 'auth');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['user']);
|
||||||
|
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']);
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Other Booking Product']);
|
||||||
|
|
||||||
|
$response = api_client()->post(
|
||||||
|
'/order-bookings',
|
||||||
|
order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'),
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['add_bookings']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets department-scoped users create order bookings for another customer', function (): void {
|
||||||
|
api_test_covers('POST /order-bookings', 'happy');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']);
|
||||||
|
$department = order_booking_create_department('Department Scoped Booking Department');
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'add_bookings',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->post(
|
||||||
|
'/order-bookings',
|
||||||
|
order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'),
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($bookingId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||||
|
});
|
||||||
@@ -82,7 +82,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
|||||||
'reference' => 'NOTE-REQUIRED',
|
'reference' => 'NOTE-REQUIRED',
|
||||||
]);
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902701,
|
|
||||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||||
'price' => 299,
|
'price' => 299,
|
||||||
'requires_note' => 0,
|
'requires_note' => 0,
|
||||||
@@ -116,6 +115,85 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
|||||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer_rules');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
|
||||||
|
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ONLY-TANK',
|
||||||
|
]);
|
||||||
|
$washProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Forvogn',
|
||||||
|
'price' => 649,
|
||||||
|
'category' => 4,
|
||||||
|
]);
|
||||||
|
$tankCleaningProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'price' => 299,
|
||||||
|
'category' => 5,
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||||
|
|
||||||
|
api_client()
|
||||||
|
->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $washProduct['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||||
|
|
||||||
|
$response = api_client()->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $tankCleaningProduct['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer_rules');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'REGULAR-WASH',
|
||||||
|
]);
|
||||||
|
$washProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Forvogn',
|
||||||
|
'price' => 649,
|
||||||
|
'category' => 4,
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||||
|
|
||||||
|
$response = api_client()->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $washProduct['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||||
api_test_covers('PUT /order/items', 'validation');
|
api_test_covers('PUT /order/items', 'validation');
|
||||||
|
|
||||||
@@ -129,7 +207,6 @@ it('does not allow clearing notes for order items whose product requires notes',
|
|||||||
'reference' => 'NOTE-EDIT',
|
'reference' => 'NOTE-EDIT',
|
||||||
]);
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902702,
|
|
||||||
'name' => 'API Note Required Product',
|
'name' => 'API Note Required Product',
|
||||||
'price' => 199,
|
'price' => 199,
|
||||||
'requires_note' => 1,
|
'requires_note' => 1,
|
||||||
@@ -142,7 +219,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
|||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'notes' => 'Initial note',
|
'notes' => 'Initial note',
|
||||||
]);
|
]);
|
||||||
$session = api_fixtures()->createUserSession(['edit_order_items']);
|
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
|
||||||
|
|
||||||
api_client()
|
api_client()
|
||||||
->put('/order/items', [
|
->put('/order/items', [
|
||||||
@@ -162,7 +239,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
|||||||
api_test_covers('GET /products', 'happy');
|
api_test_covers('GET /products', 'happy');
|
||||||
|
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902703,
|
|
||||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||||
'price' => 299,
|
'price' => 299,
|
||||||
'requires_note' => 0,
|
'requires_note' => 0,
|
||||||
@@ -198,7 +274,9 @@ it('blocks addon products added as standalone additional order items for custome
|
|||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess();
|
->assertSuccess();
|
||||||
|
|
||||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
|
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||||
|
'notes' => 'Addon customer rule check',
|
||||||
|
])
|
||||||
->assertStatus(400)
|
->assertStatus(400)
|
||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
it('loads a single department overview for superusers without department scoped access', function (): void {
|
||||||
|
api_test_covers('GET /superuser/departments/{id}/overview', 'happy');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Overview Department ' . uniqid('', false),
|
||||||
|
'description' => 'Department overview fixture',
|
||||||
|
'economic_department_id' => 42,
|
||||||
|
'visible' => 1,
|
||||||
|
]);
|
||||||
|
$departmentRow = api_fixtures()->fetchRowById('departments', (int)$department['id']);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'superuser_fetch_department',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->get(
|
||||||
|
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06&date_to=2026-07-06',
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$payload = $response->data();
|
||||||
|
|
||||||
|
expect($payload)->toBeArray();
|
||||||
|
expect($payload['department'])
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('id', (int)$department['id'])
|
||||||
|
->toHaveKey('name', $departmentRow['name'])
|
||||||
|
->toHaveKey('description', 'Department overview fixture')
|
||||||
|
->toHaveKey('economic_department_id', 42);
|
||||||
|
|
||||||
|
expect($payload['overview'])
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('department_ids', [(int)$department['id']])
|
||||||
|
->toHaveKey('date', '2026-07-06')
|
||||||
|
->toHaveKey('date_to', '2026-07-06');
|
||||||
|
|
||||||
|
expect($payload['overview']['metrics'])
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKeys([
|
||||||
|
'bookings',
|
||||||
|
'complaints',
|
||||||
|
'night_washes',
|
||||||
|
'revenue',
|
||||||
|
'washes',
|
||||||
|
'products_sold',
|
||||||
|
'transactions',
|
||||||
|
'water_usage',
|
||||||
|
'overtime',
|
||||||
|
]);
|
||||||
|
expect($payload['overview']['metrics']['revenue']['state'])->toBe('ready');
|
||||||
|
expect($payload['overview']['metrics']['revenue']['value'])->toBe(0);
|
||||||
|
expect($payload['overview']['products'])->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects superuser department overview requests without permission or valid input', function (): void {
|
||||||
|
api_test_covers('GET /superuser/departments/{id}/overview', 'auth');
|
||||||
|
api_test_covers('GET /superuser/departments/{id}/overview', 'failure');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$unauthorizedSession = api_fixtures()->createUserSession([]);
|
||||||
|
|
||||||
|
api_client()->get(
|
||||||
|
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06',
|
||||||
|
$unauthorizedSession['headers']
|
||||||
|
)
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['superuser_fetch_department']);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
|
||||||
|
|
||||||
|
api_client()->get('/superuser/departments/bad/overview?date=2026-07-06', $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Parameter id must be a positive integer');
|
||||||
|
|
||||||
|
api_client()->get('/superuser/departments/' . $department['id'] . '/overview', $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Missing required parameters: date');
|
||||||
|
|
||||||
|
api_client()->get('/superuser/departments/99999999/overview?date=2026-07-06', $session['headers'])
|
||||||
|
->assertStatus(404)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Department not found');
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ return [
|
|||||||
'GET /branding',
|
'GET /branding',
|
||||||
'POST /branding',
|
'POST /branding',
|
||||||
'PUT /branding',
|
'PUT /branding',
|
||||||
|
'GET /superuser/departments/{id}/overview',
|
||||||
'PUT /superuser/department/branding',
|
'PUT /superuser/department/branding',
|
||||||
'POST /bird/voice/calls/webhook/inbound',
|
'POST /bird/voice/calls/webhook/inbound',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -112,6 +112,46 @@ CREATE TABLE IF NOT EXISTS `department_variables` (
|
|||||||
KEY `idx_department_variables_department_id` (`department_id`),
|
KEY `idx_department_variables_department_id` (`department_id`),
|
||||||
KEY `idx_department_variables_variable` (`variable`)
|
KEY `idx_department_variables_variable` (`variable`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL,
|
||||||
|
'department_daily_reports' => <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `department_daily_reports` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`department_id` INT NOT NULL,
|
||||||
|
`water_usage` INT NOT NULL DEFAULT 0,
|
||||||
|
`water_usage_morning` INT NOT NULL DEFAULT 0,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`filled_by` INT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_department_daily_reports_department_id` (`department_id`),
|
||||||
|
KEY `idx_department_daily_reports_created_at` (`created_at`),
|
||||||
|
KEY `idx_department_daily_reports_department_created_at` (`department_id`, `created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL,
|
||||||
|
'department_time_bookings_opening_hours' => <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `department_time_bookings_opening_hours` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`department` INT NOT NULL,
|
||||||
|
`monday_start` TIME NULL,
|
||||||
|
`monday_end` TIME NULL,
|
||||||
|
`tuesday_start` TIME NULL,
|
||||||
|
`tuesday_end` TIME NULL,
|
||||||
|
`wednesday_start` TIME NULL,
|
||||||
|
`wednesday_end` TIME NULL,
|
||||||
|
`thursday_start` TIME NULL,
|
||||||
|
`thursday_end` TIME NULL,
|
||||||
|
`friday_start` TIME NULL,
|
||||||
|
`friday_end` TIME NULL,
|
||||||
|
`saturday_start` TIME NULL,
|
||||||
|
`saturday_end` TIME NULL,
|
||||||
|
`sunday_start` TIME NULL,
|
||||||
|
`sunday_end` TIME NULL,
|
||||||
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_department_time_bookings_opening_hours_department` (`department`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'department_gates' => <<<'SQL'
|
'department_gates' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `department_gates` (
|
CREATE TABLE IF NOT EXISTS `department_gates` (
|
||||||
|
|||||||
+3
@@ -31,8 +31,11 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
|
|||||||
$content = department_daily_reports_openapi_content_or_skip();
|
$content = department_daily_reports_openapi_content_or_skip();
|
||||||
|
|
||||||
expect($content)->toContain('/departments/daily-reports/overview:');
|
expect($content)->toContain('/departments/daily-reports/overview:');
|
||||||
|
expect($content)->toContain('/superuser/departments/{id}/overview:');
|
||||||
expect($content)->toContain('operationId: getDailyReportOverview');
|
expect($content)->toContain('operationId: getDailyReportOverview');
|
||||||
|
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
|
||||||
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
|
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
|
||||||
|
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
|
||||||
expect($content)->toContain('DepartmentDailyReportMetric:');
|
expect($content)->toContain('DepartmentDailyReportMetric:');
|
||||||
expect($content)->toContain('DepartmentDailyReportProductTile:');
|
expect($content)->toContain('DepartmentDailyReportProductTile:');
|
||||||
expect($content)->toContain('- name: department_ids');
|
expect($content)->toContain('- name: department_ids');
|
||||||
|
|||||||
@@ -342,6 +342,8 @@ it('wires the overview route to batched repository methods and overview path', f
|
|||||||
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
||||||
|
|
||||||
expect($routeContent)->toContain('/departments/daily-reports/overview');
|
expect($routeContent)->toContain('/departments/daily-reports/overview');
|
||||||
|
expect($routeContent)->toContain('/superuser/departments/{id}/overview');
|
||||||
|
expect($routeContent)->toContain('superuser_fetch_department');
|
||||||
expect($routeContent)->toContain('/departments/daily-reports/complaints');
|
expect($routeContent)->toContain('/departments/daily-reports/complaints');
|
||||||
expect($routeContent)->toContain('outsideHoursStatisticsService');
|
expect($routeContent)->toContain('outsideHoursStatisticsService');
|
||||||
expect($routeContent)->toContain('dailyReportComplaintsRepository');
|
expect($routeContent)->toContain('dailyReportComplaintsRepository');
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use classes\customer_order_product_policy;
|
||||||
|
|
||||||
|
it('recognizes tankcleaning products by category and legacy names', function (): void {
|
||||||
|
expect(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 5,
|
||||||
|
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'category_name' => 'Other',
|
||||||
|
]))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 3,
|
||||||
|
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||||
|
'category_name' => 'Other',
|
||||||
|
]))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 3,
|
||||||
|
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'category_name' => 'Tankrens',
|
||||||
|
]))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
|
||||||
|
$washProduct = [
|
||||||
|
'product_category' => 4,
|
||||||
|
'product_name' => 'Forvogn',
|
||||||
|
'category_name' => 'Udvendig',
|
||||||
|
];
|
||||||
|
$tankCleaningProduct = [
|
||||||
|
'product_category' => 5,
|
||||||
|
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||||
|
'category_name' => 'Tank cleaning',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
|
||||||
|
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user