Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f2c80dda | ||
|
|
6f3d7e0f7d |
@@ -1,98 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -2036,7 +2036,8 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
|
||||
@@ -230,6 +230,16 @@ class limited_backoffice_service
|
||||
'limited_backoffice',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, true>
|
||||
*/
|
||||
private const PHONE_COUNTRY_CODES = [
|
||||
45 => true,
|
||||
46 => true,
|
||||
47 => true,
|
||||
358 => true,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
@@ -534,7 +544,8 @@ class limited_backoffice_service
|
||||
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
|
||||
$displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.');
|
||||
$password = $this->normalizePassword($payload['password'] ?? null, true);
|
||||
$email = $this->normalizeOptionalString($payload['email'] ?? null);
|
||||
$email = $this->normalizeEmail($payload['email'] ?? null, true);
|
||||
$phone = $this->normalizeOptionalPhonePair($payload);
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
@@ -545,13 +556,23 @@ class limited_backoffice_service
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `users` (`customer_number`, `display_name`, `email`, `password`, `group_id`)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
'INSERT INTO `users`
|
||||
(`customer_number`, `display_name`, `email`, `password`, `group_id`, `phone_country_code`, `phone`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee insert.');
|
||||
}
|
||||
$statement->bind_param('isssi', $customerNumber, $displayName, $email, $passwordHash, $groupId);
|
||||
$statement->bind_param(
|
||||
'isssiii',
|
||||
$customerNumber,
|
||||
$displayName,
|
||||
$email,
|
||||
$passwordHash,
|
||||
$groupId,
|
||||
$phone['phone_country_code'],
|
||||
$phone['phone']
|
||||
);
|
||||
$statement->execute();
|
||||
$employeeId = (int)$mysqli->insert_id;
|
||||
$statement->close();
|
||||
@@ -629,11 +650,12 @@ class limited_backoffice_service
|
||||
? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.')
|
||||
: null;
|
||||
$email = array_key_exists('email', $payload)
|
||||
? $this->normalizeOptionalString($payload['email'])
|
||||
? $this->normalizeEmail($payload['email'], true)
|
||||
: null;
|
||||
$password = array_key_exists('password', $payload)
|
||||
? $this->normalizePassword($payload['password'], false)
|
||||
: null;
|
||||
$phone = $this->normalizeOptionalPhonePair($payload, false);
|
||||
$active = array_key_exists('active', $payload)
|
||||
? (bool)$payload['active']
|
||||
: $this->isEmployeeRowActive($employee);
|
||||
@@ -663,6 +685,10 @@ class limited_backoffice_service
|
||||
if ($password !== null) {
|
||||
$userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$userUpdates['phone_country_code'] = $phone['phone_country_code'];
|
||||
$userUpdates['phone'] = $phone['phone'];
|
||||
}
|
||||
$usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at');
|
||||
if ($active) {
|
||||
$userUpdates['group_id'] = $managedGroupId;
|
||||
@@ -1061,6 +1087,89 @@ class limited_backoffice_service
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function normalizeEmail(mixed $value, bool $required): ?string
|
||||
{
|
||||
$email = $this->normalizeOptionalString($value);
|
||||
if ($email === null) {
|
||||
if ($required) {
|
||||
throw new limited_backoffice_exception('Email is required.', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new limited_backoffice_exception('Email must be a valid email address.', 400);
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{phone_country_code:int|null,phone:int|null}|null
|
||||
*/
|
||||
private function normalizeOptionalPhonePair(array $payload, bool $defaultWhenMissing = true): ?array
|
||||
{
|
||||
$hasCountryCode = array_key_exists('phone_country_code', $payload);
|
||||
$hasPhone = array_key_exists('phone', $payload);
|
||||
if (!$hasCountryCode && !$hasPhone) {
|
||||
return $defaultWhenMissing
|
||||
? ['phone_country_code' => null, 'phone' => null]
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!$hasCountryCode || !$hasPhone) {
|
||||
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
|
||||
}
|
||||
|
||||
$countryCode = $this->normalizeOptionalDigits($payload['phone_country_code']);
|
||||
$phone = $this->normalizeOptionalDigits($payload['phone']);
|
||||
if ($countryCode === null && $phone === null) {
|
||||
return ['phone_country_code' => null, 'phone' => null];
|
||||
}
|
||||
|
||||
if ($countryCode === null || $phone === null) {
|
||||
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
|
||||
}
|
||||
|
||||
if (!isset(self::PHONE_COUNTRY_CODES[$countryCode])) {
|
||||
throw new limited_backoffice_exception('Phone country code is not supported.', 400);
|
||||
}
|
||||
|
||||
$phoneText = (string)$phone;
|
||||
if (!preg_match('/^\d{4,15}$/', $phoneText)) {
|
||||
throw new limited_backoffice_exception('Phone number must be 4-15 digits.', 400);
|
||||
}
|
||||
|
||||
return [
|
||||
'phone_country_code' => $countryCode,
|
||||
'phone' => $phone,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeOptionalDigits(mixed $value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (ctype_digit($value)) {
|
||||
return (int)$value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new limited_backoffice_exception('Phone values must contain digits only.', 400);
|
||||
}
|
||||
|
||||
private function normalizePassword(mixed $value, bool $required): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
@@ -1236,6 +1345,8 @@ class limited_backoffice_service
|
||||
'customer_number' => (int)$row['customer_number'],
|
||||
'display_name' => (string)($row['display_name'] ?? ''),
|
||||
'email' => $row['email'] === null ? null : (string)$row['email'],
|
||||
'phone_country_code' => $row['phone_country_code'] === null ? null : (int)$row['phone_country_code'],
|
||||
'phone' => $row['phone'] === null ? null : (int)$row['phone'],
|
||||
'active' => $active,
|
||||
'role' => $this->rolePayload((string)$row['role_key']),
|
||||
'departments' => $this->departmentSummaries($departmentIds),
|
||||
@@ -1356,7 +1467,7 @@ class limited_backoffice_service
|
||||
$types = '';
|
||||
$values = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at'], true)) {
|
||||
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at', 'phone_country_code', 'phone'], true)) {
|
||||
continue;
|
||||
}
|
||||
if ($value === null) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_order_product_policy;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
@@ -94,7 +93,6 @@ class order_items_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Avoid SQL injection
|
||||
$reference = $db->escape_string($reference);
|
||||
$notes = $db->escape_string($notes);
|
||||
@@ -169,7 +167,6 @@ class order_items_o extends db
|
||||
try {
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Get the product price
|
||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||
|
||||
@@ -357,4 +354,4 @@ class order_items_o extends db
|
||||
{
|
||||
return (new products_o())->select((int)$this->product_id->value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,21 +612,46 @@ class orderInvoicesRoute
|
||||
$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 = [];
|
||||
while ($row = $query_result->fetch_assoc()) {
|
||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
||||
if ($invoice_collection_id > 0) {
|
||||
if (self::isParametersSet(['invoice_collection_ids'])) {
|
||||
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
|
||||
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 = 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 = [];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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()
|
||||
->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);
|
||||
});
|
||||
|
||||
@@ -400,6 +400,8 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Cashier',
|
||||
'email' => 'limited-cashier@example.test',
|
||||
'phone_country_code' => 45,
|
||||
'phone' => 12345678,
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -413,6 +415,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
|
||||
expect($created->data()['phone_country_code'] ?? null)->toBe(45);
|
||||
expect($created->data()['phone'] ?? null)->toBe(12345678);
|
||||
expect($created->body)->not->toContain('department_access_');
|
||||
expect($created->body)->not->toContain('permissions');
|
||||
|
||||
@@ -431,6 +436,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'display_name' => 'Limited Lead',
|
||||
'email' => 'limited-lead@example.test',
|
||||
'phone_country_code' => 358,
|
||||
'phone' => 87654321,
|
||||
'role_key' => 'operations_lead',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
@@ -440,11 +448,25 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
->assertSuccess();
|
||||
|
||||
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
|
||||
expect($updated->data()['email'] ?? null)->toBe('limited-lead@example.test');
|
||||
expect($updated->data()['phone_country_code'] ?? null)->toBe(358);
|
||||
expect($updated->data()['phone'] ?? null)->toBe(87654321);
|
||||
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
|
||||
|
||||
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||
$ids = array_column($list->data(), 'id');
|
||||
expect($ids)->toContain($employeeId);
|
||||
$listedEmployee = null;
|
||||
foreach ($list->data() as $employee) {
|
||||
if ((int)($employee['id'] ?? 0) === $employeeId) {
|
||||
$listedEmployee = $employee;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect($listedEmployee)->not->toBeNull();
|
||||
expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test');
|
||||
expect($listedEmployee['phone_country_code'] ?? null)->toBe(358);
|
||||
expect($listedEmployee['phone'] ?? null)->toBe(87654321);
|
||||
expect($list->body)->not->toContain('department_access_');
|
||||
|
||||
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
||||
@@ -466,6 +488,34 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts employees without optional phone details', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee No Phone']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited No Phone',
|
||||
'email' => 'limited-no-phone@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue();
|
||||
expect(array_key_exists('phone', $created->data()))->toBeTrue();
|
||||
expect($created->data()['phone_country_code'])->toBeNull();
|
||||
expect($created->data()['phone'])->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||
@@ -476,6 +526,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Outside Employee',
|
||||
'email' => 'outside@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$otherDepartment['id']],
|
||||
@@ -487,6 +538,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Raw Employee',
|
||||
'email' => 'raw@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -499,6 +551,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Unknown Role Employee',
|
||||
'email' => 'unknown-role@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'superuser',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -551,3 +604,88 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Cannot manage shared groups.');
|
||||
});
|
||||
|
||||
it('rejects invalid limited backoffice employee contact details', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Contact Validation']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
$basePayload = [
|
||||
'display_name' => 'Contact Employee',
|
||||
'email' => 'contact@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
];
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', array_diff_key($basePayload, ['email' => true]), $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Email is required.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'email' => 'not-an-email',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Email must be a valid email address.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'phone_country_code' => 45,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone country code and phone number must be provided together.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'phone_country_code' => 1,
|
||||
'phone' => 12345678,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone country code is not supported.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'phone_country_code' => 45,
|
||||
'phone' => '12ab',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone values must contain digits only.');
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', $basePayload, $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
|
||||
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'email' => '',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Email is required.');
|
||||
|
||||
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'phone_country_code' => 45,
|
||||
'phone' => '123',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone number must be 4-15 digits.');
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
'reference' => 'NOTE-REQUIRED',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902701,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
@@ -48,85 +49,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
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_order_product_policy::ONLY_TANKCLEANING_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 {
|
||||
api_test_covers('PUT /order/items', 'validation');
|
||||
|
||||
@@ -140,6 +62,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
'reference' => 'NOTE-EDIT',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902702,
|
||||
'name' => 'API Note Required Product',
|
||||
'price' => 199,
|
||||
'requires_note' => 1,
|
||||
@@ -152,7 +75,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
'quantity' => 1,
|
||||
'notes' => 'Initial note',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items']);
|
||||
|
||||
api_client()
|
||||
->put('/order/items', [
|
||||
@@ -172,6 +95,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
api_test_covers('GET /products', 'happy');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902703,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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