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; } }