快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
501 lines
16 KiB
PHP
501 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|