CaiWuXiTong/app/Support/QrCode.php
limeng d19409627a feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。

- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
  链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 财务复审:确认系统快递名称(唯一),快递所填报价写成报价管理的 CourierQuoteVersion,
  可直接在协议签署页发起报价确认单;初审人不能复审
- 开通:需匹配到已签署的协议签署任务,或填写线下签署说明
- 快递资料页新增「入驻快递」页签,列出已开通的入驻快递
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
2026-10-12 17:37:44 +08:00

501 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Support;
use InvalidArgumentException;
/**
* 纯 PHP 二维码生成(字节模式、M 级纠错,版本 1-40),输出 SVG。
*
* 只服务于站内生成邀请链接二维码这类短文本场景,避免为此引入第三方依赖。
* 编码流程遵循 ISO/IEC 18004:数据码字 → Reed-Solomon 纠错 → 分块交织 →
* 功能图形 + 数据放置 → 按罚分挑选掩码 → 写入格式/版本信息。
*/
final class QrCode
{
private const ECC_CODEWORDS_PER_BLOCK = [
-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26,
26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
];
private const NUM_ERROR_CORRECTION_BLOCKS = [
-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16,
17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
];
/** M 级纠错的格式位。 */
private const FORMAT_BITS_M = 0;
private int $version;
private int $size;
/** @var list<list<bool>> */
private array $modules = [];
/** @var list<list<bool>> */
private array $isFunction = [];
private function __construct(string $text)
{
$bytes = array_values(unpack('C*', $text) ?: []);
$this->version = $this->chooseVersion(count($bytes));
$this->size = $this->version * 4 + 17;
$row = array_fill(0, $this->size, false);
$this->modules = array_fill(0, $this->size, $row);
$this->isFunction = array_fill(0, $this->size, $row);
$this->drawFunctionPatterns();
$this->drawCodewords($this->addEccAndInterleave($this->dataCodewords($bytes)));
$this->applyBestMask();
}
public static function svg(string $text, int $scale = 8, int $quietZone = 4): string
{
if ($text === '') {
throw new InvalidArgumentException('二维码内容不能为空。');
}
$qr = new self($text);
$dimension = $qr->size + $quietZone * 2;
$path = '';
for ($y = 0; $y < $qr->size; $y++) {
for ($x = 0; $x < $qr->size; $x++) {
if ($qr->modules[$y][$x]) {
$path .= sprintf('M%d %dh1v1h-1z', $x + $quietZone, $y + $quietZone);
}
}
}
$pixels = $dimension * $scale;
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %1$d %1$d" width="%2$d" height="%2$d" shape-rendering="crispEdges">'
. '<rect width="%1$d" height="%1$d" fill="#ffffff"/><path d="%3$s" fill="#000000"/></svg>',
$dimension,
$pixels,
$path,
);
}
/**
* @return list<list<bool>> 供测试校验的模块矩阵(true 为黑)。
*/
public static function matrix(string $text): array
{
return (new self($text))->modules;
}
private function chooseVersion(int $byteCount): int
{
for ($version = 1; $version <= 40; $version++) {
$capacityBits = $this->numDataCodewords($version) * 8;
$usedBits = 4 + ($version < 10 ? 8 : 16) + $byteCount * 8;
if ($usedBits <= $capacityBits) {
return $version;
}
}
throw new InvalidArgumentException('二维码内容过长。');
}
/**
* @param list<int> $bytes
* @return list<int>
*/
private function dataCodewords(array $bytes): array
{
$bits = [];
$append = static function (int $value, int $length) use (&$bits): void {
for ($i = $length - 1; $i >= 0; $i--) {
$bits[] = ($value >> $i) & 1;
}
};
$append(0b0100, 4);
$append(count($bytes), $this->version < 10 ? 8 : 16);
foreach ($bytes as $byte) {
$append($byte, 8);
}
$capacityBits = $this->numDataCodewords($this->version) * 8;
$append(0, min(4, $capacityBits - count($bits)));
$append(0, (8 - count($bits) % 8) % 8);
$codewords = [];
foreach (array_chunk($bits, 8) as $chunk) {
$value = 0;
foreach ($chunk as $bit) {
$value = ($value << 1) | $bit;
}
$codewords[] = $value;
}
for ($pad = 0xEC; count($codewords) < $capacityBits / 8; $pad ^= 0xEC ^ 0x11) {
$codewords[] = $pad;
}
return $codewords;
}
/**
* @param list<int> $data
* @return list<int>
*/
private function addEccAndInterleave(array $data): array
{
$numBlocks = self::NUM_ERROR_CORRECTION_BLOCKS[$this->version];
$blockEccLength = self::ECC_CODEWORDS_PER_BLOCK[$this->version];
$rawCodewords = intdiv($this->numRawDataModules($this->version), 8);
$numShortBlocks = $numBlocks - $rawCodewords % $numBlocks;
$shortBlockLength = intdiv($rawCodewords, $numBlocks);
$divisor = $this->reedSolomonDivisor($blockEccLength);
$blocks = [];
$offset = 0;
for ($i = 0; $i < $numBlocks; $i++) {
$length = $shortBlockLength - $blockEccLength + ($i < $numShortBlocks ? 0 : 1);
$block = array_slice($data, $offset, $length);
$offset += $length;
$ecc = $this->reedSolomonRemainder($block, $divisor);
if ($i < $numShortBlocks) {
$block[] = 0;
}
$blocks[] = array_merge($block, $ecc);
}
$result = [];
$blockLength = count($blocks[0]);
for ($i = 0; $i < $blockLength; $i++) {
foreach ($blocks as $j => $block) {
if ($i !== $shortBlockLength - $blockEccLength || $j >= $numShortBlocks) {
$result[] = $block[$i];
}
}
}
return $result;
}
private function drawFunctionPatterns(): void
{
for ($i = 0; $i < $this->size; $i++) {
$this->setFunction(6, $i, $i % 2 === 0);
$this->setFunction($i, 6, $i % 2 === 0);
}
$this->drawFinder(3, 3);
$this->drawFinder($this->size - 4, 3);
$this->drawFinder(3, $this->size - 4);
$positions = $this->alignmentPositions();
$count = count($positions);
foreach ($positions as $i => $x) {
foreach ($positions as $j => $y) {
if (($i === 0 && $j === 0) || ($i === 0 && $j === $count - 1) || ($i === $count - 1 && $j === 0)) {
continue;
}
for ($dy = -2; $dy <= 2; $dy++) {
for ($dx = -2; $dx <= 2; $dx++) {
$this->setFunction($x + $dx, $y + $dy, max(abs($dx), abs($dy)) !== 1);
}
}
}
}
$this->drawFormatBits(0);
$this->drawVersion();
}
private function drawFinder(int $x, int $y): void
{
for ($dy = -4; $dy <= 4; $dy++) {
for ($dx = -4; $dx <= 4; $dx++) {
$xx = $x + $dx;
$yy = $y + $dy;
if ($xx >= 0 && $xx < $this->size && $yy >= 0 && $yy < $this->size) {
$distance = max(abs($dx), abs($dy));
$this->setFunction($xx, $yy, $distance !== 2 && $distance !== 4);
}
}
}
}
private function drawFormatBits(int $mask): void
{
$data = (self::FORMAT_BITS_M << 3) | $mask;
$remainder = $data;
for ($i = 0; $i < 10; $i++) {
$remainder = ($remainder << 1) ^ (($remainder >> 9) * 0x537);
}
$bits = (($data << 10) | $remainder) ^ 0x5412;
$bit = static fn (int $index): bool => (($bits >> $index) & 1) !== 0;
for ($i = 0; $i <= 5; $i++) {
$this->setFunction(8, $i, $bit($i));
}
$this->setFunction(8, 7, $bit(6));
$this->setFunction(8, 8, $bit(7));
$this->setFunction(7, 8, $bit(8));
for ($i = 9; $i < 15; $i++) {
$this->setFunction(14 - $i, 8, $bit($i));
}
for ($i = 0; $i < 8; $i++) {
$this->setFunction($this->size - 1 - $i, 8, $bit($i));
}
for ($i = 8; $i < 15; $i++) {
$this->setFunction(8, $this->size - 15 + $i, $bit($i));
}
$this->setFunction(8, $this->size - 8, true);
}
private function drawVersion(): void
{
if ($this->version < 7) {
return;
}
$remainder = $this->version;
for ($i = 0; $i < 12; $i++) {
$remainder = ($remainder << 1) ^ (($remainder >> 11) * 0x1F25);
}
$bits = ($this->version << 12) | $remainder;
for ($i = 0; $i < 18; $i++) {
$dark = (($bits >> $i) & 1) !== 0;
$a = $this->size - 11 + $i % 3;
$b = intdiv($i, 3);
$this->setFunction($a, $b, $dark);
$this->setFunction($b, $a, $dark);
}
}
/**
* @param list<int> $codewords
*/
private function drawCodewords(array $codewords): void
{
$totalBits = count($codewords) * 8;
$index = 0;
for ($right = $this->size - 1; $right >= 1; $right -= 2) {
if ($right === 6) {
$right = 5;
}
for ($vertical = 0; $vertical < $this->size; $vertical++) {
for ($j = 0; $j < 2; $j++) {
$x = $right - $j;
$upward = (($right + 1) & 2) === 0;
$y = $upward ? $this->size - 1 - $vertical : $vertical;
if (! $this->isFunction[$y][$x] && $index < $totalBits) {
$this->modules[$y][$x] = (($codewords[$index >> 3] >> (7 - ($index & 7))) & 1) !== 0;
$index++;
}
}
}
}
}
private function applyBestMask(): void
{
$bestMask = 0;
$bestPenalty = PHP_INT_MAX;
for ($mask = 0; $mask < 8; $mask++) {
$this->applyMask($mask);
$this->drawFormatBits($mask);
$penalty = $this->penaltyScore();
if ($penalty < $bestPenalty) {
$bestPenalty = $penalty;
$bestMask = $mask;
}
$this->applyMask($mask);
}
$this->applyMask($bestMask);
$this->drawFormatBits($bestMask);
}
private function applyMask(int $mask): void
{
for ($y = 0; $y < $this->size; $y++) {
for ($x = 0; $x < $this->size; $x++) {
if ($this->isFunction[$y][$x]) {
continue;
}
$invert = match ($mask) {
0 => ($x + $y) % 2 === 0,
1 => $y % 2 === 0,
2 => $x % 3 === 0,
3 => ($x + $y) % 3 === 0,
4 => (intdiv($x, 3) + intdiv($y, 2)) % 2 === 0,
5 => $x * $y % 2 + $x * $y % 3 === 0,
6 => ($x * $y % 2 + $x * $y % 3) % 2 === 0,
default => (($x + $y) % 2 + $x * $y % 3) % 2 === 0,
};
if ($invert) {
$this->modules[$y][$x] = ! $this->modules[$y][$x];
}
}
}
}
private function penaltyScore(): int
{
$size = $this->size;
$penalty = 0;
$dark = 0;
$lineScore = function (callable $cell) use ($size): int {
$score = 0;
for ($line = 0; $line < $size; $line++) {
$run = 1;
$sequence = '';
for ($i = 0; $i < $size; $i++) {
$current = $cell($line, $i);
$sequence .= $current ? '1' : '0';
if ($i > 0 && $current === $cell($line, $i - 1)) {
$run++;
} else {
if ($run >= 5) {
$score += $run - 2;
}
$run = 1;
}
}
if ($run >= 5) {
$score += $run - 2;
}
$padded = '0000' . $sequence . '0000';
$score += 40 * (substr_count($padded, '00001011101') + substr_count($padded, '10111010000'));
}
return $score;
};
$penalty += $lineScore(fn (int $line, int $i): bool => $this->modules[$line][$i]);
$penalty += $lineScore(fn (int $line, int $i): bool => $this->modules[$i][$line]);
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if ($this->modules[$y][$x]) {
$dark++;
}
if ($x < $size - 1 && $y < $size - 1) {
$color = $this->modules[$y][$x];
if ($color === $this->modules[$y][$x + 1] && $color === $this->modules[$y + 1][$x] && $color === $this->modules[$y + 1][$x + 1]) {
$penalty += 3;
}
}
}
}
$total = $size * $size;
$k = intdiv(abs($dark * 20 - $total * 10) + $total - 1, $total) - 1;
return $penalty + max(0, $k) * 10;
}
/**
* @return list<int>
*/
private function alignmentPositions(): array
{
if ($this->version === 1) {
return [];
}
$count = intdiv($this->version, 7) + 2;
$step = $this->version === 32
? 26
: (int) ceil(($this->version * 4 + 4) / ($count * 2 - 2)) * 2;
$positions = [6];
for ($position = $this->size - 7; count($positions) < $count; $position -= $step) {
array_splice($positions, 1, 0, [$position]);
}
return $positions;
}
private function setFunction(int $x, int $y, bool $dark): void
{
$this->modules[$y][$x] = $dark;
$this->isFunction[$y][$x] = true;
}
private function numRawDataModules(int $version): int
{
$result = (16 * $version + 128) * $version + 64;
if ($version >= 2) {
$alignmentCount = intdiv($version, 7) + 2;
$result -= (25 * $alignmentCount - 10) * $alignmentCount - 55;
if ($version >= 7) {
$result -= 36;
}
}
return $result;
}
private function numDataCodewords(int $version): int
{
return intdiv($this->numRawDataModules($version), 8)
- self::ECC_CODEWORDS_PER_BLOCK[$version] * self::NUM_ERROR_CORRECTION_BLOCKS[$version];
}
/**
* @return list<int>
*/
private function reedSolomonDivisor(int $degree): array
{
$result = array_fill(0, $degree, 0);
$result[$degree - 1] = 1;
$root = 1;
for ($i = 0; $i < $degree; $i++) {
for ($j = 0; $j < $degree; $j++) {
$result[$j] = $this->gfMultiply($result[$j], $root);
if ($j + 1 < $degree) {
$result[$j] ^= $result[$j + 1];
}
}
$root = $this->gfMultiply($root, 0x02);
}
return $result;
}
/**
* @param list<int> $data
* @param list<int> $divisor
* @return list<int>
*/
private function reedSolomonRemainder(array $data, array $divisor): array
{
$result = array_fill(0, count($divisor), 0);
foreach ($data as $byte) {
$factor = $byte ^ array_shift($result);
$result[] = 0;
foreach ($divisor as $i => $coefficient) {
$result[$i] ^= $this->gfMultiply($coefficient, $factor);
}
}
return $result;
}
private function gfMultiply(int $x, int $y): int
{
$z = 0;
for ($i = 7; $i >= 0; $i--) {
$z = ($z << 1) ^ (($z >> 7) * 0x11D);
$z ^= (($y >> $i) & 1) * $x;
}
return $z & 0xFF;
}
}