mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 12:51:00 +00:00
384 lines
11 KiB
PHP
384 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Sales\Application;
|
|
|
|
use App\Domains\Accounts\Models\CompanySetting;
|
|
use App\Domains\Contacts\Models\Customer;
|
|
|
|
/**
|
|
* Renders document numbers from a per-company format string.
|
|
*
|
|
* A format is a run of `{{NAME}}` / `{{NAME:value}}` tokens; anything outside a
|
|
* recognised token contributes nothing to the result. Tokens naming a sequence
|
|
* are resolved against the highest number already stored for the company (and,
|
|
* for the per-customer sequence, the customer), so the rendered number is the
|
|
* one the document about to be written should carry.
|
|
*/
|
|
class SerialNumberService
|
|
{
|
|
public const VALID_PLACEHOLDERS = ['CUSTOMER_SERIES', 'SEQUENCE', 'DATE_FORMAT', 'SERIES', 'RANDOM_SEQUENCE', 'DELIMITER', 'CUSTOMER_SEQUENCE'];
|
|
|
|
/**
|
|
* Bytes a token name is spelled with.
|
|
*/
|
|
private const NAME_BYTES = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_';
|
|
|
|
/**
|
|
* Bytes a token value is spelled with when it runs longer than one byte.
|
|
*/
|
|
private const VALUE_BYTES = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_';
|
|
|
|
/**
|
|
* Longest multi-byte token value that is still read as one value.
|
|
*/
|
|
private const VALUE_LIMIT = 6;
|
|
|
|
private $model;
|
|
|
|
private $ob;
|
|
|
|
private $customer;
|
|
|
|
private $company;
|
|
|
|
private $settingKey;
|
|
|
|
private $sequenceScope = [];
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
public $nextSequenceNumber;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
public $nextCustomerSequenceNumber;
|
|
|
|
/**
|
|
* Point the service at the model class whose rows carry the sequences.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function setModel($model)
|
|
{
|
|
$this->model = $model;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Adopt an existing row's sequences so an update keeps its numbers.
|
|
*
|
|
* The per-customer sequence is only adopted while the row still belongs to
|
|
* the customer in play; moving a document to another customer therefore
|
|
* leaves it to be renumbered for the new one.
|
|
*/
|
|
public function setModelObject($id = null)
|
|
{
|
|
$this->ob = $this->model::find($id);
|
|
|
|
if ($this->ob && $this->ob->sequence_number) {
|
|
$this->nextSequenceNumber = $this->ob->sequence_number;
|
|
}
|
|
|
|
if (isset($this->ob->customer_sequence_number, $this->customer)
|
|
&& $this->ob->customer_id == $this->customer->id) {
|
|
$this->nextCustomerSequenceNumber = $this->ob->customer_sequence_number;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return $this
|
|
*/
|
|
public function setCompany($company)
|
|
{
|
|
$this->company = $company;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Resolve the customer the per-customer sequence and series belong to.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function setCustomer($customer = null)
|
|
{
|
|
$this->customer = Customer::find($customer);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Override the company setting the number format is read from.
|
|
*
|
|
* Without this the key is derived from the model class name, which is not
|
|
* enough for documents that share a table (credit notes are Invoice rows
|
|
* but carry their own format).
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function setSettingKey(string $key)
|
|
{
|
|
$this->settingKey = $key;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Restrict the sequence lookups to a subset of the model's rows.
|
|
*
|
|
* Takes column => value constraints that are applied on top of the company
|
|
* (and customer) filters, so documents sharing a table can each keep an
|
|
* independent, gapless sequence.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function setSequenceScope(array $constraints)
|
|
{
|
|
$this->sequenceScope = $constraints;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Render the number the next document should carry.
|
|
*
|
|
* Passing no format falls back to the company setting for this document
|
|
* kind.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getNextNumber(?string $format = null)
|
|
{
|
|
$derivedKey = strtolower(class_basename($this->model)).'_number_format';
|
|
|
|
if ($format === null) {
|
|
$format = CompanySetting::getSetting(
|
|
$this->settingKey ?: $derivedKey,
|
|
$this->company
|
|
);
|
|
}
|
|
|
|
$this->setNextNumbers();
|
|
|
|
return $this->generateSerialNumber($format);
|
|
}
|
|
|
|
/**
|
|
* Fill in whichever of the two sequences is still unresolved.
|
|
*/
|
|
public function setNextNumbers()
|
|
{
|
|
if (! $this->nextSequenceNumber) {
|
|
$this->setNextSequenceNumber();
|
|
}
|
|
|
|
if (! $this->nextCustomerSequenceNumber) {
|
|
$this->setNextCustomerSequenceNumber();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Resolve the company-wide sequence as one past the highest in use.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function setNextSequenceNumber()
|
|
{
|
|
$highest = $this->scopedQuery()
|
|
->whereNotNull('sequence_number')
|
|
->orderByDesc('sequence_number')
|
|
->first();
|
|
|
|
$this->nextSequenceNumber = $highest ? $highest->sequence_number + 1 : 1;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Resolve the per-customer sequence as one past the highest in use.
|
|
*
|
|
* With no customer resolved the lookup falls back to customer 1 rather
|
|
* than skipping the customer filter.
|
|
*
|
|
* @return self
|
|
*/
|
|
public function setNextCustomerSequenceNumber()
|
|
{
|
|
$highest = $this->scopedQuery()
|
|
->where('customer_id', $this->customer ? $this->customer->id : 1)
|
|
->whereNotNull('customer_sequence_number')
|
|
->orderByDesc('customer_sequence_number')
|
|
->first();
|
|
|
|
$this->nextCustomerSequenceNumber = $highest ? $highest->customer_sequence_number + 1 : 1;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* List the recognised tokens of a format, in the order they appear.
|
|
*
|
|
* Each entry is a `name` / `value` pair; a token written without a value
|
|
* yields an empty string. Tokens whose name is not one this service knows
|
|
* about are dropped, as is any text between tokens.
|
|
*/
|
|
public static function getPlaceholders(string $format)
|
|
{
|
|
$recognised = collect();
|
|
$end = strlen($format);
|
|
$cursor = 0;
|
|
|
|
while ($cursor < $end) {
|
|
$token = self::readToken($format, $cursor, $end);
|
|
|
|
if ($token === null) {
|
|
$cursor++;
|
|
|
|
continue;
|
|
}
|
|
|
|
[$name, $value, $cursor] = $token;
|
|
|
|
if (in_array($name, self::VALID_PLACEHOLDERS)) {
|
|
$recognised->push([
|
|
'name' => $name,
|
|
'value' => $value,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $recognised;
|
|
}
|
|
|
|
/**
|
|
* Read the token opening at the given offset, if there is one.
|
|
*
|
|
* Both the name/value split and the value itself are ambiguous: a value
|
|
* may be written with or without a leading colon, and may be either a run
|
|
* of word bytes or a single arbitrary byte. Candidates are therefore tried
|
|
* longest-name first, colon first, and word-run before single byte; the
|
|
* first spelling whose closing braces line up wins.
|
|
*
|
|
* @return array{0: string, 1: string, 2: int}|null name, value, offset just past the token
|
|
*/
|
|
private static function readToken(string $format, int $start, int $end)
|
|
{
|
|
if (substr($format, $start, 2) !== '{{') {
|
|
return null;
|
|
}
|
|
|
|
$nameAt = $start + 2;
|
|
|
|
for ($width = self::runLength($format, self::NAME_BYTES, $nameAt, $end); $width > 0; $width--) {
|
|
foreach ([true, false] as $colon) {
|
|
$valueAt = $nameAt + $width;
|
|
|
|
if ($colon) {
|
|
if (($format[$valueAt] ?? null) !== ':') {
|
|
continue;
|
|
}
|
|
|
|
$valueAt++;
|
|
}
|
|
|
|
$value = self::readValue($format, $valueAt, $end);
|
|
|
|
if ($value !== null) {
|
|
return [substr($format, $nameAt, $width), $value[0], $value[1]];
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Read a token's value plus its closing braces at the given offset.
|
|
*
|
|
* @return array{0: string, 1: int}|null value, offset just past the closing braces
|
|
*/
|
|
private static function readValue(string $format, int $at, int $end)
|
|
{
|
|
$run = self::runLength($format, self::VALUE_BYTES, $at, $end);
|
|
|
|
if ($run > 0 && $run <= self::VALUE_LIMIT && substr($format, $at + $run, 2) === '}}') {
|
|
return [substr($format, $at, $run), $at + $run + 2];
|
|
}
|
|
|
|
$byte = $format[$at] ?? null;
|
|
|
|
if ($byte !== null && $byte !== "\n" && substr($format, $at + 1, 2) === '}}') {
|
|
return [$byte, $at + 3];
|
|
}
|
|
|
|
if (substr($format, $at, 2) === '}}') {
|
|
return ['', $at + 2];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Count the bytes at the given offset that belong to the given set.
|
|
*/
|
|
private static function runLength(string $format, string $bytes, int $at, int $end): int
|
|
{
|
|
return $at < $end ? strspn($format, $bytes, $at) : 0;
|
|
}
|
|
|
|
/**
|
|
* Concatenate what every recognised token of the format renders to.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function generateSerialNumber(string $format)
|
|
{
|
|
$serialNumber = '';
|
|
|
|
foreach (self::getPlaceholders($format) as $placeholder) {
|
|
$serialNumber .= $this->renderPlaceholder($placeholder['name'], $placeholder['value']);
|
|
}
|
|
|
|
return $serialNumber;
|
|
}
|
|
|
|
/**
|
|
* Render one token.
|
|
*
|
|
* A token whose name is not one of the computed ones (a series or a
|
|
* delimiter) simply renders its own value.
|
|
*/
|
|
private function renderPlaceholder(string $name, string $value): string
|
|
{
|
|
return match ($name) {
|
|
'SEQUENCE' => str_pad($this->nextSequenceNumber, $value ?: 6, 0, STR_PAD_LEFT),
|
|
'CUSTOMER_SEQUENCE' => str_pad($this->nextCustomerSequenceNumber, $value, 0, STR_PAD_LEFT),
|
|
'DATE_FORMAT' => date($value ?: 'Y'),
|
|
'RANDOM_SEQUENCE' => substr(bin2hex(random_bytes($value ?: 6)), 0, $value ?: 6),
|
|
'CUSTOMER_SERIES' => isset($this->customer) ? ($this->customer->prefix ?? 'CST') : 'CST',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start a lookup narrowed to the company and the configured scope.
|
|
*/
|
|
private function scopedQuery()
|
|
{
|
|
$query = $this->model::query()->where('company_id', $this->company);
|
|
|
|
foreach ($this->sequenceScope as $column => $value) {
|
|
$query->where($column, $value);
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
}
|