Merge remote-tracking branch 'origin/main' into feature/limeng-courier-agreement-signing

This commit is contained in:
limeng 2026-08-15 17:29:45 +08:00
commit ce5d777635
72 changed files with 7443 additions and 521 deletions

View File

@ -156,14 +156,15 @@
- 本系统按 3 个以上上游来源设计;来源统一登记在 `config/upstreams.php`,每个来源必须拥有独立配置、适配器、缓存命名空间、并发预算、熔断状态和后台刷新任务。一个来源故障不得清空或阻塞其他来源的快照。
- 上游是长期运行且不可修改的旧系统,统一按只读黑盒处理;不得要求修改其账号、表结构、索引、字段或启用 Change Tracking 等功能。
- 本地开发环境(`APP_ENV=local`)直接执行 `UpstreamQueryCache` resolver 中已经写入代码的只读查询,不等待生产刷新节点、共享快照或 `allowed_scopes`。两套连接资料仍从共享系统数据库自动解析,开发者只填写一次系统数据库密码,不再配置上游环境变量。
- 生产环境(`APP_ENV=production`)继续通过 `UpstreamQueryCache` 读取持久化在共享系统数据库 `upstream_query_cache` 中的按需查询快照;页面冷查询只登记需求,首份数据和后续刷新由 `upstream:snapshots:refresh-all` 生成。
- 项目正式使用到的上游表或视图,必须逐项物化到独立逻辑库 `caiwu_business_mirror`。页面查询、分页、筛选和汇总优先读取该共享业务镜像,不得再按页码或筛选条件临时生成一页查询快照。`caiwu_system_config` 只保存用户、权限、系统设置、连接资料、数据字典和同步状态;两个逻辑库默认复用同一台 MariaDB、同一套 TLS 和团队密码,新人仍只输入一次系统数据库密码。
- 现有尚未迁移的页面可以暂时继续使用 `UpstreamQueryCache`,但这是过渡兼容方案。新增业务表接入和已出现分页缺页、冷查询等待问题的旧页面,必须同时实现上游固定 Repository、稳定批次游标、镜像表、断点同步、同步状态和只读镜像 Repository;不得继续扩展按参数快照。
- 两套上游的表、视图和字段结构由命令 `upstream:schema:sync` 低频读取 `INFORMATION_SCHEMA`,保存到共享系统数据库 `upstream_schema_columns`。任何能够直连的开发环境或生产刷新节点都可以生成目录;同步失败必须保留最近一次完整目录。
- 缺少现成 Repository、页面适配器、生产 `scope` 或共享快照,不代表本地开发无法读取该来源。开发者和 Codex 在 `APP_ENV=local` 中可以直接通过 `UpstreamReadOnlyQueryFactory::forSource(<source_key>)` 对数据字典中选定的对象执行少量、限定字段和返回量的只读 `SELECT/WITH`,用于确认业务行、理解字段并实现功能;不需要先修改云数据库记录或等待生产刷新。禁止把用户输入拼成 SQL、输出敏感业务行或以此替代合并前的正式 Repository/测试。
- 新来源适配器必须通过 `UpstreamReadOnlyQuery` 执行固定、参数化的只读查询,禁止把原始 PDO 连接暴露给控制器或页面。即使现有数据库账号本身有写权限,应用代码也不得执行写入、高风险语句或存储过程。
- 系统数据库只保存活跃查询登记、参数、访问范围和共享结果,不执行 SQL。禁止把任意 SQL、DSN 或凭据写入任务表让后台解释执行;生产后台进程只能运行已经合并到 `main` 并在 `UpstreamSnapshotRefresher` 中登记的固定查询代码。
- 开发者需要尚未缓存的新表或字段时,必须在任务说明和 PR 中列出来源标识、业务目的、表/视图、字段、筛选与排序、最大返回量、公司/权限范围、缓存时间和失败表现;未说明边界的“把整张表接进来”不得合并。
- 新查询在任务分支中必须同时完成 Repository/适配器、稳定 `scope`、`UpstreamQueryCache` 调用、对应刷新器映射和测试。自动测试使用脱敏 fixture 或测试替身;能够直连的开发机可执行少量真实只读验收,但不得输出凭据、敏感业务行或提交本机覆盖配置。
- 新查询在本地开发环境可以直接读取验证,不需要先开放 `allowed_scopes`。合并并部署到生产前,仍需登记刷新器和生产精确范围,生产页面继续复用共享快照。
- 新业务表在任务分支中必须同时完成 Repository/适配器、稳定批次游标、镜像表、同步状态、可断点续跑的同步器、只读镜像 Repository、页面切换和测试。自动测试使用脱敏 fixture 或测试替身;能够直连的开发机可执行少量真实只读验收,但不得输出凭据、敏感业务行或提交本机覆盖配置。
- 本地探索可以直接读取上游验证字段和业务含义;正式页面完成接入后,本地和生产都优先读取共享业务镜像。首次全量同步完成前必须明确显示“尚未完成同步”,不得把局部数据或空表显示成真实的“无数据”。
- 快照复用按“来源 + 数据范围 + 查询条件 + 公司/权限范围”判断,与账户数量和开发机数量无关;权限或数据范围不同的结果不得错误共用。
- 生产列表和汇总快照默认 2 分钟,选项类数据默认 60 分钟。生产页面命中过期快照时立即返回共享旧值并排入后台刷新。
- 生产相同查询使用共享系统数据库中的独立单飞锁;每个来源的不同冷查询默认最多占用 2 个上游槽位、每分钟最多 6 个查询组、每轮最多刷新 2 个近期活跃查询。上述限制不作用于本地开发直连。
@ -171,7 +172,7 @@
- 上游 API 必须保留 `X-Caiwu-Data-Status` 和 `X-Caiwu-Data-Synced-At` 响应头;验收同时检查数据状态和同步时间,不能只看 HTTP 200。
- 每个来源的连接开关统一保存在系统数据库。`allowed_scopes` 只控制生产快照刷新,不阻挡本地开发直连;生产范围仍需使用精确前缀,禁止用 `*` 放开核心大表。
- 跨来源报表不得在页面请求中实时连接多个上游做 JOIN;需要组合的数据先分别受控读取并物化到共享标准表或查询快照,再从共享数据生成报表。物化记录必须保留来源标识、上游业务键和同步时间,金额仍以本地财务规则校验。
- 当前方案是近实时查询快照,不得冒充完整行级增量复制。没有可靠变更标记时,禁止定时全表扫描;历史一致性只能采用低频、小批量、可中止的核对。
- 共享业务镜像是项目实际使用表的受控副本,不是上游全库复制。每张表必须优先使用可靠的更新时间、完成时间或递增业务键做小批量增量同步;没有可靠变更标记时,只允许经评估的重叠时间窗、低频分段核对或定向补偿,禁止每分钟全表扫描。
- 首次全量历史数据同步必须单独评估行数、时间窗口和上游低峰期,不得在日常发布时自动执行。
- 生产服务器不能直接访问某个上游时,只允许通过专用低权限 SSH 账户建立受限反向隧道;每个来源使用独立 `SourceKey`、私有配置、计划任务和 Docker 私网端口,禁止监听公网地址,隧道账户禁止 Shell、TTY、本地转发和任意端口转发。
- 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1 -SourceKey <来源标识>` 安装。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。
@ -183,7 +184,7 @@
- 开发者拉取包含本规则的最新代码后,由 Codex 运行不带参数的 `deploy/local-init.sh`。脚本根据 Dockerfile、依赖锁文件、前端源码和静态资源计算镜像指纹,构建输入变化或必要静态资源缺失时自动重建,并清除 Laravel 配置缓存;不得要求新人判断是否使用 `--build`。`docker-compose.local.yml` 必须继续挂载 `./modules:/var/www/html/modules`,禁止只重启旧镜像后根据旧模块或旧静态资源判断主线无效。
- 本机临时引导库的新增迁移由 `deploy/local-init.sh` 和新版容器入口自动补齐;该逻辑只允许在 `SYSTEM_DATABASE_MODE=bootstrap`、MySQL 驱动且主机为固定容器服务名 `mysql` 时运行。连接共享系统数据库后必须自动跳过,普通开发者不需要也不得自行执行 `php artisan migrate`;看到本机迁移 `Pending` 时应重新运行标准初始化脚本,而不是操作共享数据库。
- Codex 排查开发机连接时,必须先确认当前主业务库仍是共享系统数据库,再确认 `UPSTREAM_STORED_CONFIG_ENABLED=true`、`UPSTREAM_LOCAL_CONFIG_ENABLED=false`,随后通过 `UpstreamSourceRegistry` 验证完整连接来自 `system_database_connections`。不得通过填写本机 `DB_JIANSUN_*`、`DB_OPERATIONS_REPLICA_*`、复制他人 `.env` 或统一 `APP_KEY` 来掩盖共享配置读取错误。
- 系统数据库统一保存应用数据、云数据库配置、上游数据字典和生产共享快照,但不是 SQL Server 网络代理。本地开发应用直接使用系统数据库中的连接资料访问两套上游;前提是该开发机网络本身能够到达数据库端点。
- 开发验收必须在不复用负责人现有 `.env`、登录会话、容器或数据库卷的全新隔离环境中完成:只填写一次系统数据库密码,然后分别验证两套来源的共享配置解析、`SELECT 1` 和至少一个真实业务表/视图的只读查询。只在负责人已配置环境中测试不算新人接入验收。生产验收还需验证共享快照。验收报告必须逐个来源明确写出来源标识、目标数据库和 Schema、验证的表或视图、读取字段、返回行数、耗时、`direct/fresh/stale` 状态及失败阶段,禁止只给“能连接”或“不能连接”的含糊结论。连接资料由程序自动读取和使用,不让新人手工配置;页面和正式 Repository 仍必须通过 `UpstreamReadOnlyQuery` 执行固定、参数化的 `SELECT`,禁止写操作和任意 SQL 输入。
- 系统数据库 `caiwu_system_config` 保存应用数据、云数据库配置、上游数据字典和同步状态;共享业务库 `caiwu_business_mirror` 保存已正式接入的上游业务行。二者都不是 SQL Server 网络代理。开发者只连接一次团队数据库,程序自动复用同一主机、端口、账号、密码和 TLS 访问两个逻辑库;需要探索尚未接入的数据时,Codex 才使用系统数据库中的连接资料执行受限只读上游查询。
- 开发验收必须在不复用负责人现有 `.env`、登录会话、容器或数据库卷的全新隔离环境中完成:只填写一次系统数据库密码,确认两个共享逻辑库均可读,再分别验证两套来源的共享配置解析、`SELECT 1` 和至少一个真实业务表/视图的只读查询。正式接入的数据还必须验证镜像总行数、第二页或末页、筛选结果、最近同步时间和上游限定计数能够对账。只在负责人已配置环境中测试不算新人接入验收。连接资料由程序自动读取和使用,不让新人手工配置;上游 Repository 仍必须通过 `UpstreamReadOnlyQuery` 执行固定、参数化的 `SELECT`,禁止写操作和任意 SQL 输入。
详细 SOP 见 `docs/开发与部署SOP.md`。

View File

@ -4,6 +4,7 @@ namespace App\Business;
use Illuminate\Pagination\LengthAwarePaginator;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
@ -11,9 +12,10 @@ class UpstreamBusinessInformation
{
private const SOURCE = 'operations_replica';
public function __construct(private readonly UpstreamQueryCacheFactory $caches)
{
}
public function __construct(
private readonly UpstreamQueryCacheFactory $caches,
private readonly BusinessInformationMirrorRepository $mirror,
) {}
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
{
@ -64,7 +66,7 @@ class UpstreamBusinessInformation
private function companyPage(int $page, int $perPage): array
{
return $this->remember(
return $this->page(
'company-information.read',
$page,
$perPage,
@ -108,7 +110,7 @@ SQL);
private function storePage(int $page, int $perPage): array
{
return $this->remember(
return $this->page(
'store-information.read',
$page,
$perPage,
@ -156,7 +158,7 @@ SQL);
private function trademarkPage(int $page, int $perPage): array
{
return $this->remember(
return $this->page(
'trademark-information.read',
$page,
$perPage,
@ -191,8 +193,12 @@ SQL);
];
}
private function remember(string $scope, int $page, int $perPage, callable $resolver): array
private function page(string $scope, int $page, int $perPage, callable $resolver): array
{
if ($this->mirror->ready($scope)) {
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage));
}
$cache = $this->caches->forSource(self::SOURCE);
return $cache->remember($scope, compact('page', 'perPage'), function () use ($resolver) {
@ -200,6 +206,22 @@ SQL);
});
}
/** @param array{data: array<int,array<string,mixed>>, total: int} $payload */
private function normalize(string $scope, array $payload): array
{
$mapper = match ($scope) {
'company-information.read' => fn (array $row): array => $this->mapCompany($row),
'store-information.read' => fn (array $row): array => $this->mapStore($row),
'trademark-information.read' => fn (array $row): array => $this->mapTrademark($row),
default => throw new InvalidArgumentException('未知的经营分析副本库资料类型:' . $scope),
};
return [
'data' => array_map($mapper, $payload['data']),
'total' => (int) $payload['total'],
];
}
private function mapCompany(array $row): array
{
$row = $this->toUtf8($row);

View File

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Throwable;
class SyncBusinessMirrors extends Command
{
protected $signature = 'business-mirror:sync-tables
{dataset? : 只同步指定数据集}
{--full : 执行或继续首次全量/校准同步}
{--batch= : 每批最多读取行数}
{--max-batches= : 每个数据集本轮最多批次数,0 表示直到完成}
{--max-datasets= : 本轮最多处理的数据集数量}';
protected $description = '将项目实际使用的上游表按稳定游标同步到共享业务镜像库';
public function handle(
BusinessMirrorDatasetRegistry $datasets,
BusinessMirrorSynchronizer $synchronizer,
GenericBusinessMirrorRepository $mirror,
): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
return self::SUCCESS;
}
$requested = $this->argument('dataset');
$keys = $requested === null
? $this->scheduledKeys($datasets, $mirror)
: [(string) $requested];
$maxDatasets = max((int) ($this->option('max-datasets')
?? config('upstream_mirrors.table_sync.max_datasets_per_run', 1)), 1);
$processed = 0;
$failed = false;
foreach ($keys as $key) {
if ($processed >= $maxDatasets) {
break;
}
try {
$result = $synchronizer->sync(
$key,
(bool) $this->option('full'),
$this->option('batch') === null ? null : (int) $this->option('batch'),
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
);
} catch (Throwable $exception) {
$failed = true;
Log::error('共享业务镜像表同步失败。', ['dataset' => $key, 'exception' => $exception::class]);
$this->error($key . ':同步失败,已保留上一版共享数据。');
$processed++;
continue;
}
if ($result['status'] === 'requires_full_sync') {
if ($requested !== null) {
$this->warn($key . ':尚未完成首次全量同步,请显式使用 --full。');
}
continue;
}
$processed++;
$this->info(sprintf(
'%s:%s,本轮 %d 批,累计处理 %d 行。',
$key,
$result['status'] === 'succeeded' ? '同步完成' : '同步进行中',
$result['batches'],
$result['rows'],
));
}
return $failed ? self::FAILURE : self::SUCCESS;
}
/** @return list<string> */
private function scheduledKeys(
BusinessMirrorDatasetRegistry $datasets,
GenericBusinessMirrorRepository $mirror,
): array {
$scheduled = [];
foreach ($datasets->all() as $key => $dataset) {
$state = $mirror->state($dataset);
if (! is_array($state) || ($state['initial_completed_at'] ?? null) === null) {
continue;
}
$scheduled[] = [
'key' => $key,
'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''),
];
}
usort($scheduled, fn (array $left, array $right): int =>
[$left['last_attempted_at'], $left['key']] <=> [$right['last_attempted_at'], $right['key']]
);
return array_column($scheduled, 'key');
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\ProductPriceMirrorSynchronizer;
use Throwable;
class SyncProductPriceMirror extends Command
{
protected $signature = 'business-mirror:sync-product-prices
{--full : 执行或继续首次全量同步}
{--batch= : 每批最多读取行数}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '将经营分析副本库的商品价格数据同步到项目共享业务镜像库';
public function handle(ProductPriceMirrorSynchronizer $synchronizer): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
return self::SUCCESS;
}
try {
$result = $synchronizer->sync(
(bool) $this->option('full'),
$this->option('batch') === null ? null : (int) $this->option('batch'),
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
);
} catch (Throwable $exception) {
Log::error('商品价格共享镜像同步失败。', ['exception' => $exception::class]);
$this->error('商品价格共享镜像同步失败,已保留上一版共享数据。');
return self::FAILURE;
}
if ($result['status'] === 'requires_full_sync') {
$this->warn('商品价格共享镜像尚未执行首次全量同步。');
return self::SUCCESS;
}
$this->info(sprintf(
'商品价格镜像:%s,本轮 %d 批,累计处理 %d 行。',
$result['status'] === 'succeeded' ? '同步完成' : '同步进行中',
$result['batches'],
$result['rows'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteChange;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierQuoteSignatureService;
use App\Support\QuoteManagementSnapshot;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
class CourierQuoteChanges extends Controller
{
public function __construct(
private readonly CourierQuoteChangeService $service,
private readonly QuoteManagementSnapshot $snapshot,
private readonly CourierQuoteSignatureService $signatureService,
) {
}
public function index(): View
{
$changes = CourierQuoteChange::query()
->with(['items', 'version'])
->latest('updated_at')
->paginate(20);
return view('business.pages.courier-payables.quote-change-history', [
'changes' => $changes,
'canReview' => $this->canReview(),
]);
}
public function create(Request $request): View
{
$quotes = $this->snapshot->load()['quotes'] ?? [];
abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。');
$selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']);
$source = $this->service->findSourceQuote($selectedId);
return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source));
}
public function store(Request $request): RedirectResponse
{
$change = $this->service->create($this->validatedChange($request, true), user_id());
return redirect()->route('business.courier-quote-changes.edit', $change)
->with('success', "已保存变更草稿 {$change->number}。原始报价未被修改。");
}
public function show(CourierQuoteChange $quoteChange): View
{
$quoteChange->load(['items', 'events.actor', 'version.signature']);
return view('business.pages.courier-payables.quote-change-show', [
'change' => $quoteChange,
'canReview' => $this->canReview() && $quoteChange->created_by !== user_id(),
'canManageSignature' => $this->canReview(),
'esignConfigured' => EsignClient::configured(),
'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'),
]);
}
public function edit(CourierQuoteChange $quoteChange): View
{
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
$quotes = $this->snapshot->load()['quotes'] ?? [];
$source = $this->service->findSourceQuote($quoteChange->source_quote_id);
$quoteChange->load('items');
return view('business.pages.courier-payables.quote-change-editor', $this->editorData($quoteChange, $quotes, $source));
}
public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
$change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id());
return redirect()->route('business.courier-quote-changes.edit', $change)
->with('success', "已保存变更草稿 {$change->number}。");
}
public function submit(CourierQuoteChange $quoteChange): RedirectResponse
{
$change = $this->service->submit($quoteChange, user_id());
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', "{$change->number} 已提交审核。");
}
public function approve(CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$change = $this->service->approve($quoteChange, user_id());
$message = "审核完成,已生成新报价版本 {$change->version->version_code}。";
try {
$this->signatureService->prepareVersion($change->version, user_id());
$message .= ' 签署 PDF 已生成并固定校验值。';
} catch (\Throwable $exception) {
report($exception);
$message .= ' 签署 PDF 生成失败,可在详情页重试。';
}
if ($change->isRetrospective()) {
$message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。';
}
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', $message);
}
public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$validated = $request->validate([
'review_note' => ['required', 'string', 'min:2', 'max:1000'],
]);
$change = $this->service->returnForChanges($quoteChange, $validated['review_note'], user_id());
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', "{$change->number} 已退回发起人修改。");
}
private function validatedChange(Request $request, bool $requiresSource): array
{
$rules = [
'change_type' => ['required', 'in:费率调整,面单调整,规则备注,执行日期,其他'],
'effective_date' => ['required', 'date_format:Y-m-d'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'items' => ['required', 'array', 'min:1', 'max:500'],
'items.*.source_cell' => ['required', 'regex:/^[A-Z]+[1-9][0-9]*$/'],
'items.*.source_row' => ['required', 'integer', 'min:0'],
'items.*.source_column' => ['required', 'integer', 'min:0'],
'items.*.region' => ['nullable', 'string', 'max:255'],
'items.*.weight_band' => ['nullable', 'string', 'max:255'],
'items.*.charge_item' => ['nullable', 'string', 'max:255'],
'items.*.original_amount' => ['required', 'numeric', 'min:0', 'decimal:0,4'],
'items.*.new_amount' => ['required', 'numeric', 'min:0', 'decimal:0,4'],
'items.*.unit' => ['nullable', 'string', 'max:32'],
'items.*.note' => ['nullable', 'string', 'max:500'],
];
if ($requiresSource) {
$rules['source_quote_id'] = ['required', 'string', 'max:32'];
}
return $request->validate($rules);
}
private function editorData(?CourierQuoteChange $change, array $quotes, array $source): array
{
$existingItems = $change?->items->keyBy('source_cell') ?? collect();
return [
'change' => $change,
'quotes' => $quotes,
'source' => $source,
'editableCells' => $this->editableCells($source, $existingItems),
];
}
private function editableCells(array $source, $existingItems): array
{
$values = $source['values'];
$cells = [];
foreach ($values as $rowIndex => $row) {
foreach ($row as $columnIndex => $value) {
if (! is_numeric($value)) {
continue;
}
$formula = $source['formulas'][$rowIndex][$columnIndex] ?? null;
if (is_string($formula) && trim($formula) !== '') {
continue;
}
$cell = $this->columnName($columnIndex) . ($rowIndex + 1);
$existing = $existingItems->get($cell);
$cells[] = [
'source_cell' => $cell,
'source_row' => $rowIndex,
'source_column' => $columnIndex,
'region' => $this->nearestText($row, $columnIndex - 1) ?? $this->nearestText($row, 0),
'weight_band' => $this->headerText($values, $rowIndex, $columnIndex),
'charge_item' => $this->headerText($values, $rowIndex, $columnIndex),
'original_amount' => number_format((float) $value, 4, '.', ''),
'new_amount' => $existing?->new_amount ?? number_format((float) $value, 4, '.', ''),
'unit' => $existing?->unit,
'note' => $existing?->note,
'selected' => $existing !== null,
];
}
}
return $cells;
}
private function headerText(array $values, int $row, int $column): ?string
{
for ($index = $row - 1; $index >= 0; $index--) {
$candidate = $values[$index][$column] ?? null;
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
return null;
}
private function nearestText(array $row, int $column): ?string
{
for ($index = $column; $index >= 0; $index--) {
$candidate = $row[$index] ?? null;
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
return null;
}
private function columnName(int $column): string
{
$name = '';
do {
$name = chr(65 + ($column % 26)) . $name;
$column = intdiv($column, 26) - 1;
} while ($column >= 0);
return $name;
}
private function canReview(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteSignatureService;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierQuoteSignatures extends Controller
{
public function __construct(private readonly CourierQuoteSignatureService $service)
{
}
public function prepare(CourierQuoteVersion $quoteVersion): RedirectResponse
{
$this->ensureCanManage();
abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。');
$signature = $this->service->prepareVersion($quoteVersion, user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteVersion->source_change_id)
->with('success', "{$quoteVersion->version_code} 的签署 PDF 已生成,文件校验值已固定。")
->with('signature_id', $signature->id);
}
public function send(Request $request, CourierQuoteSignature $quoteSignature): RedirectResponse
{
$this->ensureCanManage();
abort_unless($quoteSignature->version?->status === 'approved', 422, '只有已审核报价版本可以发起签署。');
abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$data = $request->validate([
'organization_name' => ['required', 'string', 'min:2', 'max:255'],
'operator_name' => ['required', 'string', 'min:2', 'max:100'],
'operator_account' => ['required', 'string', 'max:255'],
'notice_type' => ['required', 'in:1,2'],
'expires_at' => ['required', 'date', 'after:now', 'before_or_equal:' . now()->addDays(90)->toDateTimeString()],
]);
$this->validateAccountForNotice($data['operator_account'], $data['notice_type']);
$signature = $this->service->send($quoteSignature, $data, user_id());
$environment = $signature->environment === 'sandbox' ? '沙箱' : '正式环境';
return redirect()->route('business.courier-quote-changes.show', $signature->version->source_change_id)
->with('success', "{$signature->version->version_code} 已推送到 E 签宝{$environment},等待快递公司签署。");
}
public function synchronize(CourierQuoteSignature $quoteSignature): RedirectResponse
{
$this->ensureCanManage();
$signature = $this->service->synchronize($quoteSignature, user_id());
$message = $signature->status === CourierQuoteSignature::STATUS_ARCHIVED
? '签署完成,签署 PDF 和完成证书已私有归档。'
: "已同步 E 签宝状态:{$signature->statusLabel()}。";
return redirect()->route('business.courier-quote-changes.show', $signature->version->source_change_id)
->with('success', $message);
}
public function download(CourierQuoteSignature $quoteSignature, string $kind): StreamedResponse
{
[$path, $suffix] = match ($kind) {
'source' => [$quoteSignature->source_pdf_path, '报价签署原文'],
'signed' => [$quoteSignature->signed_pdf_path, '已签署报价'],
'certificate' => [$quoteSignature->certificate_path, '签署完成证书'],
default => abort(404),
};
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。');
$fileName = $quoteSignature->version->version_code . '-' . $suffix . '.pdf';
return \Storage::disk('uploads')->download($path, $fileName, ['Content-Type' => 'application/pdf']);
}
private function validateAccountForNotice(string $account, string $noticeType): void
{
$valid = $noticeType === '1'
? preg_match('/^1\d{10}$/', $account) === 1
: filter_var($account, FILTER_VALIDATE_EMAIL) !== false;
if (! $valid) {
$message = $noticeType === '1' ? '短信通知必须填写 11 位手机号。' : '邮件通知必须填写有效邮箱。';
throw ValidationException::withMessages(['operator_account' => $message]);
}
}
private function ensureCanManage(): void
{
abort_unless(
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true),
403,
'当前账号没有报价签署管理权限。',
);
}
}

View File

@ -19,7 +19,7 @@ use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
@ -38,7 +38,7 @@ class Pages extends Controller
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly ProductPriceRepository $productPrices,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
@ -272,18 +272,22 @@ class Pages extends Controller
: app(PinduoduoSpreadBillRepository::class);
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
$result = $cache->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
),
$resolver = fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
);
$result = $usingMirror
? $resolver()
: $cache->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$resolver,
);
} catch (UpstreamSnapshotPendingException $exception) {
$content['pending'] = true;
$result = ['rows' => [], 'total' => 0];
@ -297,11 +301,14 @@ class Pages extends Controller
}
try {
$storeOptions = $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
[],
fn () => $repository->storeOptions(),
);
$resolver = fn () => $repository->storeOptions();
$storeOptions = $usingMirror
? $resolver()
: $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
[],
$resolver,
);
} catch (Throwable $exception) {
report($exception);
$storeOptions = [];
@ -352,11 +359,14 @@ class Pages extends Controller
], static fn (mixed $value): bool => $value !== '');
try {
$result = $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true],
fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000),
);
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = $repository->usesBusinessMirror()
? $resolver()
: $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true],
$resolver,
);
} catch (ServiceUnavailableHttpException) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
@ -504,51 +514,61 @@ class Pages extends Controller
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('operations_replica');
try {
$result = $cache->remember(
'supplier-payables.product-prices.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->productPrices->paginate($filters, $page, $limit),
);
if (! $this->productPrices->ready()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务库尚未完成建库,请等待负责人完成初始化。'
);
}
if (! $this->productPrices->initialized()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'商品价格正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'
);
}
$paginator = $this->productPrices->paginate($filters, $page, $limit);
$paginator->appends(request()->except('page'));
return [
'productPrices' => $this->productPricePaginator((array) ($result['rows'] ?? []), $limit, $page),
'productPrices' => $paginator,
'productPriceFilters' => $filters,
'productPriceStatus' => $cache->responseHeaders(),
'productPriceError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'productPrices' => $this->productPricePaginator([], $limit, $page),
'productPriceFilters' => $filters,
'productPriceStatus' => [],
'productPriceError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'productPrices' => $this->productPricePaginator([], $limit, $page),
'productPriceFilters' => $filters,
'productPriceStatus' => [],
'productPriceError' => '商品价格暂时无法读取,请稍后重试。',
];
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。'
);
}
}
private function productPricePaginator(array $rows, int $limit, int $page): Paginator
private function emptyProductPriceContent(array $filters, int $limit, int $page, string $error): array
{
return new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
return [
'productPrices' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productPriceFilters' => $filters,
'productPriceError' => $error,
];
}
/**
@ -870,28 +890,38 @@ class Pages extends Controller
];
$repository = app(AlibabaBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
$result = $cache->remember(
'marketing-account-bills.alibaba.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $repository->paginate(
$filters['shop_name'], $filters['product_type'],
$filters['from'], $filters['to'], $page, $pageSize,
),
$listResolver = fn () => $repository->paginate(
$filters['shop_name'], $filters['product_type'],
$filters['from'], $filters['to'], $page, $pageSize,
);
$summary = $cache->remember(
'marketing-account-bills.alibaba.summary',
['filters' => $filters],
fn () => $repository->summary(
$filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'],
),
);
$options = $cache->remember(
'marketing-account-bills.alibaba.options',
[],
fn () => $repository->filterOptions(),
$summaryResolver = fn () => $repository->summary(
$filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'],
);
$optionsResolver = fn () => $repository->filterOptions();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'marketing-account-bills.alibaba.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'marketing-account-bills.alibaba.summary',
['filters' => $filters],
$summaryResolver,
);
$options = $usingMirror
? $optionsResolver()
: $cache->remember(
'marketing-account-bills.alibaba.options',
[],
$optionsResolver,
);
} catch (ServiceUnavailableHttpException $exception) {
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
@ -956,33 +986,43 @@ class Pages extends Controller
'flow_type' => $flowType !== '' ? $flowType : null,
];
$result = $cache->remember(
'taobao-alipay-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
)
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summary = $cache->remember(
'taobao-alipay-bills.summary',
['filters' => $filters],
fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
)
);
$storeOptions = $cache->remember(
'taobao-alipay-bills.options',
[],
fn () => $repository->storeOptions()
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'taobao-alipay-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'taobao-alipay-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'taobao-alipay-bills.options',
[],
$optionsResolver,
);
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
@ -1031,34 +1071,43 @@ class Pages extends Controller
];
// 页面只读取共享查询快照;冷查询由统一限流和熔断链路访问上游。
$result = $cache->remember(
'pinduoduo-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
)
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summary = $cache->remember(
'pinduoduo-bills.summary',
['filters' => $filters],
fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
)
);
$storeOptions = $cache->remember(
'pinduoduo-bills.options',
[],
fn () => $repository->storeOptions()
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'pinduoduo-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'pinduoduo-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'pinduoduo-bills.options',
[],
$optionsResolver,
);
// 包装成 Laravel 分页器,便于 Blade 用 $bills->links()
$bills = new LengthAwarePaginator(
@ -1104,9 +1153,10 @@ class Pages extends Controller
$cache = $this->upstreamCaches->forSource('jiansun');
$scopePrefix = 'jd-wallet-bills.' . $ledger;
$errors = [];
$remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, &$errors): mixed {
$usingMirror = $repository->usesBusinessMirror($ledger);
$remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, $usingMirror, &$errors): mixed {
try {
return $cache->remember($scope, $parameters, $resolver);
return $usingMirror ? $resolver() : $cache->remember($scope, $parameters, $resolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
} catch (Throwable $exception) {
@ -1135,12 +1185,19 @@ class Pages extends Controller
: $repository->summaryRecharge($filters['store_name'], $filters['from'], $filters['to']),
['amount_total' => 0, 'balance_total' => 0, 'count' => 0],
);
$shopOptions = $remember(
'jd-wallet-bills.options',
[],
fn () => $repository->shopOptions(),
[],
);
try {
$optionsResolver = fn () => $repository->shopOptions();
$shopOptions = $repository->usesBusinessMirrorForOptions()
? $optionsResolver()
: $cache->remember('jd-wallet-bills.options', [], $optionsResolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
$shopOptions = [];
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
$shopOptions = [];
}
return [
'view' => 'banking.marketing_jd_wallet_bills.partials.embedded-table',

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Integrations;
use App\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class EsignCallbacks extends Controller
{
public function __invoke(Request $request, CourierQuoteSignatureService $service): JsonResponse
{
$appId = trim((string) config('services.esign.app_id'));
$appSecret = trim((string) config('services.esign.app_secret'));
if ($appId === '' || $appSecret === '') {
return response()->json(['code' => '503', 'msg' => 'not configured'], 503);
}
$timestamp = trim((string) $request->header('X-Tsign-Open-TIMESTAMP'));
$algorithm = trim((string) $request->header('X-Tsign-Open-SIGNATURE-ALGORITHM'));
$callbackAppId = trim((string) $request->header('X-Tsign-Open-App-Id'));
$signature = strtolower(trim((string) $request->header('X-Tsign-Open-SIGNATURE')));
$rawBody = $request->getContent();
if (! ctype_digit($timestamp)
|| strcasecmp($algorithm, 'HmacSHA256') !== 0
|| ! hash_equals($appId, $callbackAppId)
|| $signature === ''
|| ! $this->timestampIsFresh($timestamp)) {
return response()->json(['code' => '401', 'msg' => 'invalid callback'], 401);
}
$queryValues = $request->query();
ksort($queryValues, SORT_STRING);
$data = $timestamp . collect($queryValues)->map(fn ($value): string => $this->queryValue($value))->implode('') . $rawBody;
$expected = strtolower(hash_hmac('sha256', $data, $appSecret));
if (! hash_equals($expected, $signature)) {
return response()->json(['code' => '401', 'msg' => 'invalid callback'], 401);
}
$payload = json_decode($rawBody, true);
if (! is_array($payload)) {
return response()->json(['code' => '400', 'msg' => 'invalid json'], 400);
}
if (($payload['action'] ?? null) !== 'SIGN_FLOW_COMPLETE') {
return $this->success();
}
$signFlowId = trim((string) ($payload['signFlowId'] ?? ''));
$remoteStatus = trim((string) ($payload['signFlowStatus'] ?? ''));
if ($signFlowId === '' || $remoteStatus === '') {
return $this->success();
}
$signatureRecord = $service->acceptCallback($signFlowId, $remoteStatus, hash('sha256', $rawBody));
if ($signatureRecord
&& $remoteStatus === '2'
&& $signatureRecord->status !== CourierQuoteSignature::STATUS_ARCHIVED) {
ArchiveCourierQuoteSignature::dispatchAfterResponse($signatureRecord->id);
}
return $this->success();
}
private function timestampIsFresh(string $timestamp): bool
{
$seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp;
$tolerance = max(60, (int) config('services.esign.callback_tolerance_seconds', 900));
return abs(now()->timestamp - $seconds) <= $tolerance;
}
private function queryValue(mixed $value): string
{
if (is_array($value)) {
return collect($value)->map(fn ($item): string => $this->queryValue($item))->implode('');
}
return is_scalar($value) ? (string) $value : '';
}
private function success(): JsonResponse
{
return response()->json(['code' => '200', 'msg' => 'success']);
}
}

View File

@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware
* @var array
*/
protected $except = [
//
'integrations/esign/callback',
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Jobs\Business;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ArchiveCourierQuoteSignature implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(public readonly int $signatureId)
{
}
public function handle(CourierQuoteSignatureService $service): void
{
$service->synchronizeById($this->signatureId);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class CourierQuoteChange extends Model
{
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING_REVIEW = 'pending_review';
public const STATUS_RETURNED = 'returned';
public const STATUS_APPROVED = 'approved';
public const RECALCULATION_NOT_REQUIRED = 'not_required';
public const RECALCULATION_PENDING = 'pending';
public const RECALCULATION_BLOCKED = 'blocked';
protected $table = 'business_courier_quote_changes';
protected $fillable = [
'company_id', 'number', 'source_quote_id', 'source_sheet_name', 'carrier_name', 'quote_type',
'change_type', 'status', 'effective_date', 'is_retrospective', 'recalculation_status',
'recalculation_note', 'summary', 'created_by', 'submitted_by', 'submitted_at', 'approved_by',
'approved_at', 'new_quote_version_id',
];
protected $casts = [
'company_id' => 'integer',
'effective_date' => 'date',
'is_retrospective' => 'boolean',
'submitted_at' => 'datetime',
'approved_at' => 'datetime',
];
public function items(): HasMany
{
return $this->hasMany(CourierQuoteChangeItem::class, 'quote_change_id')->orderBy('position');
}
public function events(): HasMany
{
return $this->hasMany(CourierQuoteChangeEvent::class, 'quote_change_id')->orderBy('created_at');
}
public function version(): HasOne
{
return $this->hasOne(CourierQuoteVersion::class, 'source_change_id');
}
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true);
}
public function isRetrospective(): bool
{
return (bool) $this->is_retrospective;
}
public function recalculationStatusLabel(): string
{
return match ($this->recalculation_status) {
self::RECALCULATION_NOT_REQUIRED => '无需追溯',
self::RECALCULATION_PENDING => '待追溯核算',
self::RECALCULATION_BLOCKED => '核算受阻',
default => $this->recalculation_status,
};
}
public function statusLabel(): string
{
return match ($this->status) {
self::STATUS_DRAFT => '草稿',
self::STATUS_PENDING_REVIEW => '待审核',
self::STATUS_RETURNED => '已退回',
self::STATUS_APPROVED => '已审核',
default => $this->status,
};
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierQuoteChangeEvent extends Model
{
protected $table = 'business_courier_quote_change_events';
protected $fillable = [
'company_id', 'quote_change_id', 'event_type', 'from_status', 'to_status', 'note', 'details_json', 'created_by',
];
protected $casts = ['company_id' => 'integer'];
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'quote_change_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(user_model_class(), 'created_by');
}
public function eventLabel(): string
{
return match ($this->event_type) {
'created' => '创建草稿',
'saved' => '保存草稿',
'submitted' => '提交审核',
'returned' => '退回修改',
'approved' => '审核通过',
default => $this->event_type,
};
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierQuoteChangeItem extends Model
{
protected $table = 'business_courier_quote_change_items';
protected $fillable = [
'company_id', 'quote_change_id', 'position', 'source_cell', 'source_row', 'source_column',
'region', 'weight_band', 'charge_item', 'original_amount', 'new_amount', 'unit', 'note',
];
protected $casts = [
'company_id' => 'integer',
'position' => 'integer',
'source_row' => 'integer',
'source_column' => 'integer',
'original_amount' => 'decimal:4',
'new_amount' => 'decimal:4',
];
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'quote_change_id');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierQuoteSignature extends Model
{
public const STATUS_PREPARED = 'prepared';
public const STATUS_SENDING = 'sending';
public const STATUS_SIGNING = 'signing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_REVOKED = 'revoked';
public const STATUS_EXPIRED = 'expired';
public const STATUS_REJECTED = 'rejected';
public const STATUS_FAILED = 'failed';
protected $table = 'business_courier_quote_signatures';
protected $fillable = [
'company_id', 'quote_version_id', 'provider', 'environment', 'status', 'organization_name',
'operator_name', 'operator_account', 'notice_types', 'expires_at', 'source_pdf_path',
'source_pdf_sha256', 'source_pdf_generated_at', 'esign_file_id', 'esign_sign_flow_id',
'remote_status', 'signed_pdf_path', 'signed_pdf_sha256', 'certificate_path',
'certificate_sha256', 'sent_at', 'completed_at', 'archived_at', 'last_synced_at',
'last_error', 'callback_payload_hash', 'created_by', 'updated_by',
];
protected $casts = [
'company_id' => 'integer',
'quote_version_id' => 'integer',
'expires_at' => 'datetime',
'source_pdf_generated_at' => 'datetime',
'sent_at' => 'datetime',
'completed_at' => 'datetime',
'archived_at' => 'datetime',
'last_synced_at' => 'datetime',
];
public function version(): BelongsTo
{
return $this->belongsTo(CourierQuoteVersion::class, 'quote_version_id');
}
public function statusLabel(): string
{
return match ($this->status) {
self::STATUS_PREPARED => '待推送',
self::STATUS_SENDING => '推送中',
self::STATUS_SIGNING => '待签署',
self::STATUS_COMPLETED => '签署完成,待归档',
self::STATUS_ARCHIVED => '已签署并归档',
self::STATUS_REVOKED => '已撤销',
self::STATUS_EXPIRED => '已过期',
self::STATUS_REJECTED => '已拒签',
self::STATUS_FAILED => '处理失败',
default => $this->status,
};
}
public function canSend(): bool
{
return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED], true)
&& blank($this->esign_sign_flow_id);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class CourierQuoteVersion extends Model
{
protected $table = 'business_courier_quote_versions';
protected $fillable = [
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'quote_type', 'effective_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by',
];
protected $casts = [
'company_id' => 'integer',
'effective_date' => 'date',
];
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');
}
public function signature(): HasOne
{
return $this->hasOne(CourierQuoteSignature::class, 'quote_version_id');
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace App\Services\Esign;
use Illuminate\Support\Facades\Http;
use JsonException;
use RuntimeException;
final class EsignClient
{
private readonly string $baseUrl;
private readonly EsignRequestSigner $signer;
public function __construct()
{
$appId = trim((string) config('services.esign.app_id'));
$appSecret = trim((string) config('services.esign.app_secret'));
if ($appId === '' || $appSecret === '') {
throw new RuntimeException('E 签宝沙箱尚未配置 App ID 或 AppSecret。');
}
$this->baseUrl = rtrim((string) config('services.esign.base_url'), '/');
$this->signer = new EsignRequestSigner($appId, $appSecret);
}
public static function configured(): bool
{
return filled(config('services.esign.app_id')) && filled(config('services.esign.app_secret'));
}
public function uploadPdf(string $bytes, string $fileName): string
{
$contentMd5 = base64_encode(md5($bytes, true));
$data = $this->request('POST', '/v3/files/file-upload-url', [
'contentMd5' => $contentMd5,
'contentType' => 'application/pdf',
'fileName' => $fileName,
'fileSize' => strlen($bytes),
'convertToPDF' => false,
]);
$fileId = (string) ($data['fileId'] ?? '');
$uploadUrl = (string) ($data['fileUploadUrl'] ?? '');
if ($fileId === '' || $uploadUrl === '') {
throw new RuntimeException('E 签宝未返回文件 ID 或上传地址。');
}
$uploadResponse = Http::timeout($this->timeout())
->withHeaders(['Content-MD5' => $contentMd5])
->withBody($bytes, 'application/pdf')
->send('PUT', $uploadUrl);
if (! $uploadResponse->successful()) {
throw new RuntimeException('报价 PDF 上传到 E 签宝文件服务失败。');
}
$this->waitUntilFileReady($fileId);
return $fileId;
}
public function createSignFlow(string $fileId, string $fileName, array $signer, int $expiresAtMs): string
{
$flowConfig = [
'signFlowTitle' => $signer['flow_title'],
'signFlowExpireTime' => $expiresAtMs,
'autoStart' => true,
'autoFinish' => true,
];
$notifyUrl = trim((string) config('services.esign.notify_url'));
if ($notifyUrl !== '') {
$flowConfig['notifyUrl'] = $notifyUrl;
}
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
'docs' => [[
'fileId' => $fileId,
'fileName' => $fileName,
]],
'signFlowConfig' => $flowConfig,
'signers' => [[
'signerType' => 1,
'signConfig' => ['signOrder' => 1],
'noticeConfig' => ['noticeTypes' => $signer['notice_types']],
'orgSignerInfo' => [
'orgName' => $signer['organization_name'],
'transactorInfo' => [
'psnAccount' => $signer['operator_account'],
'psnInfo' => ['psnName' => $signer['operator_name']],
],
],
'signFields' => [[
'fileId' => $fileId,
'normalSignFieldConfig' => [
'freeMode' => true,
'movableSignField' => true,
'signFieldSize' => '159',
],
]],
]],
]);
$signFlowId = (string) ($data['signFlowId'] ?? '');
if ($signFlowId === '') {
throw new RuntimeException('E 签宝未返回签署流程 ID。');
}
return $signFlowId;
}
public function signFlowDetail(string $signFlowId): array
{
return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail");
}
public function downloadUrls(string $signFlowId): array
{
return $this->request('POST', "/v3/sign-flow/{$signFlowId}/file-download-url", [
'urlAvailableDate' => '3600',
]);
}
public function download(string $url): string
{
$response = Http::timeout($this->timeout())->get($url);
if (! $response->successful()) {
throw new RuntimeException('无法从 E 签宝下载签署归档文件。');
}
return $response->body();
}
private function waitUntilFileReady(string $fileId): void
{
for ($attempt = 0; $attempt < 8; $attempt++) {
$data = $this->request('GET', "/v3/files/{$fileId}");
$status = (string) ($data['fileStatus'] ?? $data['status'] ?? '');
if (in_array($status, ['2', '5'], true)) {
return;
}
if ($attempt < 7) {
usleep(400000);
}
}
throw new RuntimeException('E 签宝文件处理超时,请稍后重试。');
}
private function request(string $method, string $path, array $payload = []): array
{
$method = strtoupper($method);
$body = $method === 'GET' ? '' : $this->encodeJson($payload);
$request = Http::timeout($this->timeout())
->withHeaders($this->signer->headers($method, $path, $body));
if ($body !== '') {
$request = $request->withBody($body, EsignRequestSigner::CONTENT_TYPE);
}
$response = $request->send($method, $this->baseUrl . $path);
if (! $response->successful()) {
throw new RuntimeException("E 签宝接口请求失败(HTTP {$response->status()})。");
}
$decoded = $response->json();
if (! is_array($decoded)) {
throw new RuntimeException('E 签宝接口返回了无法识别的数据。');
}
if ((int) ($decoded['code'] ?? -1) !== 0) {
$code = (string) ($decoded['code'] ?? 'unknown');
$message = mb_substr((string) ($decoded['message'] ?? '未知错误'), 0, 300);
throw new RuntimeException("E 签宝接口返回业务错误({$code}):{$message}");
}
return is_array($decoded['data'] ?? null) ? $decoded['data'] : [];
}
private function encodeJson(array $payload): string
{
try {
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('E 签宝请求数据无法编码。', previous: $exception);
}
}
private function timeout(): int
{
return max(5, (int) config('services.esign.timeout', 20));
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Services\Esign;
use InvalidArgumentException;
final class EsignRequestSigner
{
public const ACCEPT = '*/*';
public const CONTENT_TYPE = 'application/json; charset=UTF-8';
public function __construct(
private readonly string $appId,
private readonly string $appSecret,
) {
if ($this->appId === '' || $this->appSecret === '') {
throw new InvalidArgumentException('E 签宝 App ID 和 AppSecret 不能为空。');
}
}
public function headers(string $method, string $pathAndParameters, string $body = ''): array
{
$method = strtoupper($method);
$pathAndParameters = $this->canonicalPath($pathAndParameters);
$contentMd5 = $body === '' ? '' : base64_encode(md5($body, true));
$stringToSign = implode("\n", [
$method,
self::ACCEPT,
$contentMd5,
self::CONTENT_TYPE,
'',
]) . "\n" . $pathAndParameters;
return [
'X-Tsign-Open-App-Id' => $this->appId,
'X-Tsign-Open-Auth-Mode' => 'Signature',
'X-Tsign-Open-Ca-Timestamp' => (string) floor(microtime(true) * 1000),
'X-Tsign-Open-Ca-Signature' => base64_encode(hash_hmac('sha256', $stringToSign, $this->appSecret, true)),
'Accept' => self::ACCEPT,
'Content-Type' => self::CONTENT_TYPE,
'Content-MD5' => $contentMd5,
];
}
public function canonicalPath(string $pathAndParameters): string
{
$parts = parse_url($pathAndParameters);
$path = (string) ($parts['path'] ?? '');
if ($path === '' || ! str_starts_with($path, '/')) {
throw new InvalidArgumentException('E 签宝 API 路径必须以 / 开头。');
}
if (! isset($parts['query']) || $parts['query'] === '') {
return $path;
}
$parameters = [];
foreach (explode('&', $parts['query']) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, null);
if (! array_key_exists($key, $parameters)) {
$parameters[$key] = $value;
}
}
ksort($parameters, SORT_STRING);
$query = collect($parameters)->map(function (?string $value, string $key): string {
return $value === null || $value === '' ? $key : $key . '=' . $value;
})->implode('&');
return $path . '?' . $query;
}
}

View File

@ -0,0 +1,317 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteChangeEvent;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class CourierQuoteChangeService
{
public function __construct(private readonly QuoteManagementSnapshot $snapshot)
{
}
public function create(array $data, int $userId): CourierQuoteChange
{
$source = $this->findSourceQuote($data['source_quote_id']);
return DB::transaction(function () use ($data, $userId, $source): CourierQuoteChange {
$retrospective = $this->isRetrospectiveDate($data['effective_date']);
$change = CourierQuoteChange::create([
'company_id' => company_id(),
'number' => $this->nextChangeNumber(),
'source_quote_id' => $source['id'],
'source_sheet_name' => $source['sheet_name'],
'carrier_name' => $source['company_name'],
'quote_type' => $source['quote_type'],
'change_type' => $data['change_type'],
'status' => CourierQuoteChange::STATUS_DRAFT,
'effective_date' => $data['effective_date'],
'is_retrospective' => $retrospective,
'recalculation_status' => $retrospective
? CourierQuoteChange::RECALCULATION_PENDING
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'recalculation_note' => null,
'summary' => $data['summary'],
'created_by' => $userId,
]);
$this->replaceItems($change, $data['items'], $source);
$this->recordEvent($change, 'created', null, CourierQuoteChange::STATUS_DRAFT, '创建报价变更草稿。', $userId, $this->changeDetails($change));
return $change->load('items', 'events');
});
}
public function update(CourierQuoteChange $change, array $data, int $userId): CourierQuoteChange
{
$this->ensureEditableBy($change, $userId);
$source = $this->findSourceQuote($change->source_quote_id);
return DB::transaction(function () use ($change, $data, $userId, $source): CourierQuoteChange {
$from = $change->status;
$retrospective = $this->isRetrospectiveDate($data['effective_date']);
$change->update([
'change_type' => $data['change_type'],
'effective_date' => $data['effective_date'],
'is_retrospective' => $retrospective,
'recalculation_status' => $retrospective
? CourierQuoteChange::RECALCULATION_PENDING
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'recalculation_note' => null,
'summary' => $data['summary'],
'status' => CourierQuoteChange::STATUS_DRAFT,
]);
$this->replaceItems($change, $data['items'], $source);
$this->recordEvent($change, 'saved', $from, CourierQuoteChange::STATUS_DRAFT, '保存报价变更草稿。', $userId, $this->changeDetails($change));
return $change->fresh(['items', 'events']);
});
}
public function submit(CourierQuoteChange $change, int $userId): CourierQuoteChange
{
$this->ensureEditableBy($change, $userId);
if ($change->items()->count() === 0) {
throw ValidationException::withMessages(['items' => '至少需要一条发生变化的报价明细。']);
}
return DB::transaction(function () use ($change, $userId): CourierQuoteChange {
$from = $change->status;
$retrospective = $this->isRetrospectiveDate($change->effective_date);
$change->update([
'status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
'is_retrospective' => $retrospective,
'recalculation_status' => $retrospective
? CourierQuoteChange::RECALCULATION_PENDING
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'recalculation_note' => null,
'submitted_by' => $userId,
'submitted_at' => now(),
]);
$this->recordEvent($change, 'submitted', $from, CourierQuoteChange::STATUS_PENDING_REVIEW, '提交报价变更审核。', $userId, $this->changeDetails($change));
return $change->fresh(['items', 'events']);
});
}
public function returnForChanges(CourierQuoteChange $change, string $note, int $userId): CourierQuoteChange
{
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以退回。']);
}
return DB::transaction(function () use ($change, $note, $userId): CourierQuoteChange {
$change->update(['status' => CourierQuoteChange::STATUS_RETURNED]);
$this->recordEvent($change, 'returned', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, $note, $userId);
return $change->fresh(['items', 'events']);
});
}
public function approve(CourierQuoteChange $change, int $userId): CourierQuoteChange
{
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']);
}
if ($change->created_by === $userId) {
throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']);
}
$source = $this->findSourceQuote($change->source_quote_id);
return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange {
$values = $source['values'];
foreach ($change->items as $item) {
if (! array_key_exists($item->source_row, $values) || ! array_key_exists($item->source_column, $values[$item->source_row])) {
throw ValidationException::withMessages(['items' => "报价单元格 {$item->source_cell} 已不存在,请退回重新核对。"]);
}
$values[$item->source_row][$item->source_column] = $item->new_amount;
}
$importedVersionCount = collect($this->snapshot->load()['quotes'] ?? [])
->whereNull('source_change_id')
->count();
$version = CourierQuoteVersion::create([
'company_id' => company_id(),
'version_code' => 'PENDING-' . $change->id,
'source_quote_id' => $source['id'],
'source_change_id' => $change->id,
'sheet_name' => $source['sheet_name'],
'company_name' => $source['company_name'],
'carrier_name' => $source['company_name'],
'quote_type' => $source['quote_type'],
'effective_date' => $change->effective_date,
'source_range' => $source['source_range'],
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($source['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $change->created_by,
'approved_by' => $userId,
]);
$version->update([
'version_code' => sprintf('QB-%03d', $importedVersionCount + $version->id),
]);
$retrospective = $this->isRetrospectiveDate($change->effective_date);
$recalculationNote = $retrospective ? $this->retrospectiveBlockReason() : null;
$change->update([
'status' => CourierQuoteChange::STATUS_APPROVED,
'is_retrospective' => $retrospective,
'recalculation_status' => $retrospective
? CourierQuoteChange::RECALCULATION_BLOCKED
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'recalculation_note' => $recalculationNote,
'approved_by' => $userId,
'approved_at' => now(),
'new_quote_version_id' => $version->id,
]);
$eventNote = "审核通过并生成新版本 {$version->version_code}。";
if ($retrospective) {
$eventNote .= ' 该报价为历史生效;补差明细尚未生成,原运费保持不变。';
}
$this->recordEvent($change, 'approved', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_APPROVED, $eventNote, $userId, [
...$this->changeDetails($change),
'version_code' => $version->version_code,
]);
return $change->fresh(['items', 'events', 'version']);
});
}
public function findSourceQuote(string $id): array
{
$quote = collect($this->snapshot->load()['quotes'] ?? [])->firstWhere('id', $id);
if (! is_array($quote)) {
throw ValidationException::withMessages(['source_quote_id' => '选择的原报价版本不存在或已不可用。']);
}
return $quote;
}
private function replaceItems(CourierQuoteChange $change, array $items, array $source): void
{
$change->items()->withTrashed()->forceDelete();
foreach (array_values($items) as $position => $item) {
$row = (int) $item['source_row'];
$column = (int) $item['source_column'];
$sourceValue = $source['values'][$row][$column] ?? null;
$formula = $source['formulas'][$row][$column] ?? null;
if (is_string($formula) && trim($formula) !== '') {
throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 是公式单元格,不能直接改价。"]);
}
if (! is_numeric($sourceValue)) {
throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 不是可调整的数值单元格。"]);
}
if (abs((float) $sourceValue - (float) $item['original_amount']) > 0.00005) {
throw ValidationException::withMessages(["items.{$position}.original_amount" => "{$item['source_cell']} 的原价格已经变化,请重新载入后再保存。"]);
}
if ((float) $item['new_amount'] < 0) {
throw ValidationException::withMessages(["items.{$position}.new_amount" => '新价格不能小于 0。']);
}
if (round((float) $item['new_amount'], 4) === round((float) $item['original_amount'], 4)) {
continue;
}
$change->items()->create([
'company_id' => company_id(),
'position' => $position,
'source_cell' => strtoupper($item['source_cell']),
'source_row' => $row,
'source_column' => $column,
'region' => $item['region'] ?? null,
'weight_band' => $item['weight_band'] ?? null,
'charge_item' => $item['charge_item'] ?? null,
'original_amount' => number_format((float) $item['original_amount'], 4, '.', ''),
'new_amount' => number_format((float) $item['new_amount'], 4, '.', ''),
'unit' => $item['unit'] ?? null,
'note' => $item['note'] ?? null,
]);
}
if ($change->items()->count() === 0) {
throw ValidationException::withMessages(['items' => '至少需要一条原价格与新价格不同的变价明细。']);
}
}
private function ensureEditableBy(CourierQuoteChange $change, int $userId): void
{
if (! $change->isEditable()) {
throw ValidationException::withMessages(['status' => '当前状态不允许继续修改。']);
}
if ($change->created_by !== $userId) {
abort(403, '只有草稿发起人可以修改或提交该变更单。');
}
}
private function recordEvent(CourierQuoteChange $change, string $type, ?string $from, ?string $to, string $note, int $userId, array $details = []): void
{
CourierQuoteChangeEvent::create([
'company_id' => company_id(),
'quote_change_id' => $change->id,
'event_type' => $type,
'from_status' => $from,
'to_status' => $to,
'note' => $note,
'details_json' => $details === [] ? null : json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'created_by' => $userId,
]);
}
private function changeDetails(CourierQuoteChange $change): array
{
return [
'change_type' => $change->change_type,
'effective_date' => $change->effective_date->toDateString(),
'is_retrospective' => $change->isRetrospective(),
'recalculation_status' => $change->recalculation_status,
'recalculation_note' => $change->recalculation_note,
'summary' => $change->summary,
'items' => $change->items()->get()->map(static fn ($item): array => [
'source_cell' => $item->source_cell,
'original_amount' => $item->original_amount,
'new_amount' => $item->new_amount,
'note' => $item->note,
])->all(),
];
}
private function nextChangeNumber(): string
{
$prefix = 'BG-' . now()->format('Ymd') . '-';
$last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number');
$sequence = $last ? ((int) substr($last, -3)) + 1 : 1;
return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
}
private function isRetrospectiveDate(string|Carbon $effectiveDate): bool
{
return Carbon::parse($effectiveDate)->startOfDay()->lt(today());
}
private function retrospectiveBlockReason(): string
{
return '未生成补差明细:当前报价未提供可执行的运费计算方式,历史运费也未记录所用报价版本。补差前需建立逐票原报价版本关联和可验证的报价计算规则;原运费及原始 Excel 保持不变。';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteVersion;
use RuntimeException;
final class CourierQuotePdf
{
public function render(CourierQuoteVersion $version): string
{
$version->loadMissing(['change.items']);
$values = $this->decodeMatrix($version->values_json);
$formulas = $this->decodeMatrix($version->formulas_json);
$html = view('business.pdf.courier-quote', [
'version' => $version,
'change' => $version->change,
'values' => $values,
'formulas' => $formulas,
'generatedAt' => now(),
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
])->render();
$pdf = app('dompdf.wrapper');
$pdf->setPaper('a4', 'landscape');
$pdf->loadHTML($html, 'UTF-8');
$bytes = $pdf->output();
if (! is_string($bytes) || ! str_starts_with($bytes, '%PDF-')) {
throw new RuntimeException('报价 PDF 生成失败。');
}
return $bytes;
}
private function decodeMatrix(?string $json): array
{
if (blank($json)) {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
}

View File

@ -0,0 +1,298 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
final class CourierQuoteSignatureService
{
public function __construct(private readonly CourierQuotePdf $pdf)
{
}
public function prepareVersion(CourierQuoteVersion $version, int $userId): CourierQuoteSignature
{
$existing = $version->signature;
if ($existing && $this->sourcePdfIsIntact($existing)) {
return $existing;
}
if ($existing && ! $existing->canSend()) {
throw ValidationException::withMessages([
'signature' => '该报价已经进入签署流程,签署原文不可重新生成。',
]);
}
$bytes = $this->pdf->render($version);
$path = $this->path($version, 'source.pdf');
$sha256 = hash('sha256', $bytes);
if (! Storage::disk('uploads')->put($path, $bytes)) {
throw new RuntimeException('报价 PDF 无法写入私有业务存储。');
}
$signature = CourierQuoteSignature::updateOrCreate(
['company_id' => $version->company_id, 'quote_version_id' => $version->id],
[
'provider' => 'esign',
'environment' => (string) config('services.esign.environment', 'sandbox'),
'status' => CourierQuoteSignature::STATUS_PREPARED,
'organization_name' => $version->carrier_name,
'source_pdf_path' => $path,
'source_pdf_sha256' => $sha256,
'source_pdf_generated_at' => now(),
'last_error' => null,
'created_by' => $existing?->created_by ?? $userId,
'updated_by' => $userId,
],
);
$this->updateVersionStatus($signature, $signature->statusLabel());
return $signature->fresh('version');
}
public function send(CourierQuoteSignature $signature, array $data, int $userId): CourierQuoteSignature
{
$signature = DB::transaction(function () use ($signature, $data, $userId): CourierQuoteSignature {
$locked = CourierQuoteSignature::query()->lockForUpdate()->findOrFail($signature->id);
if (! $locked->canSend()) {
throw ValidationException::withMessages(['signature' => '当前报价版本已经进入签署流程,不能重复推送。']);
}
if (! $this->sourcePdfIsIntact($locked)) {
throw ValidationException::withMessages(['signature' => '待签报价 PDF 缺失或校验不一致,请重新生成后再推送。']);
}
$locked->update([
'status' => CourierQuoteSignature::STATUS_SENDING,
'organization_name' => $data['organization_name'],
'operator_name' => $data['operator_name'],
'operator_account' => $data['operator_account'],
'notice_types' => (string) $data['notice_type'],
'expires_at' => Carbon::parse($data['expires_at']),
'last_error' => null,
'updated_by' => $userId,
]);
return $locked->fresh('version');
});
$this->updateVersionStatus($signature, '推送中');
try {
$client = app(EsignClient::class);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$fileName = 'quote-' . $signature->version->version_code . '.pdf';
$fileId = $client->uploadPdf($bytes, $fileName);
$signFlowId = $client->createSignFlow($fileId, $fileName, [
'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认',
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
], $signature->expires_at->getTimestampMs());
$signature->update([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => $fileId,
'esign_sign_flow_id' => $signFlowId,
'remote_status' => '1',
'sent_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
]);
$this->updateVersionStatus($signature, '待签署');
return $signature->fresh('version');
} catch (Throwable $exception) {
$signature->update([
'status' => CourierQuoteSignature::STATUS_FAILED,
'last_error' => $this->safeError($exception),
'updated_by' => $userId,
]);
$this->updateVersionStatus($signature, '推送失败');
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function synchronize(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature
{
if (blank($signature->esign_sign_flow_id)) {
throw ValidationException::withMessages(['signature' => '该报价版本尚未创建 E 签宝签署流程。']);
}
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
try {
$client = app(EsignClient::class);
$detail = $client->signFlowDetail($signature->esign_sign_flow_id);
$remoteStatus = (string) ($detail['signFlowStatus'] ?? $detail['status'] ?? '');
if ($remoteStatus === '') {
throw new RuntimeException('E 签宝未返回签署流程状态。');
}
$signature = $this->applyRemoteStatus($signature, $remoteStatus, $userId);
return $remoteStatus === '2' ? $this->archive($signature, $client, $userId) : $signature;
} catch (Throwable $exception) {
$signature->update([
'last_error' => $this->safeError($exception),
'last_synced_at' => now(),
'updated_by' => $userId,
]);
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function synchronizeById(int $signatureId): void
{
$signature = CourierQuoteSignature::allCompanies()->find($signatureId);
if (! $signature) {
return;
}
$this->synchronize($signature, $signature->updated_by);
}
public function acceptCallback(string $signFlowId, string $remoteStatus, string $payloadHash): ?CourierQuoteSignature
{
return DB::transaction(function () use ($signFlowId, $remoteStatus, $payloadHash): ?CourierQuoteSignature {
$signature = CourierQuoteSignature::allCompanies()
->where('esign_sign_flow_id', $signFlowId)
->lockForUpdate()
->first();
if (! $signature) {
return null;
}
if ($signature->callback_payload_hash === $payloadHash) {
return null;
}
$signature->callback_payload_hash = $payloadHash;
return $this->applyRemoteStatus($signature, $remoteStatus, $signature->updated_by);
});
}
public function sourcePdfIsIntact(CourierQuoteSignature $signature): bool
{
if (blank($signature->source_pdf_path) || blank($signature->source_pdf_sha256)) {
return false;
}
$disk = Storage::disk('uploads');
return $disk->exists($signature->source_pdf_path)
&& hash_equals($signature->source_pdf_sha256, hash('sha256', $disk->get($signature->source_pdf_path)));
}
private function applyRemoteStatus(CourierQuoteSignature $signature, string $remoteStatus, int $userId): CourierQuoteSignature
{
[$status, $label] = match ($remoteStatus) {
'1' => [CourierQuoteSignature::STATUS_SIGNING, '待签署'],
'2' => [CourierQuoteSignature::STATUS_COMPLETED, '签署完成,待归档'],
'3' => [CourierQuoteSignature::STATUS_REVOKED, '已撤销'],
'5' => [CourierQuoteSignature::STATUS_EXPIRED, '已过期'],
'7' => [CourierQuoteSignature::STATUS_REJECTED, '已拒签'],
default => [$signature->status, $signature->statusLabel()],
};
$signature->forceFill([
'status' => $status,
'remote_status' => $remoteStatus,
'completed_at' => $remoteStatus === '2' ? ($signature->completed_at ?? now()) : $signature->completed_at,
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
])->save();
$this->updateVersionStatus($signature, $label);
$signature->refresh();
return $signature;
}
private function archive(CourierQuoteSignature $signature, EsignClient $client, int $userId): CourierQuoteSignature
{
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
$urls = $client->downloadUrls($signature->esign_sign_flow_id);
$signedUrl = (string) data_get($urls, 'files.0.downloadUrl', '');
$certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? '');
if ($signedUrl === '' || $certificateUrl === '') {
throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。');
}
$signedBytes = $client->download($signedUrl);
$certificateBytes = $client->download($certificateUrl);
if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) {
throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。');
}
$version = CourierQuoteVersion::allCompanies()->findOrFail($signature->quote_version_id);
$signedPath = $this->path($version, 'signed.pdf');
$certificatePath = $this->path($version, 'completion-certificate.pdf');
$disk = Storage::disk('uploads');
if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) {
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
}
$signature->update([
'status' => CourierQuoteSignature::STATUS_ARCHIVED,
'signed_pdf_path' => $signedPath,
'signed_pdf_sha256' => hash('sha256', $signedBytes),
'certificate_path' => $certificatePath,
'certificate_sha256' => hash('sha256', $certificateBytes),
'archived_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
]);
$this->updateVersionStatus($signature, '已签署并归档');
return $signature->fresh('version');
}
private function path(CourierQuoteVersion $version, string $fileName): string
{
$versionCode = preg_replace('/[^A-Za-z0-9_-]/', '-', $version->version_code) ?: (string) $version->id;
return "courier-quotes/{$version->company_id}/{$versionCode}/{$fileName}";
}
private function updateVersionStatus(CourierQuoteSignature $signature, string $label): void
{
CourierQuoteVersion::allCompanies()
->whereKey($signature->quote_version_id)
->update(['signature_status' => $label]);
}
private function safeError(Throwable $exception): string
{
$message = preg_replace('/[[:cntrl:]]+/u', ' ', $exception->getMessage()) ?: 'E 签宝处理失败。';
return mb_substr($message, 0, 1000);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Support;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Facades\Schema;
use JsonException;
final class QuoteManagementSnapshot
@ -40,6 +42,12 @@ final class QuoteManagementSnapshot
$quotes[] = $this->normalizeSheet($sheet, $position);
}
if (Schema::hasTable('business_courier_quote_versions')) {
foreach (CourierQuoteVersion::query()->orderBy('id')->get() as $version) {
$quotes[] = $this->normalizeVersion($version);
}
}
return [
'available' => true,
'state' => 'ready',
@ -88,6 +96,45 @@ final class QuoteManagementSnapshot
return $companyName !== '' ? $companyName : $name;
}
private function normalizeVersion(CourierQuoteVersion $version): array
{
$values = $this->decodeMatrix($version->values_json);
$formulas = $this->decodeMatrix($version->formulas_json);
$columnCount = max(array_map('count', $values ?: [[]]));
return [
'id' => $version->version_code,
'sheet_index' => null,
'sheet_name' => $version->sheet_name,
'company_name' => $version->company_name,
'carrier' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),
'source_range' => $version->source_range ?? '',
'row_count' => count($values),
'column_count' => $columnCount,
'formula_count' => $this->formulaCount($formulas),
'status' => '已审核待签署',
'signature_status' => $version->signature_status,
'values' => $values,
'formulas' => $formulas,
'source_change_id' => $version->source_change_id,
];
}
private function decodeMatrix(?string $json): array
{
if ($json === null || trim($json) === '') {
return [];
}
try {
return $this->normalizeMatrix(json_decode($json, true, 512, JSON_THROW_ON_ERROR));
} catch (JsonException) {
return [];
}
}
private function normalizeMatrix(mixed $matrix): array
{
if (! is_array($matrix)) {

View File

@ -16,7 +16,9 @@ services:
retries: 12
start_period: 30s
# 独立的财务系统配置数据库。只开放这个容器,正式业务库 caiwu_db 继续保持内网隔离。
# 团队共享 MariaDB 实例:逻辑上分为系统配置库和业务镜像库。
# 两个库独立备份、独立表结构,但复用同一套 TLS 入口和团队密码,
# 避免新人再填写第二套连接资料。正式业务库 caiwu_db 继续保持内网隔离。
config_db:
image: mariadb:11.4
pull_policy: missing
@ -25,10 +27,10 @@ services:
env_file:
- ./runtime/config-db.env
command:
- "--innodb-buffer-pool-size=64M"
- "--innodb-log-buffer-size=8M"
- "--max-connections=30"
- "--table-open-cache=200"
- "--innodb-buffer-pool-size=512M"
- "--innodb-log-buffer-size=32M"
- "--max-connections=60"
- "--table-open-cache=600"
- "--performance-schema=OFF"
ports:
- "0.0.0.0:13306:3306"
@ -38,8 +40,8 @@ services:
networks:
- default
- config_database
mem_limit: 256m
cpus: "0.50"
mem_limit: 1536m
cpus: "2.00"
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s

View File

@ -107,6 +107,42 @@ return [
'options' => $mysqlSslOptions(),
],
// 上游业务数据的项目内镜像。它与用户、权限和系统设置所在的
// caiwu_system_config 分库保存,但默认复用同一台 MariaDB 和账号。
'business_mirror' => [
'driver' => 'mysql',
'read' => [
'host' => [
env('BUSINESS_MIRROR_DB_HOST_READ', env('BUSINESS_MIRROR_DB_HOST', env('DB_HOST', '127.0.0.1'))),
],
],
'write' => [
'host' => [
env('BUSINESS_MIRROR_DB_HOST_WRITE', env('BUSINESS_MIRROR_DB_HOST', env('DB_HOST', '127.0.0.1'))),
],
],
'sticky' => true,
'port' => env('BUSINESS_MIRROR_DB_PORT', env('DB_PORT', '3306')),
'database' => env('BUSINESS_MIRROR_DB_DATABASE', 'caiwu_business_mirror'),
'username' => env('BUSINESS_MIRROR_DB_USERNAME', env('DB_USERNAME', 'forge')),
'password' => env('BUSINESS_MIRROR_DB_PASSWORD', env('DB_PASSWORD', '')),
'unix_socket' => env('BUSINESS_MIRROR_DB_SOCKET', env('DB_SOCKET', '')),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => 'InnoDB ROW_FORMAT=DYNAMIC',
'modes' => [
'STRICT_TRANS_TABLES',
'NO_ZERO_IN_DATE',
'NO_ZERO_DATE',
'ERROR_FOR_DIVISION_BY_ZERO',
'NO_ENGINE_SUBSTITUTION',
],
'options' => $mysqlSslOptions(),
],
'mariadb' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),

View File

@ -34,4 +34,14 @@ return [
'api_key' => env('SENDGRID_API_KEY'),
],
'esign' => [
'environment' => env('ESIGN_ENVIRONMENT', 'sandbox'),
'base_url' => env('ESIGN_BASE_URL', 'https://smlopenapi.esign.cn'),
'app_id' => env('ESIGN_APP_ID'),
'app_secret' => env('ESIGN_APP_SECRET'),
'notify_url' => env('ESIGN_NOTIFY_URL'),
'callback_tolerance_seconds' => (int) env('ESIGN_CALLBACK_TOLERANCE_SECONDS', 900),
'timeout' => (int) env('ESIGN_TIMEOUT', 20),
],
];

View File

@ -0,0 +1,85 @@
<?php
return [
'connection' => env('BUSINESS_MIRROR_DB_CONNECTION', 'business_mirror'),
// 只有生产刷新节点写镜像;开发环境和网页请求只读共享结果。
'refresh_enabled' => env(
'BUSINESS_MIRROR_REFRESH_ENABLED',
env('APP_ENV', 'production') === 'production'
),
'product_prices' => [
'dataset_key' => 'supplier-payables.product-prices',
'source_key' => 'operations_replica',
'source_object' => 'rpt.Bulk_Price_Limit',
'batch_size' => (int) env('PRODUCT_PRICE_MIRROR_BATCH_SIZE', 500),
'max_batches_per_run' => (int) env('PRODUCT_PRICE_MIRROR_MAX_BATCHES', 4),
// 上游完成记录通常不再变化;仍回看 48 小时,覆盖迟到或修正数据。
'overlap_hours' => (int) env('PRODUCT_PRICE_MIRROR_OVERLAP_HOURS', 48),
],
/*
|--------------------------------------------------------------------------
| 上游原表镜像目录
|--------------------------------------------------------------------------
|
| 页面不能再按“页码 + 筛选条件”临时生成一份快照。这里登记项目代码实际
| 使用的上游表/视图,由后台按表同步到 caiwu_business_mirror。首次全量
| 必须显式执行;日常任务只续传已完成首次全量的数据集。
|
| watermark_column 为空时按稳定业务键追加,并依靠周期全量校准修正历史
| 更新和删除;存在可靠更新时间时使用重叠窗口增量同步。
*/
'table_sync' => [
'batch_size' => (int) env('BUSINESS_MIRROR_TABLE_BATCH_SIZE', 500),
'max_batches_per_run' => (int) env('BUSINESS_MIRROR_TABLE_MAX_BATCHES', 2),
'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1),
'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48),
'full_reconcile_hours' => (int) env('BUSINESS_MIRROR_FULL_RECONCILE_HOURS', 168),
],
'datasets' => [
// 经营分析副本库:组织资料、订单、退款和聚水潭基础数据。
'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'],
'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.jst_Orders' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_Orders', 'target' => 'ops_jst_orders', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.jst_OrderItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_OrderItem', 'target' => 'ops_jst_order_item', 'keys' => ['Id']],
'operations_replica.rpt.jst_sku' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku', 'target' => 'ops_jst_sku', 'keys' => ['autoid']],
'operations_replica.rpt.jst_warehouseInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_warehouseInfo', 'target' => 'ops_jst_warehouse_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.jst_skumap' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_skumap', 'target' => 'ops_jst_sku_map', 'keys' => ['shop_id', 'shop_i_id', 'shop_sku_id'], 'watermark' => 'modifiedTime'],
'operations_replica.rpt.ads_itemid_sales_cost_ratio_v2_di' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'ads_itemid_sales_cost_ratio_v2_di', 'target' => 'ops_item_sales_cost_ratio', 'keys' => ['stat_date', 'item_id'], 'watermark' => 'item_id_update'],
'operations_replica.rpt.jst_returnAndRefundInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_returnAndRefundInfo', 'target' => 'ops_jst_return_refund_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.jst_returnAndRefundItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_returnAndRefundItem', 'target' => 'ops_jst_return_refund_item', 'keys' => ['Id']],
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
// JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。
'jiansun.dbo.tmall_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'tmall_spread_bills', 'target' => 'js_tmall_spread_bills', 'keys' => ['id']],
'jiansun.dbo.pdd_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pdd_spread_bills', 'target' => 'js_pdd_spread_bills', 'keys' => ['id'], 'watermark' => 'createTime'],
'jiansun.dbo.alibaba_bill' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'alibaba_bill', 'target' => 'js_alibaba_bill', 'keys' => ['id']],
'jiansun.dbo.jd_spreadBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_spreadBills', 'target' => 'js_jd_spread_bills', 'keys' => ['id'], 'watermark' => 'updateTime'],
'jiansun.dbo.jd_costBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_costBills', 'target' => 'js_jd_cost_bills', 'keys' => ['id'], 'watermark' => 'creatTime'],
'jiansun.dbo.finance_purchaseReturn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_purchaseReturn', 'target' => 'js_finance_purchase_return', 'keys' => ['id']],
'jiansun.dbo.finance_invoiceDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_invoiceDeduction', 'target' => 'js_finance_invoice_deduction', 'keys' => ['id'], 'watermark' => 'createTime'],
'jiansun.dbo.fys_supplierPaymentSummary' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierPaymentSummary', 'target' => 'js_supplier_payment_summary', 'keys' => ['id'], 'watermark' => 'modifiedTime'],
'jiansun.dbo.finance_advanceLoans' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_advanceLoans', 'target' => 'js_finance_advance_loans', 'keys' => ['id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_unOrderDetails' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_unOrderDetails', 'target' => 'js_pm_unorder_details', 'keys' => ['Id']],
'jiansun.dbo.finance_sampleDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_sampleDeduction', 'target' => 'js_finance_sample_deduction', 'keys' => ['id'], 'watermark' => 'finishTime'],
'jiansun.dbo.fys_express_courier_settlement' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_express_courier_settlement', 'target' => 'js_express_courier_settlement', 'keys' => ['Id'], 'watermark' => 'modify_time'],
'jiansun.dbo.finance_boardDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_boardDeduction', 'target' => 'js_finance_board_deduction', 'keys' => ['id']],
'jiansun.dbo.pm_plainPlateData' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_plainPlateData', 'target' => 'js_pm_plain_plate_data', 'keys' => ['Id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_boardPurchasePrice' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardPurchasePrice', 'target' => 'js_pm_board_purchase_price', 'keys' => ['id'], 'watermark' => 'updateTime'],
'jiansun.dbo.pm_boardPriceChangeImport' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardPriceChangeImport', 'target' => 'js_pm_board_price_change', 'keys' => ['id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_boardApplyDetail' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardApplyDetail', 'target' => 'js_pm_board_apply_detail', 'keys' => ['Id']],
'jiansun.dbo.pm_edgeBandData' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_edgeBandData', 'target' => 'js_pm_edge_band_data', 'keys' => ['id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_edgeBandApplyDetail' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_edgeBandApplyDetail', 'target' => 'js_pm_edge_band_apply_detail', 'keys' => ['Id']],
'jiansun.dbo.pm_edgeBandProfitInfo' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_edgeBandProfitInfo', 'target' => 'js_pm_edge_band_profit_info', 'keys' => ['id']],
'jiansun.dbo.finance_edgeBandDeductio' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_edgeBandDeductio', 'target' => 'js_finance_edge_band_deduction', 'keys' => ['id']],
'jiansun.dbo.pm_averageUnitPrice' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_averageUnitPrice', 'target' => 'js_pm_average_unit_price', 'keys' => ['id']],
'jiansun.dbo.pm_boardInventoryDetail_snapshot' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardInventoryDetail_snapshot', 'target' => 'js_pm_board_inventory_snapshot', 'keys' => ['id']],
'jiansun.dbo.fys_supplierGoods_sttlement_purchase' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_purchase', 'target' => 'js_supplier_goods_purchase', 'keys' => ['Id'], 'watermark' => 'createTime'],
'jiansun.dbo.fys_supplierGoods_sttlement_sale' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierGoods_sttlement_sale', 'target' => 'js_supplier_goods_sale', 'keys' => ['Id'], 'watermark' => 'createTime'],
'jiansun.dbo.pm_purchaseIn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_purchaseIn', 'target' => 'js_pm_purchase_in', 'keys' => ['id'], 'watermark' => 'updateTime'],
],
];

View File

@ -0,0 +1,113 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('business_courier_quote_changes', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('number', 32);
$table->string('source_quote_id', 32);
$table->string('source_sheet_name');
$table->string('carrier_name');
$table->string('quote_type');
$table->string('change_type', 32);
$table->string('status', 32)->default('draft');
$table->date('effective_date');
$table->boolean('is_retrospective')->default(false);
$table->string('recalculation_status', 32)->default('not_required');
$table->text('recalculation_note')->nullable();
$table->text('summary');
$table->unsignedInteger('created_by');
$table->unsignedInteger('submitted_by')->nullable();
$table->timestamp('submitted_at')->nullable();
$table->unsignedInteger('approved_by')->nullable();
$table->timestamp('approved_at')->nullable();
$table->unsignedInteger('new_quote_version_id')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'number'], 'courier_quote_changes_company_number_unique');
$table->index(['company_id', 'status'], 'courier_quote_changes_company_status_index');
$table->index(['company_id', 'source_quote_id'], 'courier_quote_changes_company_source_index');
$table->index(['company_id', 'recalculation_status'], 'courier_quote_changes_company_recalc_index');
});
Schema::create('business_courier_quote_change_items', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('quote_change_id');
$table->unsignedInteger('position')->default(0);
$table->string('source_cell', 24);
$table->unsignedInteger('source_row');
$table->unsignedInteger('source_column');
$table->string('region')->nullable();
$table->string('weight_band')->nullable();
$table->string('charge_item')->nullable();
$table->decimal('original_amount', 15, 4);
$table->decimal('new_amount', 15, 4);
$table->string('unit', 32)->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'quote_change_id'], 'courier_quote_items_company_change_index');
$table->unique(['quote_change_id', 'source_cell'], 'courier_quote_items_change_cell_unique');
});
Schema::create('business_courier_quote_change_events', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('quote_change_id');
$table->string('event_type', 32);
$table->string('from_status', 32)->nullable();
$table->string('to_status', 32)->nullable();
$table->text('note')->nullable();
$table->longText('details_json')->nullable();
$table->unsignedInteger('created_by');
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'quote_change_id'], 'courier_quote_events_company_change_index');
});
Schema::create('business_courier_quote_versions', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('version_code', 32);
$table->string('source_quote_id', 32);
$table->unsignedInteger('source_change_id');
$table->string('sheet_name');
$table->string('company_name');
$table->string('carrier_name');
$table->string('quote_type');
$table->date('effective_date');
$table->string('source_range')->nullable();
$table->longText('values_json');
$table->longText('formulas_json')->nullable();
$table->string('status', 32)->default('approved');
$table->string('signature_status', 32)->default('未签署');
$table->unsignedInteger('created_by');
$table->unsignedInteger('approved_by');
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'version_code'], 'courier_quote_versions_company_code_unique');
$table->unique(['company_id', 'source_change_id'], 'courier_quote_versions_company_change_unique');
$table->index(['company_id', 'carrier_name', 'quote_type'], 'courier_quote_versions_lookup_index');
});
}
public function down(): void
{
Schema::dropIfExists('business_courier_quote_versions');
Schema::dropIfExists('business_courier_quote_change_events');
Schema::dropIfExists('business_courier_quote_change_items');
Schema::dropIfExists('business_courier_quote_changes');
}
};

View File

@ -0,0 +1,86 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
// 本机临时引导库只负责让新人进入设置页;正式镜像表由共享环境迁移。
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('business_mirror_sync_states')) {
$schema->create('business_mirror_sync_states', function (Blueprint $table): void {
$table->string('dataset_key', 120)->primary();
$table->string('source_key', 64);
$table->string('source_object', 191);
$table->string('status', 24)->default('pending');
$table->string('run_mode', 24)->nullable();
$table->dateTime('window_started_at')->nullable();
$table->dateTime('window_ends_at')->nullable();
// SQL Server datetime2(7) 比 PHP/MySQL 常用的 6 位小数更精细,
// 游标必须按原始 ISO 字符串保存,不能截断后造成重复或漏行。
$table->string('cursor_completed_at', 33)->nullable();
$table->string('cursor_business_id', 191)->nullable();
$table->string('cursor_product_code', 191)->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->string('last_error_code', 80)->nullable();
$table->timestamps();
});
}
if (! $schema->hasTable('product_price_mirror_rows')) {
$schema->create('product_price_mirror_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->char('upstream_key', 64)->unique();
$table->string('business_id', 191);
$table->string('barcode', 191)->index();
$table->string('spu', 191)->nullable()->index();
$table->text('product_name')->nullable();
$table->string('category', 255)->nullable()->index();
$table->string('production_status', 120)->nullable();
$table->decimal('minimum_price', 20, 4)->nullable();
$table->decimal('price_limit', 20, 4)->nullable();
$table->decimal('purchase_price', 20, 4)->nullable();
$table->decimal('shipping_fee', 20, 4)->nullable();
$table->decimal('jingxi_limit_price', 20, 4)->nullable();
$table->string('approval_status', 120)->nullable();
$table->string('approval_result', 120)->nullable();
$table->text('workflow_title')->nullable();
$table->dateTime('completed_at', 6)->index();
$table->char('row_hash', 64);
$table->dateTime('synced_at');
$table->timestamps();
$table->index(['completed_at', 'business_id', 'barcode'], 'product_price_mirror_order');
});
}
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
$schema->dropIfExists('product_price_mirror_rows');
$schema->dropIfExists('business_mirror_sync_states');
}
};

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('business_courier_quote_signatures', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('quote_version_id');
$table->string('provider', 32)->default('esign');
$table->string('environment', 32)->default('sandbox');
$table->string('status', 32)->default('prepared');
$table->string('organization_name');
$table->string('operator_name')->nullable();
$table->string('operator_account')->nullable();
$table->string('notice_types', 16)->nullable();
$table->timestamp('expires_at')->nullable();
$table->string('source_pdf_path', 1024);
$table->string('source_pdf_sha256', 64);
$table->timestamp('source_pdf_generated_at');
$table->string('esign_file_id', 128)->nullable();
$table->string('esign_sign_flow_id', 128)->nullable();
$table->string('remote_status', 32)->nullable();
$table->string('signed_pdf_path', 1024)->nullable();
$table->string('signed_pdf_sha256', 64)->nullable();
$table->string('certificate_path', 1024)->nullable();
$table->string('certificate_sha256', 64)->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('archived_at')->nullable();
$table->timestamp('last_synced_at')->nullable();
$table->text('last_error')->nullable();
$table->string('callback_payload_hash', 64)->nullable();
$table->unsignedInteger('created_by');
$table->unsignedInteger('updated_by');
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'quote_version_id'], 'courier_quote_signatures_company_version_unique');
$table->unique('esign_sign_flow_id', 'courier_quote_signatures_flow_unique');
$table->index(['company_id', 'status'], 'courier_quote_signatures_company_status_index');
});
}
public function down(): void
{
Schema::dropIfExists('business_courier_quote_signatures');
}
};

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('business_mirror_table_states')) {
return;
}
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key', 191)->primary();
$table->string('source_key', 64);
$table->string('source_object', 191);
$table->string('target_table', 64);
$table->string('status', 24)->default('pending');
$table->string('run_mode', 24)->nullable();
$table->uuid('generation')->nullable();
$table->json('cursor')->nullable();
$table->json('high_watermark')->nullable();
$table->dateTime('window_started_at', 6)->nullable();
$table->dateTime('window_ends_at', 6)->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->unsignedBigInteger('mirror_row_count')->default(0);
$table->string('last_error_code', 120)->nullable();
$table->timestamps();
$table->index(['source_key', 'status'], 'mirror_table_states_source_status');
$table->index('last_succeeded_at', 'mirror_table_states_last_success');
});
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
Schema::connection($this->connection())->dropIfExists('business_mirror_table_states');
}
};

View File

@ -65,6 +65,28 @@ fi
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
if [ "$RUN_MIGRATIONS" = "1" ]; then
# 先创建独立业务镜像库并授权应用账号,再启动会执行迁移的新应用。
# 该脚本幂等;已有数据库只会补齐授权,不会覆盖业务数据。
docker compose -f compose.jiangling.yml up -d config_db
config_db_attempt=1
while [ "$config_db_attempt" -le 24 ]; do
if [ "$(docker inspect caiwu_config_db --format '{{.State.Health.Status}}' 2>/dev/null || true)" = "healthy" ]; then
break
fi
sleep 5
config_db_attempt=$((config_db_attempt + 1))
done
if [ "$(docker inspect caiwu_config_db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]; then
docker compose -f compose.jiangling.yml logs --tail 80 config_db >&2 || true
echo "Shared MariaDB did not become healthy before mirror database initialization" >&2
exit 1
fi
docker exec caiwu_config_db sh /docker-entrypoint-initdb.d/10-config-db-security.sh
fi
docker compose -f compose.jiangling.yml up -d --remove-orphans
healthy=0
@ -103,10 +125,10 @@ fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 只运行已登记来源的共享快照刷新命令,不启用整个 Laravel 调度器,
# 避免意外启动账单提醒等既有任务。共享锁保证多环境不会重复访问上游。
# 只运行已登记的上游后台读取,不启用整个 Laravel 调度器,避免意外启动
# 账单提醒等既有任务。同一把锁保证快照与业务镜像不会并发施压上游。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -69,6 +69,28 @@ fi
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
if [ "$RUN_MIGRATIONS" = "1" ]; then
# 先创建独立业务镜像库并授权应用账号,再启动会执行迁移的新应用。
# 该脚本幂等;已有数据库只会补齐授权,不会覆盖业务数据。
docker compose -f compose.jiangling.yml up -d config_db
config_db_attempt=1
while [ "$config_db_attempt" -le 24 ]; do
if [ "$(docker inspect caiwu_config_db --format '{{.State.Health.Status}}' 2>/dev/null || true)" = "healthy" ]; then
break
fi
sleep 5
config_db_attempt=$((config_db_attempt + 1))
done
if [ "$(docker inspect caiwu_config_db --format '{{.State.Health.Status}}' 2>/dev/null || true)" != "healthy" ]; then
docker compose -f compose.jiangling.yml logs --tail 80 config_db >&2 || true
echo "Shared MariaDB did not become healthy before mirror database initialization" >&2
exit 1
fi
docker exec caiwu_config_db sh /docker-entrypoint-initdb.d/10-config-db-security.sh
fi
docker compose -f compose.jiangling.yml up -d --remove-orphans
healthy=0
@ -107,10 +129,10 @@ fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 只运行已登记来源的共享快照刷新命令,不启用整个 Laravel 调度器,
# 避免意外启动账单提醒等既有任务。共享锁保证多环境不会重复访问上游。
# 只运行已登记的上游后台读取,不启用整个 Laravel 调度器,避免意外启动
# 账单提醒等既有任务。同一把锁保证快照与业务镜像不会并发施压上游。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -44,6 +44,7 @@ if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then
{
printf '%s\n' \
'MARIADB_DATABASE=caiwu_system_config' \
'BUSINESS_MIRROR_DATABASE=caiwu_business_mirror' \
'MARIADB_USER=caiwu_config_writer' \
"MARIADB_PASSWORD=$CONFIG_DB_PASSWORD" \
"MARIADB_ROOT_PASSWORD=$CONFIG_DB_ROOT_PASSWORD" \

View File

@ -8,6 +8,19 @@ case "${MARIADB_USER:-}" in
;;
esac
BUSINESS_MIRROR_DATABASE="${BUSINESS_MIRROR_DATABASE:-caiwu_business_mirror}"
case "$BUSINESS_MIRROR_DATABASE" in
''|*[!A-Za-z0-9_]* )
echo 'BUSINESS_MIRROR_DATABASE contains unsupported characters' >&2
exit 1
;;
esac
mariadb --protocol=socket -uroot -p"$MARIADB_ROOT_PASSWORD" <<SQL
CREATE DATABASE IF NOT EXISTS \`$BUSINESS_MIRROR_DATABASE\`
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL PRIVILEGES ON \`$BUSINESS_MIRROR_DATABASE\`.* TO \`$MARIADB_USER\`@'%';
ALTER USER \`$MARIADB_USER\`@'%' REQUIRE SSL;
FLUSH PRIVILEGES;
SQL

View File

@ -150,18 +150,18 @@ Settlement 0..1--1 Transaction
### 6.3 多上游数据边界
- 页面请求不得实时连接多个上游数据库做 JOIN;组合数据先按来源读取并物化到本地,再由本地表或快照生成报表。
- 页面请求不得实时连接多个上游数据库做 JOIN;项目正式使用的上游表先按来源同步到 `caiwu_business_mirror`,再由共享镜像表生成页面和报表。
- 新的物化数据必须能追溯 `source_key`、上游业务键和 `synced_at`;若现有表没有对应列,应单独提交迁移并获得数据库变更授权,不能把来源信息只藏在日志里。
- 同一上游业务键的重复同步必须幂等;不同来源出现相同业务键时不得互相覆盖。
- 上游金额进入本地后仍按本系统的 `decimal`、公司权限、期间锁定和审计规则校验,上游返回值不能直接成为不可追溯的最终财务结果。
普通开发机不以直连上游作为开发前提。需要尚未存在于共享快照的新表或字段时:
普通开发机不以直连上游作为日常页面运行前提。需要尚未存在于共享业务镜像的新表或字段时:
1. 任务说明先列出来源、业务目的、表/视图、字段、过滤与排序、最大返回量、权限范围和缓存时间;
2. Repository 只能通过 `UpstreamReadOnlyQuery` 执行固定参数化只读查询,页面通过 `UpstreamQueryCache` 使用稳定 `scope`;
3. 同一提交必须登记对应 `UpstreamSnapshotRefresher` 映射,并覆盖重复调用只查询一次、权限隔离、空数据和上游不可用测试;
1. 任务说明先列出来源、业务目的、表/视图、字段、过滤与排序、上游业务键、增量游标、最大批次和权限范围;
2. 上游 Repository 只能通过 `UpstreamReadOnlyQuery` 执行固定参数化只读查询;正式页面通过镜像 Repository 读取 `caiwu_business_mirror`;
3. 同一提交必须完成镜像表、断点同步器、同步状态和页面切换,并覆盖幂等、分页、筛选、首次未完成、空数据和上游不可用测试;
4. 功能分支使用脱敏 fixture 开发;只有合并并部署到 `main` 的查询才允许由生产刷新节点执行;
5. 部署代码后只开放经过验证的精确 `allowed_scopes`,首次请求登记查询,后台生成共享快照,第二个应用实例验证复用;
5. 部署代码后经授权执行首次全量同步,对比上游限定计数、镜像总数、第二页或末页和同步时间,第二个应用实例验证复用;
6. 禁止把任意 SQL 存进系统数据库、开放全局 `*`、为了开发便利分发凭据,或让每位开发者各自周期性扫描上游。
## 7. 核心业务规则
@ -287,7 +287,7 @@ created_from = ecommerce-finance
- upsert、结算状态迁移、创建资金交易及其反向关联必须置于数据库事务中。
- 所有写入使用外部唯一键和数据库唯一约束保证幂等;不可只依赖应用层查重。
- 报表从 `ec_profit_snapshots` 查询,不在请求中扫描全部订单/结算明细。
- 跨来源报表只能读取已经物化到本地的标准表或快照,不在请求中等待任一上游数据库。
- 跨来源报表只能读取已经物化到共享业务库的标准表;尚未迁移的旧查询快照仅作过渡,不在请求中等待任一上游数据库。
- 大文件导入使用流式读取,原文件通过既有媒介/存储机制保存,并设置访问权限。
## 12. 权限、审计与安全

View File

@ -0,0 +1,60 @@
# 上游业务镜像清单
## 结论
项目不是只使用 `rpt.Bulk_Price_Limit` 一张表。当前主线代码直接读取两套上游中的约 40 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、板材、封边条和库存。
`caiwu_business_mirror` 的目标是把项目实际使用的上游原表数据保存为共享业务镜像。网页只读镜像,后台刷新节点在低压、可断点的条件下完成首次全量和后续增量;不得再按页码或筛选条件临时生成不完整的数据副本。
## 当前迁移状态
| 状态 | 说明 |
| --- | --- |
| 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 |
| 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 |
| 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 |
## 经营分析副本库(operations_replica)
| 业务范围 | 上游对象 | 镜像目标 |
| --- | --- | --- |
| 公司资料 | `rpt.fys_companyInfo` | `ops_fys_company_info` |
| 店铺资料 | `rpt.fys_shopInfo` | `ops_fys_shop_info` |
| 商标资料 | `rpt.fys_TrademarkInfo` | `ops_fys_trademark_info` |
| 订单 | `rpt.jst_Orders`、`rpt.jst_OrderItem` | `ops_jst_orders`、`ops_jst_order_item` |
| 商品与仓库 | `rpt.jst_sku`、`rpt.jst_warehouseInfo`、`rpt.jst_skumap` | `ops_jst_*` |
| 推广费用 | `rpt.ads_itemid_sales_cost_ratio_v2_di` | `ops_item_sales_cost_ratio` |
| 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem` | `ops_jst_return_refund_*` |
| 聚水潭店铺 | `rpt.jst_shopInfo` | `ops_jst_shop_info` |
商品价格 `rpt.Bulk_Price_Limit` 继续使用已经上线的类型化同步器,不在通用原表任务中重复同步。
## JianSun 旧业务库(jiansun)
| 业务范围 | 上游对象 |
| --- | --- |
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |
| 库存与货款对账 | `dbo.pm_boardInventoryDetail_snapshot`、`dbo.fys_supplierGoods_sttlement_purchase`、`dbo.fys_supplierGoods_sttlement_sale`、`dbo.pm_purchaseIn` |
其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。
## 同步规则
1. 首次全量只能通过 `business-mirror:sync-tables <dataset> --full` 显式启动;失败或中断后使用同一命令继续。
2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。
3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。
4. 每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。
5. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。
## 后续切换顺序
1. 公司、店铺、商标等小型维表。
2. 营销账户五张单表账单。
3. 供应商应付与货款对账。
4. 订单、退款和推广费用。
5. 板材、封边条和库存的跨表查询。
每个阶段都必须先完成首次全量和数量核对,再将对应 Repository 切到镜像;任一阶段失败时保留上一版镜像和旧只读链路用于回滚。

View File

@ -167,26 +167,30 @@ git pull --ff-only
本系统按 3 个以上上游数据库设计。生产系统数据库的启动连接只存在于服务器私有 `runtime/.env`;本地新人只填写系统数据库密码,即可自动解锁两套上游的固定主机、端口、库名、账号和密码,不需要复制生产 `APP_KEY`、填写上游环境变量或等待负责人开放范围。
本地开发环境(`APP_ENV=local`)直接执行代码中的固定只读查询;生产环境(`APP_ENV=production`)继续使用共享快照。开发者能直连上游时,页面和 Repository 可以立即获得真实数据;网络不通时才需要处理该开发机的网络白名单。无论哪种环境,SQL 仍统一经过 `UpstreamReadOnlyQuery`,只允许参数化的 `SELECT`,防止误改旧财务库。
本地开发探索可以直接执行代码中的固定只读查询,用于确认尚未接入的表和字段。正式业务页面不再把“页码 + 筛选条件”各自保存成一份临时快照,而是优先读取项目共享业务库 `caiwu_business_mirror` 中已经接入的表级副本。生产同步节点小批量访问上游并更新镜像,所有开发机和生产页面从同一份镜像做分页、筛选和汇总。无论哪种环境,上游 SQL 仍统一经过 `UpstreamReadOnlyQuery`,只允许参数化的 `SELECT`,防止误改旧财务库。
`caiwu_system_config` 与 `caiwu_business_mirror` 是同一台团队 MariaDB 中的两个独立逻辑库:前者保存用户、权限、设置、连接资料、数据字典和同步状态;后者只保存项目实际使用的上游业务数据。它们默认复用同一主机、端口、账号、密码和 TLS,因此新人仍只输入一次系统数据库密码,不需要理解或填写第二套连接参数。
固定调用链如下:
```text
本地页面/API -> 单来源适配器 -> 只读查询执行器 -> 对应上游
本地探索/开发验证 -> 单来源适配器 -> 只读查询执行器 -> 对应上游
生产页面/API -> 共享查询快照 -> 后台刷新器 -> 只读查询执行器 -> 对应上游
|
+-> 受控物化到共享标准表/快照 -> 跨来源报表
生产同步节点 -> 稳定游标分批读取 -> caiwu_business_mirror
本地与生产页面/API -> caiwu_business_mirror -> 本地分页、筛选、汇总
尚未迁移的旧页面 -> 共享查询快照(过渡兼容,不再扩展)
```
执行规则:
1. 页面只能通过 `UpstreamQueryCache` 或现有兼容入口读取,不能直接连接上游;
1. 已接入镜像的页面只能通过镜像 Repository 读取,不能直接连接上游;尚未迁移的旧页面暂时通过 `UpstreamQueryCache` 兼容;
2. 新适配器只能通过 `UpstreamReadOnlyQuery` 执行固定、参数化的 `SELECT/WITH`,不能把 PDO 交给控制器,不能执行写入、建表、存储过程或多语句;
3. 生产快照键必须包含来源、数据范围、查询条件以及公司/权限范围,不能按“登录账号个数”复制缓存,也不能让不同权限共用结果;
3. 镜像表必须保留来源标识、稳定上游业务键、同步时间和必要行哈希;列表分页与筛选只在共享业务库执行,不能为每个页码复制一份上游结果;
4. 生产刷新每个来源默认最多 2 个冷查询并发、每分钟 6 个查询组、每轮刷新 2 个活跃查询;所有来源共用默认 4 个总槽位;
5. 生产环境中一个来源断线或熔断只影响自身;本地直连失败时直接返回脱敏错误,不阻塞另一个来源;
6. 跨来源报表禁止在 HTTP 请求中实时 JOIN 多个数据库。先分别同步到共享快照或标准表并记录 `source_key`、上游业务键和同步时间,再从共享数据生成报表;
6. 跨来源报表禁止在 HTTP 请求中实时 JOIN 多个数据库。先分别同步到共享业务镜像并记录 `source_key`、上游业务键和同步时间,再从共享数据生成报表;
7. 新来源必须先完成单表、单查询探测并登记 `UpstreamSnapshotRefresher`,最后才允许开启;仅写入 `config/upstreams.php` 不代表已经接入完成;
8. 当前登记的后续来源模板保持关闭,尚未完成表到业务页面的映射和刷新器前,不得填入真实配置或开启总开关。
@ -203,24 +207,56 @@ UPSTREAM_SLOT_WAIT_MS=1500
php artisan upstream:snapshots:refresh <source_key>
```
生产环境使用下面的总入口生成共享快照。本地开发页面直接读取上游,不依赖该任务:
尚未迁移页面仍由下面的兼容入口生成查询快照:
```bash
php artisan upstream:snapshots:refresh-all
```
旧的 `php artisan jiansun:snapshots:refresh` 继续保留兼容。新增来源只需登记自己的 `UpstreamSnapshotRefresher`,不再为每台开发机单独添加 cron。
旧的 `php artisan jiansun:snapshots:refresh` 继续保留兼容。已迁移的数据集使用自己的 `business-mirror:sync-*` 命令;部署脚本在同一把上游互斥锁内顺序调用,避免快照刷新和表级同步同时施压上游。
### 8.2 开发者需要尚未缓存的上游数据时
### 8.2 共享业务镜像接入闭环
系统数据库保存团队配置、数据字典和生产快照,不是 SQL Server 网络代理。开发环境和生产环境的读取方式不同:
项目不会复制上游所有表,只复制已经被正式业务代码使用的表或视图。每个数据集必须完成以下闭环:
1. 明确来源、对象、字段、业务键、稳定排序、增量依据和页面用途;
2. 首次全量同步使用固定时间窗、稳定游标、小批量和断点状态,任何中断都从已提交游标继续;
3. 首次全量完成前页面明确提示同步未完成,不能把第一页、局部快照或空表当成完整数据;
4. 后续同步优先按更新时间、完成时间或递增键增量读取,并保留有限重叠窗口覆盖迟到修正;
5. 上游异常时保留上一版镜像,页面继续读取旧数据并展示最近同步时间;禁止页面现场直连上游补页;
6. 验收至少对比镜像总数、第一页、第二页或末页、指定筛选、最近更新时间和上游限定计数;
7. 没有可靠增量依据的大表必须单独评估低频分段核对,不能在每分钟任务里全表扫描。
首个落地数据集是商品价格:上游 `operations_replica / rpt.Bulk_Price_Limit`,镜像表 `product_price_mirror_rows`,同步命令:
```bash
php artisan business-mirror:sync-product-prices --full
php artisan business-mirror:sync-product-prices
```
其余已在代码中使用的上游表由统一的数据集目录管理。首次全量必须逐个数据集显式执行,普通定时任务只续传已完成首次全量的数据集:
```bash
php artisan business-mirror:sync-tables <dataset-key> --full
php artisan business-mirror:sync-tables
```
完整数据集、来源对象、镜像表和分阶段切换状态见 `docs/上游业务镜像清单.md`。不得把“已登记同步目录”误报成“页面已经切换完成”;每个业务域仍需在首份全量核对通过后,把对应 Repository 改为只读镜像。
首次 `--full` 属于生产数据写入,必须先备份 `caiwu_system_config` 和 `caiwu_business_mirror`,并由负责人明确授权。后续无参数命令只执行或继续受控增量。
### 8.3 开发者需要尚未接入的上游数据时
系统数据库保存团队配置、数据字典和同步状态,共享业务库保存已接入业务行;二者都不是 SQL Server 网络代理。开发探索与正式页面的读取方式不同:
```text
本地开发探索 -> 通用只读入口 -> 使用共享凭据直接读取选定业务表
本地功能实现 -> 固定 Repository -> 使用共享凭据直接只读上游
生产环境 -> 登记 scope + 参数 -> 后台固定刷新器 -> 共享查询快照
正式页面 -> 镜像 Repository -> caiwu_business_mirror
生产同步节点 -> 固定批次 Repository -> 对应上游
```
开发者需要先确认表名和字段时,进入“系统设置 → 数据库管理 → 查看数据字典”。任何能直连上游的开发环境或生产刷新节点都可以低频读取两套上游的 `INFORMATION_SCHEMA`,把表、视图、字段、类型和可空信息保存到共享系统数据库 `upstream_schema_columns`。目录同步失败时保留最近一次完整结果,不会清空其他来源。需要更新目录时由 Codex 自动执行:
@ -240,16 +276,16 @@ php artisan upstream:schema:sync operations_replica
1. **先声明边界**:任务和 PR 必须写明 `source_key`、业务用途、表或视图、字段、过滤条件、排序、最大返回量、公司/权限范围、期望 TTL 和没有数据时的页面表现。禁止只写“同步整张表”;
2. **实现固定查询**:Repository/适配器通过 `UpstreamReadOnlyQuery` 执行参数化的 `SELECT/WITH`;控制器和页面只调用 Repository,不接触 PDO,不把 SQL 保存到系统数据库;
3. **登记读取契约**:为列表、汇总和选项定义稳定且可审查的 `scope`,通过 `UpstreamQueryCache` 读取,并在对应 `UpstreamSnapshotRefresher` 中登记同名范围。参数必须有限制,列表必须有稳定排序和行数上限;
3. **登记同步契约**:定义稳定且可审查的数据集键、上游业务键、同步游标、重叠窗口和镜像表。参数必须有限制,批次必须有稳定排序和行数上限;
4. **分支内完成自动测试**:使用脱敏 fixture 或测试替身验证正常、空数据和不可用状态;开发机能够直连时,通过通用只读入口或新 Repository 用少量真实只读数据验证,不得在测试、日志或截图中输出凭据和敏感业务行;
5. **本地直接验证**:本地页面/API 直接执行固定只读查询,不需要配置 `allowed_scopes` 或等待首份快照;
6. **生产登记范围**:代码审查和测试通过后合并到 `main`;生产刷新器仍登记精确 `allowed_scopes` 并生成共享快照;
7. **完成双实例验收**:确认首份快照有同步时间,页面响应头为 `fresh` 或允许的 `stale`;随后从第二个应用实例用相同访问范围读取,并证明 resolver 没有再次访问上游;
5. **本地直接验证**:开发阶段可以直接执行固定只读查询确认数据;正式页面测试使用隔离镜像库 fixture,不依赖负责人环境;
6. **生产首次同步**:代码审查和测试通过后合并到 `main`;经数据库写入授权后创建镜像表并执行可断点的首次全量;
7. **完成共享验收**:确认同步状态成功、镜像总数和筛选计数与上游限定查询一致,第二页或末页可读;随后从第二个应用实例读取相同镜像,并证明页面没有再次访问上游;
8. **发现压力立即收口**:慢查询、返回量异常或上游告警时,先从共享记录移除该精确范围并保留证据,再缩小字段、时间窗或分页。禁止通过提高并发、扩大 `*` 或全表扫描解决。
部署脚本已经统一安装 `upstream:snapshots:refresh-all`,新增业务查询通常不需要新增 cron 或修改服务器 Compose。发布包含新范围时,发布记录必须额外写明:对应刷新器是否在镜像中、开放了哪些精确范围、首次快照是否成功、第二实例是否复用以及是否观察到慢查询/熔断。未完成这些验收时,范围保持关闭。
部署脚本在统一上游互斥锁内运行兼容快照刷新和已登记的业务镜像同步。发布新增镜像数据集时,发布记录必须额外写明:镜像表迁移、批次 Repository、首次同步状态、镜像/上游对账、第二页或末页验证、第二实例复用,以及是否观察到慢查询或上游压力。未完成首次全量时,页面必须保持“同步未完成”,不能显示误导性空数据。
### 8.3 JianSun 现有来源
### 8.4 旧查询快照兼容范围
该来源按不可修改的只读旧系统处理,不要求对方修改账号、表结构、索引、字段或数据库功能。连接启用状态保存在系统数据库;`JIANSUN_ENABLED` 只用于明确开启的本机覆盖模式。
@ -322,9 +358,9 @@ php artisan system-database:reencrypt-upstream-passwords --apply
标准共享模式在系统数据库云数据库记录的 `allowed_scopes` 字段中填写逗号分隔的精确范围或 `prefix.*`;本机覆盖模式才使用 `JIANSUN_ALLOWED_SCOPES`。首次上线先开放完成探测的小表范围,例如 `invoice-deductions.*`;约 280 万行核心表对应范围必须单独验证,任何模式下都不得填写全局 `*`。
生产策略提供的是按代码实际使用范围生成的近实时查询快照,不等同于复制上游全库。本地开发直接读取代码实际调用的数据。没有业务代码调用的数据范围不会查询或同步;禁止为了追求秒级实时而周期性扫描数百万行。
旧查询快照只用于尚未完成表级迁移的页面,不等同于复制上游全库,也不得继续作为新功能默认方案。没有业务代码调用的表不会同步;新增正式页面按 8.2 接入共享业务镜像。禁止为了追求秒级实时而周期性扫描数百万行。
### 8.4 生产服务器不能直连上游时
### 8.5 生产服务器不能直连上游时
使用一台指定开发机作为主中继,另一台作为备用中继。它们只转发 TCP 流量,不保存数据库凭据,也不在开发机执行同步查询。数据库凭据加密保存在系统数据库,中继机只保存已收紧 ACL 的端点和隧道配置;应用仍通过同一套快照、限流和熔断逻辑读取数据。

View File

@ -0,0 +1,21 @@
<?php
namespace Modules\EcommerceFinance\Contracts;
interface BusinessMirrorDataset
{
public function key(): string;
public function sourceKey(): string;
public function sourceSchema(): string;
public function sourceObject(): string;
public function targetTable(): string;
/** @return list<string> */
public function keyColumns(): array;
public function watermarkColumn(): ?string;
}

View File

@ -17,14 +17,25 @@ class AlibabaBillRepository
private UpstreamReadOnlyQuery $query;
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
public function __construct(
UpstreamReadOnlyQueryFactory $factory,
private readonly ?MarketingAccountBillMirrorRepository $mirror = null,
) {
$this->query = $factory->forSource(self::SOURCE);
}
public function usesBusinessMirror(): bool
{
return $this->mirror?->ready(MarketingAccountBillMirrorRepository::ALIBABA) ?? false;
}
/** @return array{shops: string[], product_types: string[]} */
public function filterOptions(): array
{
if ($this->usesBusinessMirror()) {
return $this->mirror->alibabaFilterOptions();
}
$shops = $this->query->select(
'SELECT DISTINCT TOP (500) shopName FROM ' . self::TABLE
. ' WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> \'\' ORDER BY shopName'
@ -51,6 +62,23 @@ class AlibabaBillRepository
): array {
$page = max(1, $page);
$pageSize = min(max(1, $pageSize), 100);
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginateAlibaba(
$shopName,
$productType,
$fromDate,
$toDate,
$page,
$pageSize,
);
return [
'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']),
'total' => $result['total'],
];
}
[$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate);
$total = (int) $this->query->scalar('SELECT COUNT(*) FROM ' . self::TABLE . ' src' . $where, $bindings);
@ -82,13 +110,17 @@ class AlibabaBillRepository
?string $fromDate = null,
?string $toDate = null,
): array {
[$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate);
$row = $this->query->first(
'SELECT COALESCE(SUM(tradeAMTIn), 0) AS income_total, '
. 'COALESCE(SUM(tradeAMTOut), 0) AS expense_total, COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . ' src' . $where,
$bindings,
) ?? [];
if ($this->usesBusinessMirror()) {
$row = $this->mirror->summarizeAlibaba($shopName, $productType, $fromDate, $toDate);
} else {
[$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate);
$row = $this->query->first(
'SELECT COALESCE(SUM(tradeAMTIn), 0) AS income_total, '
. 'COALESCE(SUM(tradeAMTOut), 0) AS expense_total, COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . ' src' . $where,
$bindings,
) ?? [];
}
$income = round((float) ($row['income_total'] ?? 0), 2);
$expense = round((float) ($row['expense_total'] ?? 0), 2);

View File

@ -0,0 +1,150 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use InvalidArgumentException;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
class BusinessInformationMirrorRepository
{
private const DATASETS = [
'company-information.read' => 'operations_replica.rpt.fys_companyInfo',
'store-information.read' => 'operations_replica.rpt.fys_shopInfo',
'trademark-information.read' => 'operations_replica.rpt.fys_TrademarkInfo',
];
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function ready(string $scope): bool
{
return $this->mirror->ready($this->dataset($scope));
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
public function paginate(string $scope, int $page, int $perPage): array
{
if (! $this->ready($scope)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
return match ($scope) {
'company-information.read' => $this->companies($page, $perPage),
'store-information.read' => $this->stores($page, $perPage),
'trademark-information.read' => $this->trademarks($page, $perPage),
default => throw new InvalidArgumentException('未知的业务资料镜像范围:' . $scope),
};
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function companies(int $page, int $perPage): array
{
$query = $this->connection->table('ops_fys_company_info')
->selectRaw(<<<'SQL'
Id AS id,
unifiedSocialCreditCode AS unified_social_credit_code,
businessLicenseName AS company_name,
registeredCapital AS subscribed_capital,
businessLicenseType AS company_type,
IncorporationDate AS establishment_date,
legalRepresentative AS legal_representative,
registeredAddress AS registered_address,
businessScope AS business_scope,
accountSerialNumber AS basic_deposit_account_number,
companyStatus AS company_status,
financialManager AS financial_officer,
supervisor AS supervisor,
taxCategoryAndType AS taxpayer_category,
NULL AS shareholders
SQL);
return $this->page($query, $page, $perPage, 'Id');
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function stores(int $page, int $perPage): array
{
$latestCompanies = $this->connection->table('ops_fys_company_info')
->selectRaw('unifiedSocialCreditCode, MAX(Id) AS latest_id')
->whereNotNull('unifiedSocialCreditCode')
->where('unifiedSocialCreditCode', '<>', '')
->groupBy('unifiedSocialCreditCode');
$query = $this->connection->table('ops_fys_shop_info AS s')
->leftJoinSub($latestCompanies, 'company_keys', function ($join): void {
$join->on('company_keys.unifiedSocialCreditCode', '=', 's.unifiedSocialCreditCode');
})
->leftJoin('ops_fys_company_info AS c', 'c.Id', '=', 'company_keys.latest_id')
->selectRaw(<<<'SQL'
s.Id AS id,
s.openTime AS opened_at,
s.erpShopName AS erp_store_name,
s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name,
s.trademarkNumber AS trademark_number,
NULL AS trademark_name,
s.platform AS platform,
s.responsibleDepartment AS profit_center,
s.shopHead AS store_manager,
s.shopState AS store_status,
s.shopType AS store_type,
s.channelType AS channel_mode
SQL);
return $this->page($query, $page, $perPage, 's.Id');
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function trademarks(int $page, int $perPage): array
{
$query = $this->connection->table('ops_fys_trademark_info')
->where('isDelete', 0)
->selectRaw(<<<'SQL'
Id AS id,
trademarkName AS trademark_name,
internationalClassification AS trademark_type,
trademarkStatus AS trademark_status,
registrationDate AS registration_date,
termOfValidity AS expiry_date,
remark AS notes
SQL);
return $this->page($query, $page, $perPage, 'Id');
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function page($query, int $page, int $perPage, string $orderColumn): array
{
$total = (clone $query)->count();
$rows = $query->orderByDesc($orderColumn)
->forPage($page, $perPage)
->get()
->map(static fn (object $row): array => (array) $row)
->all();
return ['data' => $rows, 'total' => $total];
}
private function dataset(string $scope): BusinessMirrorDataset
{
$key = self::DATASETS[$scope] ?? null;
if ($key === null) {
throw new InvalidArgumentException('未知的业务资料镜像范围:' . $scope);
}
return $this->datasets->get($key);
}
}

View File

@ -0,0 +1,256 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use RuntimeException;
class GenericBusinessMirrorRepository
{
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
public function connection(): ConnectionInterface
{
return $this->connection;
}
public function ready(BusinessMirrorDataset $dataset): bool
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('business_mirror_table_states')
|| ! $schema->hasTable($dataset->targetTable())) {
return false;
}
$state = $this->state($dataset);
return is_array($state) && ($state['initial_completed_at'] ?? null) !== null;
}
/** @return array<string,mixed>|null */
public function state(BusinessMirrorDataset $dataset): ?array
{
if (! Schema::connection($this->connection->getName())->hasTable('business_mirror_table_states')) {
return null;
}
$row = $this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key())
->first();
if ($row === null) {
return null;
}
$state = (array) $row;
$state['cursor'] = $this->decode($state['cursor'] ?? null);
$state['high_watermark'] = $this->decode($state['high_watermark'] ?? null);
return $state;
}
public function startRun(
BusinessMirrorDataset $dataset,
string $mode,
string $generation,
?string $windowStart,
?string $windowEnd,
bool $resume,
): array {
$now = now();
$values = [
'source_key' => $dataset->sourceKey(),
'source_object' => $dataset->sourceSchema() . '.' . $dataset->sourceObject(),
'target_table' => $dataset->targetTable(),
'status' => 'running',
'run_mode' => $mode,
'generation' => $generation,
'last_attempted_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
];
if (! $resume) {
$values += [
'cursor' => null,
'window_started_at' => $windowStart,
'window_ends_at' => $windowEnd,
'last_upserted_rows' => 0,
];
}
$query = $this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key());
if ($query->exists()) {
$query->update($values);
} else {
$this->connection->table('business_mirror_table_states')->insert(
['dataset_key' => $dataset->key(), 'created_at' => $now] + $values,
);
}
return $this->state($dataset) ?? [];
}
public function storeBatch(
BusinessMirrorDataset $dataset,
array $columns,
array $rows,
array $cursor,
string $generation,
int $cumulativeRows,
): void {
$now = now();
$columnNames = array_map(fn (array $column): string => (string) $column['column_name'], $columns);
$mapped = [];
foreach ($rows as $row) {
$values = [];
foreach ($columnNames as $columnName) {
$values[$columnName] = $this->value($row, $columnName);
}
$identity = array_map(
fn (string $column): string => $this->identityValue($this->value($row, $column)),
$dataset->keyColumns(),
);
$mapped[] = $values + [
'_mirror_key' => hash('sha256', implode("\0", $identity)),
'_mirror_generation' => $generation,
'_mirror_synced_at' => $now,
];
}
$this->connection->transaction(function () use (
$dataset, $columnNames, $mapped, $cursor, $cumulativeRows, $now
): void {
$updates = [...$columnNames, '_mirror_generation', '_mirror_synced_at'];
$chunkSize = max(1, intdiv(50000, max(count($updates) + 1, 1)));
foreach (array_chunk($mapped, $chunkSize) as $chunk) {
$this->connection->table($dataset->targetTable())->upsert($chunk, ['_mirror_key'], $updates);
}
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key())
->update([
'cursor' => json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION),
'last_upserted_rows' => $cumulativeRows,
'updated_at' => $now,
]);
});
}
public function complete(
BusinessMirrorDataset $dataset,
string $mode,
string $generation,
array $highWatermark,
int $rows,
): void {
$now = now();
$this->connection->transaction(function () use (
$dataset, $mode, $generation, $highWatermark, $rows, $now
): void {
if ($mode === 'full') {
$existingRows = $this->connection->table($dataset->targetTable())->count();
if ($rows === 0 && $existingRows > 0) {
throw new RuntimeException('business_mirror_unexpected_empty_source');
}
$this->connection->table($dataset->targetTable())
->where(function ($query) use ($generation): void {
$query->whereNull('_mirror_generation')
->orWhere('_mirror_generation', '!=', $generation);
})
->delete();
}
$rowCount = $this->connection->table($dataset->targetTable())->count();
$values = [
'status' => 'succeeded',
'cursor' => null,
'high_watermark' => json_encode($highWatermark, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION),
'last_succeeded_at' => $now,
'last_upserted_rows' => $rows,
'mirror_row_count' => $rowCount,
'last_error_code' => null,
'updated_at' => $now,
];
if ($mode === 'full') {
$values['initial_completed_at'] = $now;
$values['last_full_succeeded_at'] = $now;
}
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key())
->update($values);
});
}
public function fail(BusinessMirrorDataset $dataset, string $errorCode): void
{
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key())
->update([
'status' => 'failed',
'last_error_code' => mb_substr($errorCode, 0, 120),
'updated_at' => now(),
]);
}
private function decode(mixed $value): ?array
{
if (is_array($value)) {
return $value;
}
if (! is_string($value) || $value === '') {
return null;
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
private function value(array $row, string $name): mixed
{
foreach ($row as $key => $value) {
if (strcasecmp((string) $key, $name) === 0) {
return $value;
}
}
return null;
}
private function identityValue(mixed $value): string
{
if ($value === null) {
return '<NULL>';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
return (string) $value;
}
}

View File

@ -19,14 +19,34 @@ class JdWalletBillRepository
private UpstreamReadOnlyQuery $query;
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
public function __construct(
UpstreamReadOnlyQueryFactory $factory,
private readonly ?MarketingAccountBillMirrorRepository $mirror = null,
) {
$this->query = $factory->forSource(self::SOURCE);
}
public function usesBusinessMirror(string $ledger): bool
{
$dataset = $ledger === 'consumption'
? MarketingAccountBillMirrorRepository::JD_CONSUMPTION
: MarketingAccountBillMirrorRepository::JD_RECHARGE;
return $this->mirror?->ready($dataset) ?? false;
}
public function usesBusinessMirrorForOptions(): bool
{
return $this->usesBusinessMirror('recharge') && $this->usesBusinessMirror('consumption');
}
/** @return string[] */
public function shopOptions(): array
{
if ($this->usesBusinessMirrorForOptions()) {
return $this->mirror->jdShopOptions();
}
$rows = $this->query->select(
"SELECT TOP (500) shopName FROM (SELECT shopName FROM " . self::RECHARGE_TABLE
. " UNION SELECT shopName FROM " . self::CONSUMPTION_TABLE
@ -47,6 +67,21 @@ class JdWalletBillRepository
int $page = 1,
int $pageSize = 25,
): array {
if ($this->usesBusinessMirror('recharge')) {
$result = $this->mirror->paginateJdRecharge(
$shopName,
$fromDate,
$toDate,
$page,
$pageSize,
);
return [
'rows' => array_map(fn (array $row): array => $this->normalizeRecharge($row), $result['rows']),
'total' => $result['total'],
];
}
return $this->paginate(self::RECHARGE_TABLE, $this->rechargeWhere($shopName, $fromDate, $toDate), $page, $pageSize, [
'id', 'shopName', 'amount', 'balanceAmount', 'orderTypeName', 'pk',
'createDate', 'updateTime', 'statusName', 'remark',
@ -61,6 +96,21 @@ class JdWalletBillRepository
int $page = 1,
int $pageSize = 25,
): array {
if ($this->usesBusinessMirror('consumption')) {
$result = $this->mirror->paginateJdConsumption(
$shopName,
$fromDate,
$toDate,
$page,
$pageSize,
);
return [
'rows' => array_map(fn (array $row): array => $this->normalizeConsumption($row), $result['rows']),
'total' => $result['total'],
];
}
return $this->paginate(self::CONSUMPTION_TABLE, $this->consumptionWhere($shopName, $fromDate, $toDate), $page, $pageSize, [
'id', 'shopName', 'amount', 'balanceAmount', 'orderTypeName', 'creatTime', 'settleTime', 'pk',
'invoiceApplyTypeName',
@ -70,12 +120,32 @@ class JdWalletBillRepository
/** @return array{amount_total: float, balance_total: float, count: int} */
public function summaryRecharge(?string $shopName = null, ?string $fromDate = null, ?string $toDate = null): array
{
if ($this->usesBusinessMirror('recharge')) {
return $this->normalizeSummary($this->mirror->summarizeJd(
MarketingAccountBillMirrorRepository::JD_RECHARGE,
'createDate',
$shopName,
$fromDate,
$toDate,
));
}
return $this->summary(self::RECHARGE_TABLE, $this->rechargeWhere($shopName, $fromDate, $toDate));
}
/** @return array{amount_total: float, balance_total: float, count: int} */
public function summaryConsumption(?string $shopName = null, ?string $fromDate = null, ?string $toDate = null): array
{
if ($this->usesBusinessMirror('consumption')) {
return $this->normalizeSummary($this->mirror->summarizeJd(
MarketingAccountBillMirrorRepository::JD_CONSUMPTION,
'creatTime',
$shopName,
$fromDate,
$toDate,
));
}
return $this->summary(self::CONSUMPTION_TABLE, $this->consumptionWhere($shopName, $fromDate, $toDate));
}
@ -111,6 +181,12 @@ class JdWalletBillRepository
$where['bindings'],
) ?? [];
return $this->normalizeSummary($row);
}
/** @return array{amount_total: float, balance_total: float, count: int} */
private function normalizeSummary(array $row): array
{
return [
'amount_total' => round((float) ($row['amount_total'] ?? 0), 2),
'balance_total' => round((float) ($row['balance_total'] ?? 0), 2),

View File

@ -0,0 +1,543 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/**
* 营销账户账单的完整业务镜像读取层。
*
* 这里只返回与旧 SQL Server Repository 相同的原始字段;金额换算和业务名称
* 仍由原 Repository 负责,确保镜像切换不会改变页面口径。
*/
class MarketingAccountBillMirrorRepository
{
public const TAOBAO = 'jiansun.dbo.tmall_spread_bills';
public const PINDUODUO = 'jiansun.dbo.pdd_spread_bills';
public const ALIBABA = 'jiansun.dbo.alibaba_bill';
public const JD_RECHARGE = 'jiansun.dbo.jd_spreadBills';
public const JD_CONSUMPTION = 'jiansun.dbo.jd_costBills';
private readonly ConnectionInterface $connection;
/** @var array<string,bool> */
private array $readiness = [];
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function ready(string $datasetKey): bool
{
if (array_key_exists($datasetKey, $this->readiness)) {
return $this->readiness[$datasetKey];
}
try {
return $this->readiness[$datasetKey] = $this->mirror->ready($this->dataset($datasetKey));
} catch (Throwable $exception) {
report($exception);
return $this->readiness[$datasetKey] = false;
}
}
/** @return string[] */
public function taobaoStoreOptions(): array
{
return $this->stringOptions(self::TAOBAO, 'shopName');
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateTaobao(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
int $page,
int $pageSize,
): array {
$query = $this->table(self::TAOBAO, 'src');
$this->applyTaobaoFilters($query, $storeName, $fromDate, $toDate, $flowType);
return $this->paginate($query, [
'src.id', 'src.shopName', 'src.uniTransTime', 'src.transTime', 'src.amount',
'src.balance', 'src.comment', 'src.finType', 'src.bizType', 'src.tradeType',
], ['src.uniTransTime', 'src.id'], $page, min(max($pageSize, 1), 200));
}
/** @return array{income_total_raw:float,expense_total_raw:float,cnt:int} */
public function summarizeTaobao(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): array {
$query = $this->table(self::TAOBAO, 'src');
$this->applyTaobaoFilters($query, $storeName, $fromDate, $toDate, $flowType);
$row = $query->selectRaw(
"COALESCE(SUM(CASE WHEN src.finType = 'DEBIT' THEN src.amount ELSE 0 END), 0) AS income_total_raw, "
. "COALESCE(SUM(CASE WHEN src.finType = 'CREDIT' THEN src.amount ELSE 0 END), 0) AS expense_total_raw, "
. 'COUNT(*) AS cnt'
)->first();
return [
'income_total_raw' => (float) ($row->income_total_raw ?? 0),
'expense_total_raw' => (float) ($row->expense_total_raw ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateTaobaoMonthlyBalances(
?string $storeName,
?string $month,
int $page,
int $pageSize,
): array {
return $this->monthlyBalances(
self::TAOBAO,
'transTime',
"CASE WHEN src.finType = 'DEBIT' THEN src.amount ELSE 0 END",
"CASE WHEN src.finType = 'CREDIT' THEN src.amount ELSE 0 END",
$storeName,
$month,
$page,
min(max($pageSize, 1), 5000),
'opening_balance_raw',
'income_total_raw',
'expense_total_raw',
'system_closing_balance_raw',
);
}
/** @return string[] */
public function pinduoduoStoreOptions(): array
{
return $this->stringOptions(self::PINDUODUO, 'shopName');
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginatePinduoduo(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
int $page,
int $pageSize,
): array {
$query = $this->table(self::PINDUODUO, 'src');
$this->applyPinduoduoFilters($query, $storeName, $fromDate, $toDate, $flowType);
return $this->paginate($query, [
'src.id', 'src.shopName', 'src.amount', 'src.balance', 'src.summary',
'src.flowType', 'src.toAdvertiserFundType', 'src.createTime',
], ['src.createTime', 'src.id'], $page, min(max($pageSize, 1), 200));
}
/** @return array{income_total:float,expense_total:float,cnt:int} */
public function summarizePinduoduo(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): array {
$query = $this->table(self::PINDUODUO, 'src');
$this->applyPinduoduoFilters($query, $storeName, $fromDate, $toDate, $flowType);
$row = $query->selectRaw(
'COALESCE(SUM(CASE WHEN src.flowType = 1 THEN src.amount ELSE 0 END), 0) AS income_total, '
. 'COALESCE(SUM(CASE WHEN src.flowType = 2 THEN src.amount ELSE 0 END), 0) AS expense_total, '
. 'COUNT(*) AS cnt'
)->first();
return [
'income_total' => (float) ($row->income_total ?? 0),
'expense_total' => (float) ($row->expense_total ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginatePinduoduoMonthlyBalances(
?string $storeName,
?string $month,
int $page,
int $pageSize,
): array {
return $this->monthlyBalances(
self::PINDUODUO,
'createTime',
'CASE WHEN src.flowType = 1 THEN src.amount ELSE 0 END',
'CASE WHEN src.flowType = 2 THEN src.amount ELSE 0 END',
$storeName,
$month,
$page,
min(max($pageSize, 1), 5000),
'opening_balance',
'income_total',
'expense_total',
'system_closing_balance',
);
}
/** @return array{shops:string[],product_types:string[]} */
public function alibabaFilterOptions(): array
{
return [
'shops' => $this->stringOptions(self::ALIBABA, 'shopName', 500),
'product_types' => $this->stringOptions(self::ALIBABA, 'tradeTypeName', 500),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateAlibaba(
?string $shopName,
?string $productType,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
$query = $this->table(self::ALIBABA, 'src');
$this->applyAlibabaFilters($query, $shopName, $productType, $fromDate, $toDate);
return $this->paginate($query, [
'src.id', 'src.shopName', 'src.tradeDay', 'src.tradeTypeName',
'src.tradeAMTIn', 'src.tradeAMTOut',
], ['src.tradeDay', 'src.id'], $page, min(max($pageSize, 1), 100));
}
/** @return array{income_total:float,expense_total:float,cnt:int} */
public function summarizeAlibaba(
?string $shopName,
?string $productType,
?string $fromDate,
?string $toDate,
): array {
$query = $this->table(self::ALIBABA, 'src');
$this->applyAlibabaFilters($query, $shopName, $productType, $fromDate, $toDate);
$row = $query->selectRaw(
'COALESCE(SUM(src.tradeAMTIn), 0) AS income_total, '
. 'COALESCE(SUM(src.tradeAMTOut), 0) AS expense_total, COUNT(*) AS cnt'
)->first();
return [
'income_total' => (float) ($row->income_total ?? 0),
'expense_total' => (float) ($row->expense_total ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return string[] */
public function jdShopOptions(): array
{
$shops = array_merge(
$this->stringOptions(self::JD_RECHARGE, 'shopName', 500),
$this->stringOptions(self::JD_CONSUMPTION, 'shopName', 500),
);
$shops = array_values(array_unique($shops));
sort($shops, SORT_NATURAL);
return array_slice($shops, 0, 500);
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateJdRecharge(
?string $shopName,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
return $this->paginateJd(
self::JD_RECHARGE,
'createDate',
['src.id', 'src.shopName', 'src.amount', 'src.balanceAmount', 'src.orderTypeName',
'src.pk', 'src.createDate', 'src.updateTime', 'src.statusName', 'src.remark'],
$shopName,
$fromDate,
$toDate,
$page,
$pageSize,
);
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateJdConsumption(
?string $shopName,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
return $this->paginateJd(
self::JD_CONSUMPTION,
'creatTime',
['src.id', 'src.shopName', 'src.amount', 'src.balanceAmount', 'src.orderTypeName',
'src.creatTime', 'src.settleTime', 'src.pk', 'src.invoiceApplyTypeName'],
$shopName,
$fromDate,
$toDate,
$page,
$pageSize,
);
}
/** @return array{amount_total:float,balance_total:float,cnt:int} */
public function summarizeJd(
string $datasetKey,
string $dateField,
?string $shopName,
?string $fromDate,
?string $toDate,
): array {
$query = $this->table($datasetKey, 'src');
$this->applyJdFilters($query, $dateField, $shopName, $fromDate, $toDate);
$row = $query->selectRaw(
'COALESCE(SUM(ABS(src.amount)), 0) AS amount_total, '
. 'COALESCE(SUM(src.balanceAmount), 0) AS balance_total, COUNT(*) AS cnt'
)->first();
return [
'amount_total' => (float) ($row->amount_total ?? 0),
'balance_total' => (float) ($row->balance_total ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
private function paginateJd(
string $datasetKey,
string $dateField,
array $fields,
?string $shopName,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
$query = $this->table($datasetKey, 'src');
$this->applyJdFilters($query, $dateField, $shopName, $fromDate, $toDate);
return $this->paginate(
$query,
$fields,
['src.' . $dateField, 'src.id'],
$page,
min(max($pageSize, 1), 100),
);
}
private function applyTaobaoFilters(
Builder $query,
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): void {
$this->whereValue($query, 'src.shopName', $storeName);
$this->whereFromDate($query, 'src.transTime', $fromDate);
$this->whereToDate($query, 'src.transTime', $toDate);
if ($flowType === 'income') {
$query->where('src.finType', 'DEBIT');
} elseif ($flowType === 'expense') {
$query->where('src.finType', 'CREDIT');
}
}
private function applyPinduoduoFilters(
Builder $query,
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): void {
$this->whereValue($query, 'src.shopName', $storeName);
$this->whereFromDate($query, 'src.createTime', $fromDate, true);
$this->whereToDate($query, 'src.createTime', $toDate);
if ($flowType === 'income') {
$query->where('src.flowType', 1);
} elseif ($flowType === 'expense') {
$query->where('src.flowType', 2);
}
}
private function applyAlibabaFilters(
Builder $query,
?string $shopName,
?string $productType,
?string $fromDate,
?string $toDate,
): void {
$this->whereValue($query, 'src.shopName', $shopName);
$this->whereValue($query, 'src.tradeTypeName', $productType);
if ($this->validDate($fromDate)) {
$query->whereDate('src.tradeDay', '>=', $fromDate);
}
if ($this->validDate($toDate)) {
$query->whereDate('src.tradeDay', '<=', $toDate);
}
}
private function applyJdFilters(
Builder $query,
string $dateField,
?string $shopName,
?string $fromDate,
?string $toDate,
): void {
$this->whereValue($query, 'src.shopName', $shopName);
$this->whereFromDate($query, 'src.' . $dateField, $fromDate, true);
$this->whereToDate($query, 'src.' . $dateField, $toDate);
}
private function whereValue(Builder $query, string $column, ?string $value): void
{
if ($value !== null && $value !== '') {
$query->where($column, $value);
}
}
private function whereFromDate(Builder $query, string $column, ?string $date, bool $midnight = false): void
{
if ($this->validDate($date)) {
$query->where($column, '>=', $date . ($midnight ? ' 00:00:00' : ''));
}
}
private function whereToDate(Builder $query, string $column, ?string $date): void
{
if ($this->validDate($date)) {
$query->where($column, '<', date('Y-m-d', strtotime($date . ' +1 day')) . ' 00:00:00');
}
}
private function validDate(?string $date): bool
{
return $date !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1;
}
/** @return string[] */
private function stringOptions(string $datasetKey, string $column, ?int $limit = null): array
{
$query = $this->table($datasetKey)
->whereNotNull($column)
->where($column, '<>', '')
->distinct()
->orderBy($column);
if ($limit !== null) {
$query->limit($limit);
}
return $query->pluck($column)->map(static fn (mixed $value): string => (string) $value)->all();
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
private function paginate(Builder $query, array $fields, array $orderColumns, int $page, int $pageSize): array
{
$page = max($page, 1);
$total = (clone $query)->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
foreach ($orderColumns as $column) {
$query->orderByDesc($column);
}
$rows = $query->select($fields)
->forPage($page, $pageSize)
->get()
->map(static fn (object $row): array => (array) $row)
->all();
return ['rows' => $rows, 'total' => $total];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
private function monthlyBalances(
string $datasetKey,
string $dateField,
string $incomeExpression,
string $expenseExpression,
?string $storeName,
?string $month,
int $page,
int $pageSize,
string $openingAlias,
string $incomeAlias,
string $expenseAlias,
string $closingAlias,
): array {
$page = max($page, 1);
$driver = $this->connection->getDriverName();
$monthStart = $driver === 'sqlite'
? "date(src.$dateField, 'start of month')"
: "DATE_FORMAT(src.$dateField, '%Y-%m-01')";
$currentMonth = $driver === 'sqlite'
? "date('now', 'start of month')"
: "DATE_FORMAT(CURRENT_DATE, '%Y-%m-01')";
$lastDay = $driver === 'sqlite'
? "date(m.month_start, '+1 month', '-1 day')"
: "DATE_FORMAT(LAST_DAY(m.month_start), '%Y-%m-%d')";
$table = $this->dataset($datasetKey)->targetTable();
$ranked = $this->connection->table($table . ' AS src')
->whereNotNull('src.shopName')
->whereNotNull('src.' . $dateField)
->whereRaw("src.$dateField < $currentMonth")
->when($storeName !== null && $storeName !== '', fn (Builder $query) => $query->where('src.shopName', $storeName))
->selectRaw("src.shopName, src.$dateField AS entry_time, src.id AS entry_id, src.balance AS entry_balance, $monthStart AS month_start")
->selectRaw("$incomeExpression AS income_value, $expenseExpression AS expense_value")
->selectRaw("LAG(src.balance) OVER (PARTITION BY src.shopName ORDER BY src.$dateField, src.id) AS opening_balance")
->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shopName, $monthStart ORDER BY src.$dateField, src.id) AS month_first_rank")
->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shopName, $monthStart ORDER BY src.$dateField DESC, src.id DESC) AS month_last_rank");
$groups = $this->connection->query()->fromSub($ranked, 'ranked')
->when($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1, fn (Builder $query) => $query->where('month_start', $month . '-01'))
->selectRaw("shopName, month_start, COALESCE(SUM(income_value), 0) AS $incomeAlias, COALESCE(SUM(expense_value), 0) AS $expenseAlias")
->selectRaw("MAX(CASE WHEN month_first_rank = 1 THEN opening_balance END) AS $openingAlias")
->selectRaw("MAX(CASE WHEN month_last_rank = 1 THEN entry_balance END) AS $closingAlias")
->groupBy('shopName')
->groupBy('month_start');
$total = $this->connection->query()->fromSub(clone $groups, 'monthly_count')->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
$query = $this->connection->query()->fromSub($groups, 'm')
->selectRaw("m.shopName, $lastDay AS balance_date, m.$incomeAlias, m.$expenseAlias, m.$openingAlias, m.$closingAlias")
->orderByDesc('m.month_start')
->orderBy('m.shopName')
->forPage($page, $pageSize);
return [
'rows' => $query->get()->map(static fn (object $row): array => (array) $row)->all(),
'total' => $total,
];
}
private function table(string $datasetKey, ?string $alias = null): Builder
{
if (! $this->ready($datasetKey)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$table = $this->dataset($datasetKey)->targetTable();
return $this->connection->table($alias === null ? $table : $table . ' AS ' . $alias);
}
private function dataset(string $datasetKey): BusinessMirrorDataset
{
return $this->datasets->get($datasetKey);
}
}

View File

@ -41,11 +41,18 @@ class PinduoduoSpreadBillRepository
private UpstreamReadOnlyQuery $query;
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
public function __construct(
UpstreamReadOnlyQueryFactory $factory,
private readonly ?MarketingAccountBillMirrorRepository $mirror = null,
) {
$this->query = $factory->forSource(self::SOURCE);
}
public function usesBusinessMirror(): bool
{
return $this->mirror?->ready(MarketingAccountBillMirrorRepository::PINDUODUO) ?? false;
}
/**
* 店铺下拉选项(全部去重)
*
@ -53,6 +60,10 @@ class PinduoduoSpreadBillRepository
*/
public function storeOptions(): array
{
if ($this->usesBusinessMirror()) {
return $this->mirror->pinduoduoStoreOptions();
}
$rows = $this->query->select(
'SELECT DISTINCT shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL ORDER BY shopName'
);
@ -79,6 +90,22 @@ class PinduoduoSpreadBillRepository
$page = max(1, $page);
$pageSize = max(1, min(200, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginatePinduoduo(
$storeName,
$fromDate,
$toDate,
$flowType,
$page,
$pageSize,
);
return [
'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']),
'total' => $result['total'],
];
}
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// 总数
@ -125,16 +152,20 @@ class PinduoduoSpreadBillRepository
?string $toDate = null,
?string $flowType = null
): array {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
if ($this->usesBusinessMirror()) {
$row = $this->mirror->summarizePinduoduo($storeName, $fromDate, $toDate, $flowType);
} else {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, '
. 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, '
. 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
}
$income = (float) ($row['income_total'] ?? 0);
$expense = (float) ($row['expense_total'] ?? 0);
@ -156,6 +187,16 @@ class PinduoduoSpreadBillRepository
): array {
$page = max(1, $page);
$pageSize = max(1, min(5000, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginatePinduoduoMonthlyBalances($storeName, $month, $page, $pageSize);
return [
'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $result['rows']),
'total' => $result['total'],
];
}
$monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, createTime), 0)';
[$whereSql, $params] = $this->buildMonthlyWhere($storeName, $month);

View File

@ -0,0 +1,216 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
class ProductPriceMirrorRepository
{
public const DATASET_KEY = 'supplier-payables.product-prices';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
public function ready(): bool
{
$schema = Schema::connection($this->connection->getName());
return $schema->hasTable('business_mirror_sync_states')
&& $schema->hasTable('product_price_mirror_rows');
}
public function initialized(): bool
{
$state = $this->state();
return is_array($state) && ($state['initial_completed_at'] ?? null) !== null;
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
$row = $this->connection->table('business_mirror_sync_states')
->where('dataset_key', self::DATASET_KEY)
->first();
return $row === null ? null : (array) $row;
}
public function paginate(array $filters, int $page, int $limit): LengthAwarePaginator
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$query = $this->connection->table('product_price_mirror_rows');
if (($filters['barcode'] ?? '') !== '') {
$query->whereRaw("barcode LIKE ? ESCAPE '!'", [$this->contains((string) $filters['barcode'])]);
}
if (($filters['completed_from'] ?? '') !== '') {
$query->where('completed_at', '>=', (string) $filters['completed_from'] . ' 00:00:00');
}
if (($filters['completed_to'] ?? '') !== '') {
$query->where('completed_at', '<', Carbon::parse((string) $filters['completed_to'])->addDay()->startOfDay());
}
return $query
->orderByDesc('completed_at')
->orderByDesc('business_id')
->orderBy('barcode')
->paginate($limit, [
'barcode', 'spu', 'product_name', 'category', 'production_status',
'minimum_price', 'price_limit', 'purchase_price', 'shipping_fee',
'jingxi_limit_price', 'approval_status', 'approval_result',
'workflow_title', 'completed_at',
], 'page', $page);
}
public function startRun(string $mode, Carbon $since, Carbon $until, bool $resume): array
{
$now = now();
$values = [
'source_key' => (string) config('upstream_mirrors.product_prices.source_key', 'operations_replica'),
'source_object' => (string) config('upstream_mirrors.product_prices.source_object', 'rpt.Bulk_Price_Limit'),
'status' => 'running',
'run_mode' => $mode,
'last_attempted_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
];
if (! $resume) {
$values += [
'window_started_at' => $since,
'window_ends_at' => $until,
'cursor_completed_at' => null,
'cursor_business_id' => null,
'cursor_product_code' => null,
'last_upserted_rows' => 0,
'created_at' => $now,
];
}
$query = $this->connection->table('business_mirror_sync_states')
->where('dataset_key', self::DATASET_KEY);
if ($query->exists()) {
$query->update($values);
} else {
$this->connection->table('business_mirror_sync_states')->insert(
['dataset_key' => self::DATASET_KEY, 'created_at' => $now] + $values,
);
}
return $this->state() ?? [];
}
public function storeBatch(array $rows, array $cursor, int $cumulativeRows): void
{
$now = now();
$mapped = array_map(function (array $row) use ($now): array {
$businessId = (string) ($row['business_id'] ?? '');
$barcode = (string) ($row['barcode'] ?? '');
$cursorCompletedAt = (string) ($row['cursor_completed_at'] ?? $row['completed_at'] ?? '');
$values = [
'business_id' => $businessId,
'barcode' => $barcode,
'spu' => $row['spu'] ?? null,
'product_name' => $row['product_name'] ?? null,
'category' => $row['category'] ?? null,
'production_status' => $row['production_status'] ?? null,
'minimum_price' => $row['minimum_price'] ?? null,
'price_limit' => $row['price_limit'] ?? null,
'purchase_price' => $row['purchase_price'] ?? null,
'shipping_fee' => $row['shipping_fee'] ?? null,
'jingxi_limit_price' => $row['jingxi_limit_price'] ?? null,
'approval_status' => $row['approval_status'] ?? null,
'approval_result' => $row['approval_result'] ?? null,
'workflow_title' => $row['workflow_title'] ?? null,
'completed_at' => $row['completed_at'],
];
return $values + [
'upstream_key' => hash('sha256', $cursorCompletedAt . '|' . $businessId . '|' . $barcode),
'row_hash' => hash('sha256', json_encode($values, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION)),
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}, $rows);
$this->connection->transaction(function () use ($mapped, $cursor, $cumulativeRows, $now): void {
if ($mapped !== []) {
$this->connection->table('product_price_mirror_rows')->upsert(
$mapped,
['upstream_key'],
[
'business_id', 'barcode', 'spu', 'product_name', 'category',
'production_status', 'minimum_price', 'price_limit', 'purchase_price',
'shipping_fee', 'jingxi_limit_price', 'approval_status', 'approval_result',
'workflow_title', 'completed_at', 'row_hash', 'synced_at', 'updated_at',
],
);
}
$this->connection->table('business_mirror_sync_states')
->where('dataset_key', self::DATASET_KEY)
->update([
'cursor_completed_at' => $cursor['completed_at'],
'cursor_business_id' => $cursor['business_id'],
'cursor_product_code' => $cursor['barcode'],
'last_upserted_rows' => $cumulativeRows,
'updated_at' => $now,
]);
});
}
public function complete(string $mode, Carbon $windowEnd, int $rows): void
{
$now = now();
$values = [
'status' => 'succeeded',
'last_succeeded_at' => $windowEnd,
'last_upserted_rows' => $rows,
'cursor_completed_at' => null,
'cursor_business_id' => null,
'cursor_product_code' => null,
'last_error_code' => null,
'updated_at' => $now,
];
if ($mode === 'full') {
$values['initial_completed_at'] = $now;
}
$this->connection->table('business_mirror_sync_states')
->where('dataset_key', self::DATASET_KEY)
->update($values);
}
public function fail(string $errorCode): void
{
$this->connection->table('business_mirror_sync_states')
->where('dataset_key', self::DATASET_KEY)
->update([
'status' => 'failed',
'last_error_code' => mb_substr($errorCode, 0, 80),
'updated_at' => now(),
]);
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
}

View File

@ -70,6 +70,62 @@ class ProductPriceRepository
return ['rows' => array_map([$this, 'normalize'], $this->query->select($sql, $bindings))];
}
/**
* 按稳定游标分批读取已完成的上游记录,只供后台镜像同步使用。
*
* @param array{completed_at:string,business_id:string,barcode:string}|null $cursor
* @return array<int,array<string,mixed>>
*/
public function mirrorBatch(?array $cursor, string $since, string $until, int $limit): array
{
$limit = min(max($limit, 1), 2000);
$bindings = [$since, $until];
$cursorSql = '';
if ($cursor !== null) {
$cursorSql = ' AND ('
. '[process_end_date] > CAST(? AS datetime2(7))'
. " OR ([process_end_date] = CAST(? AS datetime2(7)) AND COALESCE([businessId], '') > ?)"
. " OR ([process_end_date] = CAST(? AS datetime2(7)) AND COALESCE([businessId], '') = ?"
. " AND COALESCE([product_code], '') > ?)"
. ')';
$bindings = array_merge($bindings, [
$cursor['completed_at'],
$cursor['completed_at'],
$cursor['business_id'],
$cursor['completed_at'],
$cursor['business_id'],
$cursor['barcode'],
]);
}
$sql = "SELECT TOP {$limit} "
. "COALESCE([businessId], '') AS business_id, "
. "COALESCE([product_code], '') AS barcode, "
. '[spu] AS spu, '
. '[product_name] AS product_name, '
. '[category] AS category, '
. '[production_status] AS production_status, '
. '[min_price] AS minimum_price, '
. '[price_limit] AS price_limit, '
. '[purchase_price] AS purchase_price, '
. '[shipping_fee] AS shipping_fee, '
. '[jingxi_limit_price] AS jingxi_limit_price, '
. "CASE [status] WHEN 'COMPLETED' THEN N'已完成' ELSE NULLIF([status], '') END AS approval_status, "
. "CASE [result] WHEN 'agree' THEN N'已同意' WHEN 'refuse' THEN N'已拒绝' ELSE NULLIF([result], '') END AS approval_result, "
. '[title] AS workflow_title, '
. 'CONVERT(varchar(33), [process_end_date], 126) AS cursor_completed_at, '
. 'CONVERT(varchar(26), [process_end_date], 121) AS completed_at '
. 'FROM ' . self::TABLE
. ' WHERE [process_end_date] IS NOT NULL'
. ' AND [process_end_date] >= CAST(? AS datetime2(7))'
. ' AND [process_end_date] < CAST(? AS datetime2(7))'
. $cursorSql
. " ORDER BY [process_end_date] ASC, COALESCE([businessId], '') ASC, COALESCE([product_code], '') ASC";
return array_map([$this, 'normalize'], $this->query->select($sql, $bindings));
}
private function contains(string $value): string
{
return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], trim($value)) . '%';

View File

@ -58,11 +58,18 @@ class TmallTaobaoSpreadBillRepository
private UpstreamReadOnlyQuery $query;
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
public function __construct(
UpstreamReadOnlyQueryFactory $factory,
private readonly ?MarketingAccountBillMirrorRepository $mirror = null,
) {
$this->query = $factory->forSource(self::SOURCE);
}
public function usesBusinessMirror(): bool
{
return $this->mirror?->ready(MarketingAccountBillMirrorRepository::TAOBAO) ?? false;
}
/**
* 店铺下拉选项(全部去重)
*
@ -70,6 +77,10 @@ class TmallTaobaoSpreadBillRepository
*/
public function storeOptions(): array
{
if ($this->usesBusinessMirror()) {
return $this->mirror->taobaoStoreOptions();
}
$rows = $this->query->select(
'SELECT DISTINCT shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL ORDER BY shopName'
);
@ -96,6 +107,22 @@ class TmallTaobaoSpreadBillRepository
$page = max(1, $page);
$pageSize = max(1, min(200, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginateTaobao(
$storeName,
$fromDate,
$toDate,
$flowType,
$page,
$pageSize,
);
return [
'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']),
'total' => $result['total'],
];
}
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// 总数
@ -143,17 +170,21 @@ class TmallTaobaoSpreadBillRepository
?string $toDate = null,
?string $flowType = null
): array {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
if ($this->usesBusinessMirror()) {
$row = $this->mirror->summarizeTaobao($storeName, $fromDate, $toDate, $flowType);
} else {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// amount 单位为「分」,汇总时除以 100 转为「元」
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, '
. 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
// amount 单位为「分」,汇总时除以 100 转为「元」
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, '
. 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
}
// 分 -> 元
$income = ((float) ($row['income_total_raw'] ?? 0)) / 100;
@ -176,6 +207,16 @@ class TmallTaobaoSpreadBillRepository
): array {
$page = max(1, $page);
$pageSize = max(1, min(5000, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginateTaobaoMonthlyBalances($storeName, $month, $page, $pageSize);
return [
'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $result['rows']),
'total' => $result['total'],
];
}
$monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, transTime), 0)';
[$where, $params] = $this->buildMonthlyWhere($storeName, $month);

View File

@ -0,0 +1,78 @@
<?php
namespace Modules\EcommerceFinance\Services;
use InvalidArgumentException;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
class BusinessMirrorDatasetRegistry
{
/** @return array<string,BusinessMirrorDataset> */
public function all(): array
{
$datasets = [];
foreach ((array) config('upstream_mirrors.datasets', []) as $key => $definition) {
$datasets[(string) $key] = $this->make((string) $key, (array) $definition);
}
return $datasets;
}
public function get(string $key): BusinessMirrorDataset
{
$definitions = (array) config('upstream_mirrors.datasets', []);
$definition = (array) ($definitions[$key] ?? []);
if ($definition === []) {
throw new InvalidArgumentException('未知的业务镜像数据集:' . $key);
}
return $this->make($key, $definition);
}
private function make(string $key, array $definition): BusinessMirrorDataset
{
$source = trim((string) ($definition['source_key'] ?? ''));
$schema = trim((string) ($definition['schema'] ?? ''));
$object = trim((string) ($definition['object'] ?? ''));
$target = trim((string) ($definition['target'] ?? ''));
$keys = array_values(array_filter(array_map('strval', (array) ($definition['keys'] ?? []))));
$watermark = trim((string) ($definition['watermark'] ?? ''));
foreach ([$source, $schema, $object, $target, ...$keys] as $identifier) {
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) {
throw new InvalidArgumentException('业务镜像数据集包含不安全的标识符:' . $key);
}
}
if ($keys === [] || strlen($target) > 64) {
throw new InvalidArgumentException('业务镜像数据集缺少稳定键或目标表名过长:' . $key);
}
if ($watermark !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $watermark) !== 1) {
throw new InvalidArgumentException('业务镜像数据集的增量字段不安全:' . $key);
}
return new readonly class($key, $source, $schema, $object, $target, $keys, $watermark ?: null) implements BusinessMirrorDataset {
public function __construct(
private string $key,
private string $source,
private string $schema,
private string $object,
private string $target,
private array $keys,
private ?string $watermark,
) {
}
public function key(): string { return $this->key; }
public function sourceKey(): string { return $this->source; }
public function sourceSchema(): string { return $this->schema; }
public function sourceObject(): string { return $this->object; }
public function targetTable(): string { return $this->target; }
public function keyColumns(): array { return $this->keys; }
public function watermarkColumn(): ?string { return $this->watermark; }
};
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use RuntimeException;
class BusinessMirrorSchemaManager
{
/** @return list<array<string,mixed>> */
public function ensure(BusinessMirrorDataset $dataset): array
{
$columns = $this->columns($dataset);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = Schema::connection($connection);
if (! $schema->hasTable($dataset->targetTable())) {
$schema->create($dataset->targetTable(), function (Blueprint $table) use ($columns, $dataset): void {
$table->char('_mirror_key', 64)->primary();
foreach ($columns as $column) {
$this->addColumn($table, $column);
}
$table->uuid('_mirror_generation')->nullable()->index();
$table->dateTime('_mirror_synced_at', 6)->index();
$watermark = $dataset->watermarkColumn();
if ($watermark !== null && $this->isIndexable($columns, $watermark)) {
$table->index($watermark, $this->indexName($dataset->targetTable(), 'watermark'));
}
});
return $columns;
}
$existing = array_map('strtolower', $schema->getColumnListing($dataset->targetTable()));
$missing = array_values(array_filter(
$columns,
fn (array $column): bool => ! in_array(strtolower((string) $column['column_name']), $existing, true),
));
if ($missing !== []) {
$schema->table($dataset->targetTable(), function (Blueprint $table) use ($missing): void {
foreach ($missing as $column) {
$this->addColumn($table, $column);
}
});
}
return $columns;
}
/** @return list<array<string,mixed>> */
public function columns(BusinessMirrorDataset $dataset): array
{
$rows = DB::table('upstream_schema_columns')
->where('source_key', $dataset->sourceKey())
->whereRaw('LOWER(schema_name) = ?', [strtolower($dataset->sourceSchema())])
->whereRaw('LOWER(object_name) = ?', [strtolower($dataset->sourceObject())])
->orderBy('ordinal_position')
->get()
->map(fn (object $row): array => (array) $row)
->all();
if ($rows === []) {
throw new RuntimeException('business_mirror_schema_catalog_missing:' . $dataset->key());
}
$names = array_map(fn (array $row): string => strtolower((string) $row['column_name']), $rows);
if (count($names) !== count(array_unique($names))) {
throw new RuntimeException('business_mirror_duplicate_column:' . $dataset->key());
}
foreach ($rows as $row) {
$name = (string) $row['column_name'];
if (strlen($name) > 64 || str_starts_with(strtolower($name), '_mirror_')) {
throw new RuntimeException('business_mirror_unsupported_column:' . $dataset->key());
}
}
foreach ([...$dataset->keyColumns(), $dataset->watermarkColumn()] as $required) {
if ($required !== null && ! in_array(strtolower($required), $names, true)) {
throw new RuntimeException('business_mirror_required_column_missing:' . $dataset->key());
}
}
return $rows;
}
private function addColumn(Blueprint $table, array $column): void
{
$name = (string) $column['column_name'];
$type = strtolower((string) $column['data_type']);
$length = (int) ($column['max_length'] ?? 0);
$precision = min(max((int) ($column['numeric_precision'] ?? 20), 1), 65);
$scale = min(max((int) ($column['numeric_scale'] ?? 0), 0), min($precision, 30));
$definition = match ($type) {
'bit' => $table->boolean($name),
'tinyint' => $table->unsignedTinyInteger($name),
'smallint' => $table->smallInteger($name),
'int' => $table->integer($name),
'bigint' => $table->bigInteger($name),
'decimal', 'numeric', 'money', 'smallmoney' => $table->decimal($name, $precision, $scale),
'float', 'real' => $table->double($name),
'date' => $table->date($name),
'datetime', 'datetime2', 'smalldatetime' => $table->dateTime($name, 6),
'time' => $table->time($name, 6),
'uniqueidentifier' => $table->char($name, 36),
'binary', 'varbinary', 'image', 'timestamp', 'rowversion' => $table->binary($name),
// MariaDB counts every inline varchar column towards the 65,535-byte
// row limit. Wide legacy SQL Server tables can contain dozens of
// nvarchar columns even though each individual column looks small.
// Keep only genuinely short values inline; TEXT is stored off-page
// and lets the mirror preserve the complete source schema.
'char', 'nchar', 'varchar', 'nvarchar' => $length > 0 && $length <= 64
? $table->string($name, $length)
: $table->longText($name),
default => $table->longText($name),
};
$definition->nullable();
}
private function isIndexable(array $columns, string $name): bool
{
foreach ($columns as $column) {
if (strcasecmp((string) $column['column_name'], $name) !== 0) {
continue;
}
$type = strtolower((string) $column['data_type']);
$length = (int) ($column['max_length'] ?? 0);
return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary'], true)
&& (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar'], true) || ($length > 0 && $length <= 64));
}
return false;
}
private function indexName(string $table, string $suffix): string
{
return substr($table . '_' . $suffix, 0, 64);
}
}

View File

@ -0,0 +1,307 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use RuntimeException;
use Throwable;
class BusinessMirrorSynchronizer
{
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly BusinessMirrorSchemaManager $schemas,
private readonly GenericBusinessMirrorRepository $mirror,
private readonly UpstreamReadOnlyQueryFactory $queries,
) {
}
/** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */
public function sync(string $datasetKey, bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array
{
$dataset = $this->datasets->get($datasetKey);
// A long-running scheduler may have retained a timed-out PDO instance
// from the previous dataset. Each dataset starts with a fresh upstream
// connection so one source failure cannot poison subsequent mirrors.
UpstreamOdbcConnection::reset($dataset->sourceKey());
$state = $this->mirror->state($dataset);
if (! $full && ($state['initial_completed_at'] ?? null) === null) {
return $this->result($dataset, 'requires_full_sync', 'incremental', 0, 0);
}
$columns = $this->schemas->ensure($dataset);
$mode = $full ? 'full' : 'incremental';
$resume = is_array($state)
&& ($state['run_mode'] ?? null) === $mode
&& in_array($state['status'] ?? null, ['running', 'failed'], true)
&& is_array($state['cursor'] ?? null);
$generation = $resume && is_string($state['generation'] ?? null)
? (string) $state['generation']
: (string) Str::uuid();
[$windowStart, $windowEnd] = $this->window($dataset, $state, $mode, $resume);
$state = $this->mirror->startRun(
$dataset,
$mode,
$generation,
$windowStart?->format('Y-m-d H:i:s.u'),
$windowEnd?->format('Y-m-d H:i:s.u'),
$resume,
);
$cursor = is_array($state['cursor'] ?? null) ? $state['cursor'] : null;
$highWatermark = is_array($state['high_watermark'] ?? null) ? $state['high_watermark'] : [];
// Routine runs keep the configured 500-row default. A larger explicit
// batch is reserved for supervised first syncs so wide SQL Server views
// need fewer repeated keyset scans without creating unbounded PHP arrays.
$batchSize = min(max($batchSize ?? (int) config('upstream_mirrors.table_sync.batch_size', 500), 1), 5000);
$maxBatches = max($maxBatches ?? (int) config('upstream_mirrors.table_sync.max_batches_per_run', 2), 0);
$batches = 0;
$rows = (int) ($state['last_upserted_rows'] ?? 0);
try {
while (true) {
$batch = $this->readBatch(
$dataset,
$columns,
$mode,
$cursor,
$highWatermark,
$windowStart,
$windowEnd,
$batchSize,
);
if ($batch === []) {
$this->mirror->complete($dataset, $mode, $generation, $cursor ?? $highWatermark, $rows);
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
}
$cursor = $this->cursor($dataset, $batch[array_key_last($batch)]);
$rows += count($batch);
$batches++;
$this->mirror->storeBatch($dataset, $columns, $this->normalize($columns, $batch), $cursor, $generation, $rows);
if (count($batch) < $batchSize) {
$this->mirror->complete($dataset, $mode, $generation, $cursor, $rows);
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
}
if ($maxBatches > 0 && $batches >= $maxBatches) {
return $this->result($dataset, 'running', $mode, $batches, $rows);
}
}
} catch (Throwable $exception) {
$this->mirror->fail($dataset, $exception::class);
throw $exception;
}
}
/** @return list<array<string,mixed>> */
private function readBatch(
BusinessMirrorDataset $dataset,
array $columns,
string $mode,
?array $cursor,
array $highWatermark,
?Carbon $windowStart,
?Carbon $windowEnd,
int $limit,
): array {
$order = $this->orderColumns($dataset);
$where = [];
$bindings = [];
if ($mode === 'incremental' && $dataset->watermarkColumn() !== null) {
$watermark = $this->quote($dataset->watermarkColumn());
$where[] = $watermark . ' >= ? AND ' . $watermark . ' < ?';
$bindings[] = $this->sqlServerDateTime($windowStart);
$bindings[] = $this->sqlServerDateTime($windowEnd);
} elseif ($mode === 'incremental' && $cursor === null && $highWatermark !== []) {
[$predicate, $values] = $this->keyset($dataset->keyColumns(), $highWatermark);
$where[] = $predicate;
array_push($bindings, ...$values);
}
if ($cursor !== null) {
[$predicate, $values] = $this->keyset($order, $cursor);
$where[] = $predicate;
array_push($bindings, ...$values);
}
$sql = 'SELECT TOP (' . $limit . ') '
. implode(', ', array_map(fn (array $column): string => $this->selectExpression($column), $columns))
. ' FROM ' . $this->quote($dataset->sourceSchema()) . '.' . $this->quote($dataset->sourceObject())
. ($where === [] ? '' : ' WHERE (' . implode(') AND (', $where) . ')')
. ' ORDER BY ' . implode(', ', array_map(fn (string $name): string => $this->quote($name), $order));
return $this->queries->forSource($dataset->sourceKey())->select($sql, $bindings);
}
/** @param array<string,mixed> $column */
private function selectExpression(array $column): string
{
$name = (string) $column['column_name'];
$quoted = $this->quote($name);
// FreeTDS may return SQL Server date values using the connection locale
// (for example "Jan 1 2026"). Convert temporal columns at the source so
// every mirror receives deterministic ISO values that MariaDB accepts.
return match (strtolower((string) ($column['data_type'] ?? ''))) {
'date' => 'CONVERT(varchar(10), ' . $quoted . ', 23) AS ' . $quoted,
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $quoted . ', 126) AS ' . $quoted,
default => $quoted,
};
}
private function sqlServerDateTime(?Carbon $value): ?string
{
// ISO 8601 with millisecond precision is accepted consistently by SQL Server
// through FreeTDS. Six-digit fractions were interpreted as an invalid date by
// some upstream datetime columns and broke every incremental run.
return $value?->format('Y-m-d\TH:i:s.v');
}
/** @return array{0:string,1:list<mixed>} */
private function keyset(array $columns, array $cursor): array
{
$parts = [];
$bindings = [];
foreach (array_values($columns) as $index => $column) {
$and = [];
for ($previous = 0; $previous < $index; $previous++) {
$name = $columns[$previous];
$and[] = $this->quote($name) . ' = ?';
$bindings[] = $this->cursorValue($cursor, $name);
}
$and[] = $this->quote($column) . ' > ?';
$bindings[] = $this->cursorValue($cursor, $column);
$parts[] = '(' . implode(' AND ', $and) . ')';
}
return ['(' . implode(' OR ', $parts) . ')', $bindings];
}
/** @return list<string> */
private function orderColumns(BusinessMirrorDataset $dataset): array
{
// The watermark belongs in the incremental WHERE window only. Sorting
// a very large SQL Server view by an unindexed timestamp forces an
// expensive full-window sort on every keyset page and eventually times
// out. Dataset keys are the stable, unique pagination contract.
return array_values(array_unique($dataset->keyColumns()));
}
/** @return array<string,mixed> */
private function cursor(BusinessMirrorDataset $dataset, array $row): array
{
$cursor = [];
foreach ($this->orderColumns($dataset) as $column) {
$cursor[$column] = $this->rowValue($row, $column);
}
return $cursor;
}
/** @return array{0:?Carbon,1:?Carbon} */
private function window(BusinessMirrorDataset $dataset, ?array $state, string $mode, bool $resume): array
{
if ($mode === 'full' || $dataset->watermarkColumn() === null) {
return [null, null];
}
if ($resume && ($state['window_started_at'] ?? null) !== null && ($state['window_ends_at'] ?? null) !== null) {
return [Carbon::parse((string) $state['window_started_at']), Carbon::parse((string) $state['window_ends_at'])];
}
$overlap = max((int) config('upstream_mirrors.table_sync.overlap_hours', 48), 1);
$last = Carbon::parse((string) ($state['last_succeeded_at'] ?? now()->subHours($overlap)));
return [$last->subHours($overlap), now()->addSecond()];
}
/** @return list<array<string,mixed>> */
private function normalize(array $columns, array $rows): array
{
$types = [];
foreach ($columns as $column) {
$types[strtolower((string) $column['column_name'])] = strtolower((string) $column['data_type']);
}
return array_map(function (array $row) use ($types): array {
foreach ($row as $name => $value) {
if (! is_string($value)) {
continue;
}
$type = $types[strtolower((string) $name)] ?? '';
if ($type === 'date') {
$row[$name] = substr($value, 0, 10);
} elseif (in_array($type, ['datetime', 'datetime2', 'smalldatetime'], true)) {
$row[$name] = preg_replace('/(\.\d{6})\d+/', '$1', str_replace('T', ' ', $value));
} elseif (in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'text', 'ntext', 'xml'], true)) {
$row[$name] = $this->normalizeText($value);
}
}
return $row;
}, $rows);
}
private function normalizeText(string $value): string
{
// A small number of legacy SQL Server rows contain embedded NUL bytes or
// malformed byte sequences. MariaDB correctly rejects those values as
// invalid utf8mb4. Do not guess a source encoding: preserve all valid
// UTF-8 and mark only irrecoverable bytes with the Unicode replacement
// character so one corrupt label cannot block the entire table mirror.
$value = str_replace("\0", '', $value);
return mb_check_encoding($value, 'UTF-8')
? $value
: mb_scrub($value, 'UTF-8');
}
private function quote(string $identifier): string
{
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) {
throw new RuntimeException('business_mirror_invalid_identifier');
}
return '[' . $identifier . ']';
}
private function cursorValue(array $cursor, string $column): mixed
{
foreach ($cursor as $key => $value) {
if (strcasecmp((string) $key, $column) === 0) {
return $value;
}
}
throw new RuntimeException('business_mirror_cursor_incomplete');
}
private function rowValue(array $row, string $column): mixed
{
foreach ($row as $key => $value) {
if (strcasecmp((string) $key, $column) === 0) {
return $value;
}
}
throw new RuntimeException('business_mirror_row_key_missing');
}
private function result(BusinessMirrorDataset $dataset, string $status, string $mode, int $batches, int $rows): array
{
return ['status' => $status, 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset->key()];
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
use RuntimeException;
use Throwable;
class ProductPriceMirrorSynchronizer
{
public function __construct(
private readonly ProductPriceRepository $upstream,
private readonly ProductPriceMirrorRepository $mirror,
) {
}
/** @return array{status:string,mode:string,batches:int,rows:int} */
public function sync(bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array
{
if (! $this->mirror->ready()) {
throw new RuntimeException('business_mirror_schema_missing');
}
$state = $this->mirror->state();
if (! $full && ($state['initial_completed_at'] ?? null) === null) {
return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0];
}
$mode = $full ? 'full' : 'incremental';
$resume = is_array($state)
&& ($state['run_mode'] ?? null) === $mode
&& in_array($state['status'] ?? null, ['running', 'failed'], true)
&& ($state['window_started_at'] ?? null) !== null
&& ($state['window_ends_at'] ?? null) !== null;
if ($resume) {
$since = Carbon::parse((string) $state['window_started_at']);
$until = Carbon::parse((string) $state['window_ends_at']);
} elseif ($full) {
$since = Carbon::create(1900, 1, 1, 0, 0, 0, config('app.timezone'));
$until = now()->addSecond();
} else {
$overlapHours = max((int) config('upstream_mirrors.product_prices.overlap_hours', 48), 1);
$since = Carbon::parse((string) $state['last_succeeded_at'])->subHours($overlapHours);
$until = now()->addSecond();
}
$state = $this->mirror->startRun($mode, $since, $until, $resume);
$cursor = $this->cursorFromState($state);
$batchSize ??= (int) config('upstream_mirrors.product_prices.batch_size', 500);
$batchSize = min(max($batchSize, 1), 2000);
$maxBatches ??= (int) config('upstream_mirrors.product_prices.max_batches_per_run', 4);
$maxBatches = max($maxBatches, 0);
$batches = 0;
$rows = (int) ($state['last_upserted_rows'] ?? 0);
try {
while (true) {
$batch = $this->upstream->mirrorBatch(
$cursor,
$since->format('Y-m-d H:i:s'),
$until->format('Y-m-d H:i:s'),
$batchSize,
);
if ($batch === []) {
$this->mirror->complete($mode, $until, $rows);
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows];
}
$last = $batch[array_key_last($batch)];
$cursor = [
'completed_at' => (string) ($last['cursor_completed_at'] ?? $last['completed_at']),
'business_id' => (string) $last['business_id'],
'barcode' => (string) $last['barcode'],
];
$rows += count($batch);
$batches++;
$this->mirror->storeBatch($batch, $cursor, $rows);
if (count($batch) < $batchSize) {
$this->mirror->complete($mode, $until, $rows);
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows];
}
if ($maxBatches > 0 && $batches >= $maxBatches) {
return ['status' => 'running', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows];
}
}
} catch (Throwable $exception) {
$this->mirror->fail($exception::class);
throw $exception;
}
}
/** @return array{completed_at:string,business_id:string,barcode:string}|null */
private function cursorFromState(array $state): ?array
{
if (($state['cursor_completed_at'] ?? null) === null) {
return null;
}
return [
// 直接复用 SQL Server 返回的 datetime2(7) ISO 字符串;Carbon 只有
// 6 位微秒精度,解析再格式化会丢掉最后一位并破坏稳定游标。
'completed_at' => (string) $state['cursor_completed_at'],
'business_id' => (string) ($state['cursor_business_id'] ?? ''),
'barcode' => (string) ($state['cursor_product_code'] ?? ''),
];
}
}

View File

@ -0,0 +1,189 @@
Copyright (c) 2005-2008, The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

Binary file not shown.

View File

@ -0,0 +1,218 @@
<x-layouts.admin>
<x-slot name="title">{{ $change ? '编辑报价变更' : '发起报价变更' }}</x-slot>
<x-slot name="content">
<style>
.quote-change-workspace {
--qc-ink: var(--vip-content-ink, #171717);
--qc-muted: var(--vip-content-muted, #595959);
--qc-gold: var(--vip-content-accent-dark, #856632);
--qc-gold-soft: var(--vip-content-accent-soft, #F7F0DE);
--qc-border: var(--vip-border, #D8D2C7);
margin: 24px 40px;
color: var(--qc-ink);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: 13px;
}
.quote-change-workspace *, .quote-change-workspace *::before, .quote-change-workspace *::after { box-sizing: border-box; }
.quote-change-breadcrumb { display: flex; align-items: center; gap: 6px; margin-bottom: 16px; color: var(--qc-muted); font-size: 12px; }
.quote-change-breadcrumb a { color: var(--qc-gold); }
.quote-change-panel { border: 1px solid var(--qc-border); border-radius: 12px; background: #fff; }
.quote-change-panel + .quote-change-panel { margin-top: 16px; }
.quote-change-panel__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 58px; padding: 12px 16px; border-bottom: 1px solid var(--qc-border); }
.quote-change-panel__title { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; }
.quote-change-panel__title .material-icons-outlined { color: var(--qc-gold); font-size: 19px; }
.quote-change-panel__hint { margin-top: 3px; color: var(--qc-muted); font-size: 12px; }
.quote-change-panel__body { padding: 16px; }
.quote-change-alert { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--qc-border); border-radius: 8px; background: var(--qc-gold-soft); color: #513E21; font-size: 12px; line-height: 1.5; }
.quote-change-alert--error { border-color: #E6B7B7; background: #FFF4F4; color: #8A1515; }
.quote-change-alert--retrospective { margin: 14px 0 0; border-color: #D9BD88; background: #FFF8E8; color: #704800; }
.quote-change-alert[hidden] { display: none; }
.quote-change-alert .material-icons-outlined { flex: 0 0 auto; font-size: 18px; }
.quote-change-source { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; }
.quote-change-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.quote-change-field { display: grid; gap: 6px; }
.quote-change-field--full { grid-column: 1 / -1; }
.quote-change-field label { color: var(--qc-ink); font-size: 12px; font-weight: 500; }
.quote-change-required { color: #B80000; }
.quote-change-control { width: 100%; height: 34px; border: 1px solid var(--qc-border); border-radius: 8px; background: #fff; padding: 0 10px; color: var(--qc-ink); font: inherit; }
textarea.quote-change-control { min-height: 88px; height: auto; padding: 9px 10px; resize: vertical; }
.quote-change-control:focus { border-color: var(--qc-gold); outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .2); }
.quote-change-control[readonly] { background: #F8F6F1; color: var(--qc-muted); }
.quote-change-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; border: 1px solid var(--qc-border); border-radius: 8px; background: #fff; padding: 0 14px; color: var(--qc-ink); font: inherit; font-weight: 500; white-space: nowrap; cursor: pointer; }
.quote-change-button:hover { border-color: var(--qc-gold); }
.quote-change-button:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .2); }
.quote-change-button--primary { border-color: var(--qc-gold); background: var(--qc-gold); color: #fff; }
.quote-change-button--primary:hover { border-color: #6A5129; background: #6A5129; }
.quote-change-button .material-icons-outlined { font-size: 18px; }
.quote-change-source-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 14px; padding: 12px; border: 1px solid var(--qc-border); border-radius: 8px; background: #F8F6F1; }
.quote-change-source-meta dt { color: var(--qc-muted); font-size: 11px; }
.quote-change-source-meta dd { margin: 3px 0 0; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.quote-batch-tools { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(130px, .6fr) auto; gap: 8px; align-items: end; }
.quote-change-table-wrap { overflow: auto; max-height: 540px; border: 1px solid var(--qc-border); border-radius: 8px; }
.quote-change-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.quote-change-table th { position: sticky; top: 0; z-index: 1; padding: 9px 10px; border-bottom: 1px solid var(--qc-border); background: var(--qc-gold-soft); color: var(--qc-ink); font-size: 12px; font-weight: 600; text-align: left; white-space: nowrap; }
.quote-change-table td { padding: 7px 10px; border-bottom: 1px solid #ECE8E0; vertical-align: middle; }
.quote-change-table tbody tr:last-child td { border-bottom: 0; }
.quote-change-table tbody tr.is-changed td { background: #FFFDF7; }
.quote-change-table__number { text-align: right !important; font-variant-numeric: tabular-nums; }
.quote-change-price-input { width: 108px; height: 30px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; text-align: right; font: inherit; font-variant-numeric: tabular-nums; }
.quote-change-price-input:focus { border-color: var(--qc-gold); outline: 0; box-shadow: 0 0 0 2px rgba(133, 102, 50, .16); }
.quote-change-delta { font-weight: 600; font-variant-numeric: tabular-nums; }
.quote-change-delta--up { color: #935F07; }
.quote-change-delta--down { color: #53793E; }
.quote-change-note-input { width: 180px; height: 30px; border: 1px solid var(--qc-border); border-radius: 6px; padding: 0 8px; font: inherit; }
.quote-change-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding: 12px 16px; border: 1px solid var(--qc-border); border-radius: 12px; background: rgba(255, 255, 255, .96); }
.quote-change-footer__summary { color: var(--qc-muted); font-size: 12px; }
.quote-change-footer__summary strong { color: var(--qc-ink); font-variant-numeric: tabular-nums; }
.quote-change-footer__actions { display: flex; gap: 8px; }
@media (max-width: 900px) {
.quote-change-workspace { margin: 16px; }
.quote-change-form-grid, .quote-change-source-meta { grid-template-columns: 1fr; }
.quote-change-field--full { grid-column: auto; }
.quote-batch-tools { grid-template-columns: 1fr; }
.quote-change-footer { align-items: stretch; flex-direction: column; }
.quote-change-footer__actions { justify-content: flex-end; }
}
</style>
<main class="quote-change-workspace" data-quote-change-editor data-today="{{ now()->toDateString() }}">
<nav class="quote-change-breadcrumb" aria-label="当前位置">
<a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">报价管理</a>
<span class="material-icons-outlined" aria-hidden="true" style="font-size:15px">chevron_right</span>
<span>{{ $change ? $change->number : '新建变更单' }}</span>
</nav>
@if (is_string(session('success')))
<div class="quote-change-alert" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span><span>{{ session('success') }}</span></div>
@endif
@if ($errors->any())
<div class="quote-change-alert quote-change-alert--error" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<div><strong>草稿未保存,请检查:</strong><ul>@foreach ($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
</div>
@endif
@unless ($change)
<section class="quote-change-panel" aria-labelledby="quote-source-title">
<header class="quote-change-panel__header">
<div><div class="quote-change-panel__title" id="quote-source-title"><span class="material-icons-outlined" aria-hidden="true">source</span><span>第一步:选择原报价</span></div><div class="quote-change-panel__hint">原报价保持只读,系统只保存发生变化的单元格。</div></div>
</header>
<div class="quote-change-panel__body">
<form class="quote-change-source" method="GET" action="{{ route('business.courier-quote-changes.create') }}">
<div class="quote-change-field"><label for="source_quote_id">报价版本</label><select class="quote-change-control" id="source_quote_id" name="source_quote_id">@foreach ($quotes as $quote)<option value="{{ $quote['id'] }}" @selected($quote['id'] === $source['id'])>{{ $quote['id'] }} · {{ $quote['company_name'] }} · {{ $quote['quote_type'] }}</option>@endforeach</select></div>
<button class="quote-change-button" type="submit"><span class="material-icons-outlined" aria-hidden="true">sync_alt</span><span>载入报价</span></button>
</form>
</div>
</section>
@endunless
<form method="POST" action="{{ $change ? route('business.courier-quote-changes.update', $change) : route('business.courier-quote-changes.store') }}" data-quote-change-form>
@csrf
@if ($change) @method('PATCH') @else <input type="hidden" name="source_quote_id" value="{{ $source['id'] }}"> @endif
<section class="quote-change-panel" aria-labelledby="quote-base-title">
<header class="quote-change-panel__header"><div><div class="quote-change-panel__title" id="quote-base-title"><span class="material-icons-outlined" aria-hidden="true">edit_note</span><span>{{ $change ? '变更单基本信息' : '第二步:填写变更信息' }}</span></div><div class="quote-change-panel__hint">生效日期可以早于今天;历史日期会进入追溯核算流程。</div></div></header>
<div class="quote-change-panel__body">
<div class="quote-change-source-meta">
<div><dt>原报价版本</dt><dd>{{ $source['id'] }}</dd></div><div><dt>快递公司</dt><dd>{{ $source['company_name'] }}</dd></div><div><dt>报价类型</dt><dd>{{ $source['quote_type'] }}</dd></div><div><dt>原执行日期</dt><dd>{{ $source['effective_date'] ?? '未标注' }}</dd></div>
</div>
<div class="quote-change-form-grid" style="margin-top:16px">
<div class="quote-change-field"><label for="change_type">变更类型 <span class="quote-change-required">*</span></label><select class="quote-change-control" id="change_type" name="change_type" required>@foreach (['费率调整', '面单调整', '规则备注', '执行日期', '其他'] as $type)<option value="{{ $type }}" @selected(old('change_type', $change?->change_type ?? '费率调整') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="quote-change-field"><label for="effective_date">生效日期 <span class="quote-change-required">*</span></label><input class="quote-change-control" id="effective_date" name="effective_date" type="date" value="{{ old('effective_date', $change?->effective_date?->toDateString() ?? now()->toDateString()) }}" data-effective-date required></div>
<div class="quote-change-field quote-change-field--full"><label for="summary">变更依据与说明 <span class="quote-change-required">*</span></label><textarea class="quote-change-control" id="summary" name="summary" maxlength="2000" required placeholder="请说明调整区域、重量段、金额及业务依据">{{ old('summary', $change?->summary) }}</textarea></div>
</div>
<div class="quote-change-alert quote-change-alert--retrospective" data-retrospective-alert hidden role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><span>这是历史生效日期。审核通过后应从 <strong data-retrospective-date></strong> 起追溯核算,只新增补款或扣款明细,不覆盖原运费。当前报价缺少可执行计算方式和逐票报价版本关联,在这些条件完整前系统不会自动写入可能错误的补差金额。</span></div>
</div>
</section>
<section class="quote-change-panel" aria-labelledby="quote-detail-edit-title">
<header class="quote-change-panel__header"><div><div class="quote-change-panel__title" id="quote-detail-edit-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>{{ $change ? '变价明细' : '第三步:编辑新价格' }}</span></div><div class="quote-change-panel__hint">仅新价格可编辑;系统会再次核对原价格,防止过期数据覆盖。</div></div></header>
<div class="quote-change-panel__body">
<div class="quote-change-alert"><span class="material-icons-outlined" aria-hidden="true">verified_user</span><span>保存草稿不会让报价生效。提交审核后草稿将锁定,由另一位管理员审核并生成新版本。</span></div>
<div class="quote-batch-tools" aria-label="批量调整">
<div class="quote-change-field"><label for="batch_mode">批量方式</label><select class="quote-change-control" id="batch_mode" data-batch-mode><option value="add">统一加减金额</option><option value="percent">按比例调整</option><option value="set">统一设为金额</option></select></div>
<div class="quote-change-field"><label for="batch_value">调整值</label><input class="quote-change-control" id="batch_value" type="number" step="0.0001" data-batch-value placeholder="例如 1 或 -0.5"></div>
<button class="quote-change-button" type="button" data-batch-apply><span class="material-icons-outlined" aria-hidden="true">calculate</span><span>应用到勾选行</span></button>
</div>
<div class="quote-change-table-wrap" style="margin-top:12px">
<table class="quote-change-table">
<thead><tr><th scope="col"><input type="checkbox" data-select-all aria-label="全选可调整行"></th><th scope="col">单元格</th><th scope="col">地区/规则</th><th scope="col">重量段/计费项</th><th scope="col" class="quote-change-table__number">原价格</th><th scope="col" class="quote-change-table__number">新价格</th><th scope="col" class="quote-change-table__number">差额</th><th scope="col">备注</th></tr></thead>
<tbody>
@foreach ($editableCells as $index => $cell)
<tr data-price-row data-original="{{ $cell['original_amount'] }}">
<td><input type="checkbox" data-row-select aria-label="选择 {{ $cell['source_cell'] }}" @checked($cell['selected'])></td>
<td><strong>{{ $cell['source_cell'] }}</strong><input type="hidden" name="items[{{ $index }}][source_cell]" value="{{ $cell['source_cell'] }}"><input type="hidden" name="items[{{ $index }}][source_row]" value="{{ $cell['source_row'] }}"><input type="hidden" name="items[{{ $index }}][source_column]" value="{{ $cell['source_column'] }}"></td>
<td>{{ $cell['region'] ?: '—' }}<input type="hidden" name="items[{{ $index }}][region]" value="{{ $cell['region'] }}"></td>
<td>{{ $cell['weight_band'] ?: ($cell['charge_item'] ?: '—') }}<input type="hidden" name="items[{{ $index }}][weight_band]" value="{{ $cell['weight_band'] }}"><input type="hidden" name="items[{{ $index }}][charge_item]" value="{{ $cell['charge_item'] }}"></td>
<td class="quote-change-table__number">{{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}<input type="hidden" name="items[{{ $index }}][original_amount]" value="{{ $cell['original_amount'] }}"></td>
<td class="quote-change-table__number"><input class="quote-change-price-input" type="number" name="items[{{ $index }}][new_amount]" min="0" step="0.0001" value="{{ old("items.$index.new_amount", $cell['new_amount']) }}" data-new-price required></td>
<td class="quote-change-table__number"><span class="quote-change-delta" data-price-delta>0.0000</span></td>
<td><input class="quote-change-note-input" name="items[{{ $index }}][note]" maxlength="500" value="{{ old("items.$index.note", $cell['note']) }}" placeholder="选填"><input type="hidden" name="items[{{ $index }}][unit]" value="{{ $cell['unit'] }}"></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</section>
<footer class="quote-change-footer">
<div class="quote-change-footer__summary">当前已修改 <strong data-changed-count>0</strong> 个价格单元格;原始工作簿不会被修改。</div>
<div class="quote-change-footer__actions"><a class="quote-change-button" href="{{ $change ? route('business.courier-quote-changes.show', $change) : route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']) }}">取消</a><button class="quote-change-button quote-change-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">save</span><span>保存草稿</span></button></div>
</footer>
</form>
@if ($change)
<form method="POST" action="{{ route('business.courier-quote-changes.submit', $change) }}" style="display:flex;justify-content:flex-end;margin-top:12px" onsubmit="return confirm('提交后草稿将锁定并进入审核,确认继续吗?')">@csrf<button class="quote-change-button" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>
@endif
</main>
<script>
(function () {
var root = document.querySelector('[data-quote-change-editor]');
if (!root || root.dataset.initialized === 'true') return;
root.dataset.initialized = 'true';
var rows = Array.from(root.querySelectorAll('[data-price-row]'));
var count = root.querySelector('[data-changed-count]');
var effectiveDate = root.querySelector('[data-effective-date]');
var retrospectiveAlert = root.querySelector('[data-retrospective-alert]');
function refreshRetrospective() {
if (!effectiveDate || !retrospectiveAlert) return;
var isRetrospective = effectiveDate.value !== '' && effectiveDate.value < root.dataset.today;
retrospectiveAlert.hidden = !isRetrospective;
if (isRetrospective) retrospectiveAlert.querySelector('[data-retrospective-date]').textContent = effectiveDate.value;
}
function refreshRow(row) {
var original = Number(row.dataset.original);
var input = row.querySelector('[data-new-price]');
var next = Number(input.value);
var delta = Number.isFinite(next) ? next - original : 0;
var output = row.querySelector('[data-price-delta]');
output.textContent = (delta > 0 ? '+' : '') + delta.toFixed(4);
output.className = 'quote-change-delta' + (delta > 0 ? ' quote-change-delta--up' : delta < 0 ? ' quote-change-delta--down' : '');
row.classList.toggle('is-changed', Math.abs(delta) > 0.00005);
if (Math.abs(delta) > 0.00005) row.querySelector('[data-row-select]').checked = true;
}
function refreshAll() { rows.forEach(refreshRow); count.textContent = String(rows.filter(function (row) { return row.classList.contains('is-changed'); }).length); }
rows.forEach(function (row) { row.querySelector('[data-new-price]').addEventListener('input', refreshAll); });
if (effectiveDate) effectiveDate.addEventListener('change', refreshRetrospective);
root.querySelector('[data-select-all]').addEventListener('change', function (event) { rows.forEach(function (row) { row.querySelector('[data-row-select]').checked = event.target.checked; }); });
root.querySelector('[data-batch-apply]').addEventListener('click', function () {
var value = Number(root.querySelector('[data-batch-value]').value);
if (!Number.isFinite(value)) { root.querySelector('[data-batch-value]').focus(); return; }
var mode = root.querySelector('[data-batch-mode]').value;
rows.filter(function (row) { return row.querySelector('[data-row-select]').checked; }).forEach(function (row) {
var input = row.querySelector('[data-new-price]');
var current = Number(input.value);
var result = mode === 'add' ? current + value : mode === 'percent' ? current * (1 + value / 100) : value;
input.value = Math.max(0, result).toFixed(4);
});
refreshAll();
});
refreshAll();
refreshRetrospective();
}());
</script>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,56 @@
<x-layouts.admin>
<x-slot name="title">报价变更历史</x-slot>
<x-slot name="content">
<style>
.quote-history { --qh-ink:var(--vip-content-ink,#171717); --qh-muted:var(--vip-content-muted,#595959); --qh-gold:var(--vip-content-accent-dark,#856632); --qh-soft:var(--vip-content-accent-soft,#F7F0DE); --qh-border:var(--vip-border,#D8D2C7); margin:24px 40px; color:var(--qh-ink); font-family:"JetBrains Mono","Microsoft YaHei","PingFang SC",monospace; font-size:13px; }
.quote-history * { box-sizing:border-box; }
.quote-history__top { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:16px; }
.quote-history__back { display:inline-flex; align-items:center; gap:5px; color:var(--qh-gold); font-size:12px; }
.quote-history__actions { display:flex; gap:8px; }
.quote-history__button { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:34px; border:1px solid var(--qh-border); border-radius:8px; background:#fff; padding:0 14px; color:var(--qh-ink); font:inherit; font-weight:500; }
.quote-history__button--primary { border-color:var(--qh-gold); background:var(--qh-gold); color:#fff; }
.quote-history__button .material-icons-outlined { font-size:18px; }
.quote-history__panel { overflow:hidden; border:1px solid var(--qh-border); border-radius:12px; background:#fff; }
.quote-history__header { padding:14px 16px; border-bottom:1px solid var(--qh-border); }
.quote-history__title { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; }
.quote-history__title .material-icons-outlined { color:var(--qh-gold); font-size:19px; }
.quote-history__hint { margin-top:4px; color:var(--qh-muted); font-size:12px; }
.quote-history__table-wrap { overflow-x:auto; }
.quote-history table { width:100%; border-collapse:collapse; font-size:12.5px; }
.quote-history th { padding:10px 12px; border-bottom:1px solid var(--qh-border); background:var(--qh-soft); font-size:12px; font-weight:600; text-align:left; white-space:nowrap; }
.quote-history td { padding:10px 12px; border-bottom:1px solid #ECE8E0; white-space:nowrap; }
.quote-history tbody tr:last-child td { border-bottom:0; }
.quote-history__number { font-weight:600; font-variant-numeric:tabular-nums; }
.quote-history__badge { display:inline-flex; align-items:center; min-height:24px; border:1px solid var(--qh-border); border-radius:999px; padding:2px 9px; background:#fff; font-size:11px; }
.quote-history__badge--pending { border-color:#D9BD88; background:#FFF8E8; color:#7A4C00; }
.quote-history__badge--approved { border-color:#BAD0AB; background:#F3F8EF; color:#40622F; }
.quote-history__badge--returned { border-color:#E6B7B7; background:#FFF4F4; color:#8A1515; }
.quote-history__link { color:var(--qh-gold); font-weight:500; }
.quote-history__empty { padding:56px 16px; color:var(--qh-muted); text-align:center; }
.quote-history__empty .material-icons-outlined { display:block; margin-bottom:8px; color:var(--qh-gold); font-size:32px; }
.quote-history__pagination { padding:12px 16px; border-top:1px solid var(--qh-border); }
@media(max-width:800px){.quote-history{margin:16px}.quote-history__top{align-items:stretch;flex-direction:column}.quote-history__actions{justify-content:flex-end}}
</style>
<main class="quote-history">
<div class="quote-history__top">
<a class="quote-history__back" href="{{ route('business.pages.show', ['section'=>'courier-payables','page'=>'quote-management']) }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价管理</span></a>
<div class="quote-history__actions"><a class="quote-history__button quote-history__button--primary" href="{{ route('business.courier-quote-changes.create') }}"><span class="material-icons-outlined" aria-hidden="true">edit_note</span><span>发起报价变更</span></a></div>
</div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
<section class="quote-history__panel" aria-labelledby="quote-history-title">
<header class="quote-history__header"><div class="quote-history__title" id="quote-history-title"><span class="material-icons-outlined" aria-hidden="true">history</span><span>报价变更记录</span></div><div class="quote-history__hint">草稿、审核和新版本按当前公司隔离保存,历史记录不可覆盖原报价。</div></header>
@if($changes->isEmpty())
<div class="quote-history__empty"><span class="material-icons-outlined" aria-hidden="true">difference</span><strong>还没有报价变更记录</strong><div>发起第一张变更单后,草稿和审核进度会显示在这里。</div></div>
@else
<div class="quote-history__table-wrap"><table><thead><tr><th>变更单号</th><th>原报价</th><th>快递公司</th><th>变更类型</th><th>明细</th><th>生效日期</th><th>生效方式</th><th>追溯核算</th><th>状态</th><th>新版本</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
@foreach($changes as $change)
@php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''})
<tr><td class="quote-history__number">{{ $change->number }}</td><td>{{ $change->source_quote_id }}</td><td>{{ $change->carrier_name }}</td><td>{{ $change->change_type }}</td><td>{{ $change->items->count() }} 项</td><td>{{ $change->effective_date->toDateString() }}</td><td><span class="quote-history__badge @if($change->isRetrospective()) quote-history__badge--pending @endif">{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</span></td><td><span class="quote-history__badge @if($change->recalculation_status==='blocked') quote-history__badge--returned @elseif($change->recalculation_status==='pending') quote-history__badge--pending @endif">{{ $change->recalculationStatusLabel() }}</span></td><td><span class="quote-history__badge @if($badge) quote-history__badge--{{ $badge }} @endif">{{ $change->statusLabel() }}</span></td><td>{{ $change->version?->version_code ?? '—' }}</td><td>{{ $change->updated_at?->format('Y-m-d H:i') }}</td><td><a class="quote-history__link" href="{{ route('business.courier-quote-changes.show',$change) }}">查看</a>@if($change->isEditable() && $change->created_by===user_id()) · <a class="quote-history__link" href="{{ route('business.courier-quote-changes.edit',$change) }}">编辑</a>@endif</td></tr>
@endforeach
</tbody></table></div><div class="quote-history__pagination">{{ $changes->links() }}</div>
@endif
</section>
</main>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,118 @@
<x-layouts.admin>
<x-slot name="title">报价变更详情</x-slot>
<x-slot name="content">
<style>
.quote-review { --qr-ink:var(--vip-content-ink,#171717); --qr-muted:var(--vip-content-muted,#595959); --qr-gold:var(--vip-content-accent-dark,#856632); --qr-soft:var(--vip-content-accent-soft,#F7F0DE); --qr-border:var(--vip-border,#D8D2C7); margin:24px 40px; color:var(--qr-ink); font-family:"JetBrains Mono","Microsoft YaHei","PingFang SC",monospace; font-size:13px; }
.quote-review * { box-sizing:border-box; }
.quote-review__toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; }
.quote-review__back,.quote-review__link { color:var(--qr-gold); }
.quote-review__back { display:inline-flex; align-items:center; gap:5px; font-size:12px; }
.quote-review__actions { display:flex; flex-wrap:wrap; gap:8px; }
.quote-review__button { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:34px; border:1px solid var(--qr-border); border-radius:8px; background:#fff; padding:0 14px; color:var(--qr-ink); font:inherit; font-weight:500; cursor:pointer; }
.quote-review__button--primary { border-color:var(--qr-gold); background:var(--qr-gold); color:#fff; }
.quote-review__button--danger { border-color:#B80000; color:#8A1515; }
.quote-review__button .material-icons-outlined { font-size:18px; }
.quote-review__panel { margin-bottom:16px; overflow:hidden; border:1px solid var(--qr-border); border-radius:8px; background:#fff; }
.quote-review__header { padding:13px 16px; border-bottom:1px solid var(--qr-border); }
.quote-review__title { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; }
.quote-review__title .material-icons-outlined { color:var(--qr-gold); font-size:19px; }
.quote-review__body { padding:16px; }
.quote-review__meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
.quote-review__meta dt { color:var(--qr-muted); font-size:11px; }
.quote-review__meta dd { margin:4px 0 0; font-size:12.5px; font-variant-numeric:tabular-nums; }
.quote-review__summary { margin-top:16px; border-top:1px solid var(--qr-border); padding-top:14px; line-height:1.65; white-space:pre-wrap; }
.quote-review__alert { display:flex; align-items:flex-start; gap:8px; margin-bottom:16px; border:1px solid #D9BD88; border-radius:8px; background:#FFF8E8; padding:10px 12px; color:#704800; font-size:12px; line-height:1.6; }
.quote-review__alert .material-icons-outlined { flex:0 0 auto; font-size:18px; }
.quote-review__table-wrap { overflow-x:auto; }
.quote-review table { width:100%; border-collapse:collapse; font-size:12.5px; }
.quote-review th { padding:10px 12px; border-bottom:1px solid var(--qr-border); background:var(--qr-soft); font-size:12px; font-weight:600; text-align:left; white-space:nowrap; }
.quote-review td { padding:10px 12px; border-bottom:1px solid #ECE8E0; }
.quote-review tbody tr:last-child td { border-bottom:0; }
.quote-review__number { text-align:right!important; font-variant-numeric:tabular-nums; }
.quote-review__delta { color:#935F07; font-weight:600; }
.quote-review__timeline { display:grid; gap:0; }
.quote-review__event { position:relative; display:grid; grid-template-columns:130px 1fr; gap:16px; padding:0 0 18px 22px; }
.quote-review__event::before { position:absolute; top:4px; bottom:-4px; left:6px; width:1px; background:var(--qr-border); content:''; }
.quote-review__event::after { position:absolute; top:4px; left:2px; width:9px; height:9px; border:2px solid var(--qr-gold); border-radius:50%; background:#fff; content:''; }
.quote-review__event:last-child::before { display:none; }
.quote-review__event-time { color:var(--qr-muted); font-size:11px; }
.quote-review__event-title { font-weight:600; }
.quote-review__event-note { margin-top:3px; color:var(--qr-muted); font-size:12px; }
.quote-review__event-actor { margin-top:3px; color:var(--qr-muted); font-size:11px; }
.quote-review__review-form { display:flex; align-items:end; gap:8px; margin-top:12px; }
.quote-review__field { flex:1; display:grid; gap:6px; }
.quote-review__field label { font-size:12px; font-weight:500; }
.quote-review__control { width:100%; min-height:34px; border:1px solid var(--qr-border); border-radius:8px; padding:8px 10px; font:inherit; }
.quote-review__signature-meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
.quote-review__signature-meta dt { color:var(--qr-muted); font-size:11px; }
.quote-review__signature-meta dd { margin:4px 0 0; overflow-wrap:anywhere; font-size:12.5px; }
.quote-review__status { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
.quote-review__status::before { width:7px; height:7px; border-radius:50%; background:var(--qr-gold); content:''; }
.quote-review__files { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
.quote-review__form { margin-top:16px; border-top:1px solid var(--qr-border); padding-top:16px; }
.quote-review__form-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
.quote-review__form-actions { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:14px; }
.quote-review__choice { display:flex; align-items:center; gap:14px; min-height:34px; }
.quote-review__choice label { display:inline-flex; align-items:center; gap:5px; font-size:12px; }
.quote-review__hint { color:var(--qr-muted); font-size:11px; line-height:1.55; }
@media(max-width:1000px){.quote-review__form-grid{grid-template-columns:1fr 1fr}.quote-review__signature-meta{grid-template-columns:1fr 1fr}}
@media(max-width:800px){.quote-review{margin:16px}.quote-review__toolbar{align-items:stretch;flex-direction:column}.quote-review__meta,.quote-review__signature-meta,.quote-review__form-grid{grid-template-columns:1fr}.quote-review__event{grid-template-columns:1fr}.quote-review__review-form,.quote-review__form-actions{align-items:stretch;flex-direction:column}}
</style>
<main class="quote-review">
<div class="quote-review__toolbar"><a class="quote-review__back" href="{{ route('business.courier-quote-changes.index') }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价历史</span></a><div class="quote-review__actions">@if($change->isEditable() && $change->created_by===user_id())<a class="quote-review__button" href="{{ route('business.courier-quote-changes.edit',$change) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑草稿</span></a><form method="POST" action="{{ route('business.courier-quote-changes.submit',$change) }}">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>@endif @if($canReview && $change->status==='pending_review')<form method="POST" action="{{ route('business.courier-quote-changes.approve',$change) }}" onsubmit="return confirm('审核通过后将生成不可覆盖的新报价版本,确认继续吗?')">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>审核通过</span></button></form>@endif</div></div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
@if($errors->any())<div style="margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;padding:10px 12px;color:#8A1515" role="alert">{{ $errors->first() }}</div>@endif
@if($change->isRetrospective())<div class="quote-review__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><div><strong>追溯变价 · {{ $change->recalculationStatusLabel() }}</strong><br>自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}</div></div>@endif
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>生效日期</dt><dd>{{ $change->effective_date->toDateString() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $change->items->count() }} 项</dd></div><div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div><div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
<section class="quote-review__panel" aria-labelledby="review-diff-title"><header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧价格差异</span></div></header><div class="quote-review__table-wrap"><table><thead><tr><th>单元格</th><th>地区/规则</th><th>重量段/计费项</th><th class="quote-review__number">原价格</th><th class="quote-review__number">新价格</th><th class="quote-review__number">差额</th><th>备注</th></tr></thead><tbody>@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)<tr><td><strong>{{ $item->source_cell }}</strong></td><td>{{ $item->region ?: '—' }}</td><td>{{ $item->weight_band ?: ($item->charge_item ?: '—') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->original_amount,'0'),'.') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->new_amount,'0'),'.') }}</td><td class="quote-review__number quote-review__delta">{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}</td><td>{{ $item->note ?: '—' }}</td></tr>@endforeach</tbody></table></div></section>
@if($change->version)
@php($quoteSignature=$change->version->signature)
<section class="quote-review__panel" aria-labelledby="review-signature-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-signature-title"><span class="material-icons-outlined" aria-hidden="true">draw</span><span>E 签宝签署与归档</span></div></header>
<div class="quote-review__body">
@if(!$quoteSignature)
<div class="quote-review__hint">该历史报价版本尚未生成签署原文。</div>
@if($canManageSignature)<form method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}" style="margin-top:12px">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></form>@endif
@else
<dl class="quote-review__signature-meta">
<div><dt>签署状态</dt><dd><span class="quote-review__status">{{ $quoteSignature->statusLabel() }}</span></dd></div>
<div><dt>运行环境</dt><dd>{{ $esignEnvironment==='sandbox'?'沙箱环境':'正式环境' }}</dd></div>
<div><dt>原文校验值</dt><dd title="{{ $quoteSignature->source_pdf_sha256 }}">{{ substr($quoteSignature->source_pdf_sha256,0,16) }}…</dd></div>
<div><dt>最近同步</dt><dd>{{ $quoteSignature->last_synced_at?->format('Y-m-d H:i') ?? '尚未同步' }}</dd></div>
<div><dt>签署企业</dt><dd>{{ $quoteSignature->organization_name }}</dd></div>
<div><dt>经办人</dt><dd>{{ $quoteSignature->operator_name ?: '尚未填写' }}</dd></div>
<div><dt>签署流程</dt><dd>{{ $quoteSignature->esign_sign_flow_id ?: '尚未创建' }}</dd></div>
<div><dt>归档时间</dt><dd>{{ $quoteSignature->archived_at?->format('Y-m-d H:i') ?? '尚未归档' }}</dd></div>
</dl>
@if($quoteSignature->last_error)<div class="quote-review__alert" role="alert" style="margin-top:14px;margin-bottom:0"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><div><strong>最近一次处理失败</strong><br>{{ $quoteSignature->last_error }}</div></div>@endif
<div class="quote-review__files">
<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'source']) }}"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>签署原文</span></a>
@if($quoteSignature->signed_pdf_path)<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'signed']) }}"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>已签署报价</span></a>@endif
@if($quoteSignature->certificate_path)<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'certificate']) }}"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span><span>完成证书</span></a>@endif
@if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')<form method="POST" action="{{ route('business.courier-quote-signatures.synchronize',$quoteSignature) }}">@csrf<button class="quote-review__button" type="submit"><span class="material-icons-outlined" aria-hidden="true">sync</span><span>同步签署状态</span></button></form>@endif
</div>
@if($canManageSignature && $quoteSignature->canSend())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.send',$quoteSignature) }}">@csrf
<div class="quote-review__form-grid">
<div class="quote-review__field"><label for="organization_name">签署企业全称</label><input class="quote-review__control" id="organization_name" name="organization_name" value="{{ old('organization_name',$quoteSignature->organization_name) }}" maxlength="255" required></div>
<div class="quote-review__field"><label for="operator_name">经办人姓名</label><input class="quote-review__control" id="operator_name" name="operator_name" value="{{ old('operator_name',$quoteSignature->operator_name) }}" maxlength="100" autocomplete="name" required></div>
<div class="quote-review__field"><label for="operator_account">手机号或邮箱</label><input class="quote-review__control" id="operator_account" name="operator_account" value="{{ old('operator_account',$quoteSignature->operator_account) }}" maxlength="255" autocomplete="email" required></div>
<div class="quote-review__field"><label for="expires_at">签署截止时间</label><input class="quote-review__control" id="expires_at" name="expires_at" type="datetime-local" value="{{ old('expires_at',now()->addDays(7)->format('Y-m-d\TH:i')) }}" required></div>
</div>
<div class="quote-review__form-actions">
<div class="quote-review__choice" role="radiogroup" aria-label="通知方式"><label><input type="radio" name="notice_type" value="1" {{ old('notice_type','1')==='1'?'checked':'' }}>短信</label><label><input type="radio" name="notice_type" value="2" {{ old('notice_type')==='2'?'checked':'' }}>邮件</label></div>
<button class="quote-review__button quote-review__button--primary" type="submit" @disabled(!$esignConfigured)><span class="material-icons-outlined" aria-hidden="true">send</span><span>推送到 E 签宝</span></button>
</div>
@if(!$esignConfigured)<div class="quote-review__hint" role="status" style="margin-top:8px">E 签宝沙箱配置缺失,当前不能创建签署流程。</div>@endif
</form>
@endif
@endif
</div>
</section>
@endif
<section class="quote-review__panel" aria-labelledby="review-events-title"><header class="quote-review__header"><div class="quote-review__title" id="review-events-title"><span class="material-icons-outlined" aria-hidden="true">history</span><span>状态与审计记录</span></div></header><div class="quote-review__body"><div class="quote-review__timeline">@foreach($change->events as $event)<div class="quote-review__event"><time class="quote-review__event-time">{{ $event->created_at?->format('Y-m-d H:i') }}</time><div><div class="quote-review__event-title">{{ $event->eventLabel() }}</div><div class="quote-review__event-note">{{ $event->note }}</div><div class="quote-review__event-actor">操作人:{{ $event->actor?->name ?? ('用户 #' . $event->created_by) }}</div></div></div>@endforeach</div>
@if($canReview && $change->status==='pending_review')<form class="quote-review__review-form" method="POST" action="{{ route('business.courier-quote-changes.return',$change) }}">@csrf<div class="quote-review__field"><label for="review_note">退回原因</label><textarea class="quote-review__control" id="review_note" name="review_note" minlength="2" maxlength="1000" required placeholder="明确说明需要修改的价格、范围或依据"></textarea></div><button class="quote-review__button quote-review__button--danger" type="submit"><span class="material-icons-outlined" aria-hidden="true">undo</span><span>退回修改</span></button></form>@endif
</div></section>
</main>
</x-slot>
</x-layouts.admin>

View File

@ -423,16 +423,6 @@
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10);
}
.quote-change-modal__panel {
grid-template-rows: auto minmax(0, 1fr);
width: min(42rem, calc(100vw - 3rem));
height: auto;
max-height: min(42rem, calc(100vh - 3rem));
margin: auto 1.5rem auto auto;
border: 1px solid var(--quote-border);
border-radius: 12px;
}
.quote-modal__header {
display: flex;
min-width: 0;
@ -528,86 +518,6 @@
font-size: 0.7rem;
}
.quote-change-form {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
}
.quote-change-form__body {
display: grid;
gap: 1rem;
min-height: 0;
overflow-y: auto;
padding: 1.25rem;
}
.quote-change-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.quote-change-form__field {
display: grid;
gap: 0.4rem;
min-width: 0;
}
.quote-change-form__field--full {
grid-column: 1 / -1;
}
.quote-change-form__label {
color: var(--quote-muted);
font-size: 0.75rem;
font-weight: 500;
}
.quote-change-form__required {
color: #B80000;
}
.quote-change-form__textarea {
min-height: 8rem;
resize: vertical;
padding: 0.65rem 0.75rem;
line-height: 1.5;
}
.quote-change-form__notice {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--quote-border);
border-radius: 8px;
background: var(--quote-accent-soft);
color: var(--quote-accent);
font-size: 0.75rem;
line-height: 1.5;
}
.quote-change-form__notice .material-icons-outlined {
flex: 0 0 auto;
font-size: 18px;
}
.quote-change-form__status {
min-height: 1.25rem;
color: #53793E;
font-size: 0.75rem;
}
.quote-change-form__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
border-top: 1px solid var(--quote-border);
}
[hidden] {
display: none !important;
}
@ -700,28 +610,6 @@
width: 100%;
}
.quote-change-modal__panel {
width: calc(100% - 1rem);
max-height: calc(100vh - 1rem);
margin: auto 0.5rem;
}
.quote-change-form__grid {
grid-template-columns: 1fr;
}
.quote-change-form__field--full {
grid-column: auto;
}
.quote-change-form__footer {
flex-direction: column-reverse;
align-items: stretch;
}
.quote-change-form__footer .quote-button {
width: 100%;
}
}
</style>
@ -789,14 +677,14 @@
<button class="quote-icon-button" type="button" title="重置筛选" aria-label="重置筛选" data-quote-reset>
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
</button>
<button class="quote-button" type="button" disabled title="当前文件尚未建立变价历史数据库">
<a class="quote-button" href="{{ route('business.courier-quote-changes.index') }}" title="查看报价变更草稿、审核状态和新版本">
<span class="material-icons-outlined" aria-hidden="true">history</span>
<span>报价历史</span>
</button>
<button class="quote-button quote-button--primary" type="button" data-quote-change-open @if (!$quoteSnapshot['available']) disabled @endif title="打开报价变更草稿">
</a>
<a class="quote-button quote-button--primary" href="{{ route('business.courier-quote-changes.create') }}" title="创建并保存报价变更草稿">
<span class="material-icons-outlined" aria-hidden="true">edit_note</span>
<span>发起报价变更</span>
</button>
</a>
</div>
</div>
@ -886,60 +774,6 @@
</div>
</div>
<div class="quote-modal" role="dialog" aria-modal="true" aria-labelledby="quote-change-title" data-quote-change-modal hidden>
<div class="quote-modal__panel quote-change-modal__panel">
<header class="quote-modal__header">
<div class="quote-modal__title-wrap">
<div class="quote-modal__title" id="quote-change-title">发起报价变更</div>
<div class="quote-modal__subtitle">创建本机变更草稿 · 原始工作簿保持不变</div>
</div>
<button class="quote-modal__close" type="button" aria-label="关闭报价变更" title="关闭" data-quote-change-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<form class="quote-change-form" data-quote-change-form>
<div class="quote-change-form__body">
<div class="quote-change-form__notice">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>当前为本机只读报价源。提交后只生成浏览器会话内的草稿提示,不会修改金额、数据库或原始 Excel。</span>
</div>
<div class="quote-change-form__grid">
<label class="quote-change-form__field quote-change-form__field--full">
<span class="quote-change-form__label">报价版本 <span class="quote-change-form__required">*</span></span>
<select class="quote-control" data-quote-change-field="quote" required></select>
</label>
<label class="quote-change-form__field">
<span class="quote-change-form__label">变更类型 <span class="quote-change-form__required">*</span></span>
<select class="quote-control" data-quote-change-field="type" required>
<option value="费率调整">费率调整</option>
<option value="面单调整">面单调整</option>
<option value="规则备注">规则备注</option>
<option value="执行日期">执行日期</option>
<option value="其他">其他</option>
</select>
</label>
<label class="quote-change-form__field">
<span class="quote-change-form__label">拟执行日期 <span class="quote-change-form__required">*</span></span>
<input class="quote-control" type="date" data-quote-change-field="date" required>
</label>
<label class="quote-change-form__field quote-change-form__field--full">
<span class="quote-change-form__label">变更说明 <span class="quote-change-form__required">*</span></span>
<textarea class="quote-control quote-change-form__textarea" data-quote-change-field="note" required placeholder="请说明调整的区域、重量段、金额或规则依据"></textarea>
</label>
</div>
<div class="quote-change-form__status" role="status" data-quote-change-status></div>
</div>
<div class="quote-change-form__footer">
<button class="quote-button" type="button" data-quote-change-close>取消</button>
<button class="quote-button quote-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<span>生成变更草稿</span>
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var root = document.querySelector('[data-quote-management]');
@ -959,16 +793,7 @@
var tableWrap = root.querySelector('.quote-table-wrap');
var pagination = root.querySelector('[data-quote-pagination]');
var modal = root.querySelector('[data-quote-modal]');
var changeModal = root.querySelector('[data-quote-change-modal]');
var changeForm = root.querySelector('[data-quote-change-form]');
var changeFields = changeForm ? {
quote: changeForm.querySelector('[data-quote-change-field="quote"]'),
type: changeForm.querySelector('[data-quote-change-field="type"]'),
date: changeForm.querySelector('[data-quote-change-field="date"]'),
note: changeForm.querySelector('[data-quote-change-field="note"]')
} : null;
var lastFocusedElement = null;
var lastChangeFocusedElement = null;
var fields = {
keyword: root.querySelector('[data-quote-filter="keyword"]'),
carrier: root.querySelector('[data-quote-filter="carrier"]'),
@ -1075,9 +900,8 @@
action.dataset.quoteOpen = quote.id;
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
actionCell.appendChild(action);
var changeAction = create('button', 'quote-action', '变价');
changeAction.type = 'button';
changeAction.dataset.quoteChange = quote.id;
var changeAction = create('a', 'quote-action', '变价');
changeAction.href = @json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id);
changeAction.title = '发起 ' + quote.sheet_name + ' 的变更草稿';
changeAction.setAttribute('aria-label', '变价 ' + quote.sheet_name);
actionCell.appendChild(changeAction);
@ -1199,58 +1023,6 @@
}
}
function setChangeDate() {
var date = new Date();
var month = String(date.getMonth() + 1).padStart(2, '0');
var day = String(date.getDate()).padStart(2, '0');
changeFields.date.value = date.getFullYear() + '-' + month + '-' + day;
}
function populateChangeQuotes(selectedId) {
changeFields.quote.replaceChildren();
quotes.forEach(function (quote) {
var option = create('option', '', quote.sheet_name);
option.value = quote.id;
option.textContent = quote.sheet_name;
changeFields.quote.appendChild(option);
});
if (selectedId && quotes.some(function (quote) { return quote.id === selectedId; })) {
changeFields.quote.value = selectedId;
}
}
function openChange(quote, trigger) {
if (!changeModal || !changeForm) {
return;
}
lastChangeFocusedElement = trigger || root.querySelector('[data-quote-change-open]');
populateChangeQuotes(quote && quote.id);
changeFields.type.value = '费率调整';
changeFields.note.value = '';
setChangeDate();
root.querySelector('[data-quote-change-status]').textContent = '';
changeModal.hidden = false;
changeModal.classList.add('is-open');
document.body.style.overflow = 'hidden';
changeFields.quote.focus();
}
function closeChange() {
if (!changeModal) {
return;
}
changeModal.classList.remove('is-open');
changeModal.hidden = true;
document.body.style.overflow = '';
if (lastChangeFocusedElement) {
lastChangeFocusedElement.focus();
}
}
Object.keys(fields).forEach(function (key) {
fields[key].addEventListener(key === 'keyword' ? 'input' : 'change', function () {
filterQuotes(true);
@ -1263,14 +1035,6 @@
fields.keyword.focus();
});
var changeOpenButton = root.querySelector('[data-quote-change-open]');
if (changeOpenButton) {
changeOpenButton.addEventListener('click', function () {
openChange(filteredQuotes[0] || quotes[0], changeOpenButton);
});
}
root.querySelector('[data-quote-page="previous"]').addEventListener('click', function () {
currentPage = Math.max(currentPage - 1, 1);
render();
@ -1283,18 +1047,6 @@
body.addEventListener('click', function (event) {
var trigger = event.target.closest('[data-quote-open]');
var changeTrigger = event.target.closest('[data-quote-change]');
if (changeTrigger) {
var changeQuote = quotes.find(function (item) { return item.id === changeTrigger.dataset.quoteChange; });
if (changeQuote) {
openChange(changeQuote, changeTrigger);
}
return;
}
if (!trigger) {
return;
}
@ -1317,29 +1069,8 @@
closeDetail();
}
if (event.key === 'Escape' && changeModal && changeModal.classList.contains('is-open')) {
closeChange();
}
});
if (changeModal && changeForm) {
changeModal.querySelectorAll('[data-quote-change-close]').forEach(function (button) {
button.addEventListener('click', closeChange);
});
changeModal.addEventListener('click', function (event) {
if (event.target === changeModal) {
closeChange();
}
});
changeForm.addEventListener('submit', function (event) {
event.preventDefault();
var quote = quotes.find(function (item) { return item.id === changeFields.quote.value; });
var status = root.querySelector('[data-quote-change-status]');
status.textContent = '已生成“' + (quote ? quote.sheet_name : '当前报价') + '”的本机变更草稿;原始工作簿未修改。';
});
}
restoreUrlFilters();
filterQuotes(false);
}());

View File

@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Quote {{ $version->version_code }}</title>
<style>
@font-face { font-family: "Droid Sans Fallback"; font-style: normal; font-weight: 400; src: url("file://{{ $fontPath }}") format("truetype"); }
@page { margin: 18mm 14mm 16mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #171717; font-family: "Droid Sans Fallback", sans-serif; font-size: 9px; line-height: 1.45; }
.latin { font-family: "DejaVu Sans", sans-serif; }
h1, h2, p, dl, dd { margin: 0; }
h1 { font-size: 20px; font-weight: 400; }
h2 { margin: 16px 0 7px; border-bottom: 1px solid #9b7a3f; padding-bottom: 4px; color: #6f5426; font-size: 12px; font-weight: 400; }
.heading { display: table; width: 100%; border-bottom: 2px solid #171717; padding-bottom: 10px; }
.heading__title, .heading__mark { display: table-cell; vertical-align: bottom; }
.heading__mark { color: #6b6b6b; text-align: right; }
.meta { display: table; width: 100%; margin-top: 12px; table-layout: fixed; }
.meta__item { display: table-cell; border-left: 2px solid #b69455; padding: 2px 9px; }
.meta dt { color: #666; font-size: 8px; }
.meta dd { margin-top: 2px; font-size: 10px; }
.notice { margin-top: 12px; border: 1px solid #c9b27f; background: #faf6ea; padding: 7px 9px; color: #5c451e; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { overflow-wrap: anywhere; border: 1px solid #cfcfcf; padding: 4px 5px; text-align: left; vertical-align: top; }
th { background: #eee8db; font-weight: 400; }
.number { text-align: right; }
.matrix { font-size: 7.5px; table-layout: auto; }
.matrix th, .matrix td { max-width: 90px; padding: 3px 4px; }
.formula { color: #735c31; font-size: 7px; }
.summary { white-space: pre-wrap; }
.signature { page-break-before: always; }
.signature h2 { margin-top: 0; }
.signature__meta { margin-bottom: 18px; color: #555; }
.signature__box { width: 250px; height: 82px; margin-left: auto; border: 1px solid #777; padding: 8px; color: #666; }
.document-control { margin-top: 22px; border-top: 1px solid #bbb; padding-top: 6px; color: #777; font-size: 7.5px; text-align: right; }
</style>
</head>
<body>
@php
$mixedText = static function ($value): string {
$parts = preg_split('/([\x20-\x7E]+)/', (string) $value, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
return implode('', array_map(static function (string $part): string {
$escaped = e($part);
return preg_match('/^[\x20-\x7E]+$/', $part) === 1
? '<span class="latin">' . $escaped . '</span>'
: $escaped;
}, $parts));
};
@endphp
<header class="heading">
<div class="heading__title">
<h1>{!! $mixedText($version->company_name) !!} 报价单</h1>
<p>{!! $mixedText($version->quote_type) !!}</p>
</div>
<div class="heading__mark">不可覆盖版本:{!! $mixedText($version->version_code) !!}</div>
</header>
<dl class="meta">
<div class="meta__item"><dt>快递公司</dt><dd>{!! $mixedText($version->carrier_name) !!}</dd></div>
<div class="meta__item"><dt>报价版本</dt><dd>{!! $mixedText($version->version_code) !!}</dd></div>
<div class="meta__item"><dt>执行日期</dt><dd>{!! $mixedText($version->effective_date?->toDateString()) !!}</dd></div>
<div class="meta__item"><dt>审核日期</dt><dd>{!! $mixedText($change?->approved_at?->format('Y-m-d H:i') ?? $version->created_at?->format('Y-m-d H:i')) !!}</dd></div>
</dl>
<div class="notice">本文件由财务系统根据已审核报价版本自动生成。签署后文件与完成证书将按本版本单独归档;签署状态不代表历史运费补差已经完成。</div>
@if($change)
<h2>变价说明</h2>
<p class="summary">{!! $mixedText($change->summary) !!}</p>
<h2>新旧价格差异</h2>
<table>
<thead><tr><th>单元格</th><th>地区/规则</th><th>重量段/计费项</th><th class="number">原价格</th><th class="number">新价格</th><th>单位</th><th>备注</th></tr></thead>
<tbody>
@foreach($change->items as $item)
<tr>
<td>{!! $mixedText($item->source_cell) !!}</td>
<td>{!! $mixedText($item->region ?: '—') !!}</td>
<td>{!! $mixedText($item->weight_band ?: ($item->charge_item ?: '—')) !!}</td>
<td class="number">{!! $mixedText(rtrim(rtrim($item->original_amount, '0'), '.')) !!}</td>
<td class="number">{!! $mixedText(rtrim(rtrim($item->new_amount, '0'), '.')) !!}</td>
<td>{!! $mixedText($item->unit ?: '—') !!}</td>
<td>{!! $mixedText($item->note ?: '—') !!}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
<h2>完整报价规则</h2>
<table class="matrix">
<tbody>
@forelse($values as $rowIndex => $row)
<tr>
@foreach($row as $columnIndex => $cell)
@php($formula = $formulas[$rowIndex][$columnIndex] ?? null)
<{{ $rowIndex === 0 ? 'th' : 'td' }}>
{!! $mixedText(is_bool($cell) ? ($cell ? '是' : '否') : ($cell ?? '')) !!}
@if(is_string($formula) && trim($formula) !== '')<div class="formula">公式:{!! $mixedText($formula) !!}</div>@endif
</{{ $rowIndex === 0 ? 'th' : 'td' }}>
@endforeach
</tr>
@empty
<tr><td>该版本未保存可展示的报价矩阵。</td></tr>
@endforelse
</tbody>
</table>
<section class="signature">
<h2>签署确认</h2>
<div class="signature__meta">报价版本:{!! $mixedText($version->version_code) !!} 快递公司:{!! $mixedText($version->carrier_name) !!} 执行日期:{!! $mixedText($version->effective_date?->toDateString()) !!}</div>
<div class="signature__box">快递公司签章<br><br>签署日期:</div>
<div class="document-control">生成时间:{!! $mixedText($generatedAt->format('Y-m-d H:i:s')) !!} · 版本 {!! $mixedText($version->version_code) !!}</div>
</section>
</body>
</html>

View File

@ -1,5 +1,7 @@
<?php
use App\Http\Controllers\Business\CourierQuoteChanges;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
@ -61,6 +63,31 @@ Route::prefix('workspace-data/supplier-payables')
* @see \modules\OfflinePayments\Routes\admin.php for module example
*/
Route::prefix('workspace/courier-payables/quote-management/changes')
->name('business.courier-quote-changes.')
->middleware('business.page:courier-payables,quote-management')
->group(function () {
Route::get('/', [CourierQuoteChanges::class, 'index'])->name('index');
Route::get('create', [CourierQuoteChanges::class, 'create'])->name('create');
Route::post('/', [CourierQuoteChanges::class, 'store'])->name('store');
Route::get('{quoteChange}', [CourierQuoteChanges::class, 'show'])->name('show');
Route::get('{quoteChange}/edit', [CourierQuoteChanges::class, 'edit'])->name('edit');
Route::patch('{quoteChange}', [CourierQuoteChanges::class, 'update'])->name('update');
Route::post('{quoteChange}/submit', [CourierQuoteChanges::class, 'submit'])->name('submit');
Route::post('{quoteChange}/approve', [CourierQuoteChanges::class, 'approve'])->name('approve');
Route::post('{quoteChange}/return', [CourierQuoteChanges::class, 'returnForChanges'])->name('return');
});
Route::prefix('workspace/courier-payables/quote-management/signatures')
->name('business.courier-quote-signatures.')
->middleware('business.page:courier-payables,quote-management')
->group(function () {
Route::post('versions/{quoteVersion}/prepare', [CourierQuoteSignatures::class, 'prepare'])->name('prepare');
Route::post('{quoteSignature}/send', [CourierQuoteSignatures::class, 'send'])->name('send');
Route::post('{quoteSignature}/synchronize', [CourierQuoteSignatures::class, 'synchronize'])->name('synchronize');
Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download');
});
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Integrations\EsignCallbacks;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
@ -9,6 +10,10 @@ use Livewire\Livewire;
* @see \App\Providers\Route::mapWebRoutes
*/
Route::post('integrations/esign/callback', EsignCallbacks::class)
->middleware('throttle:120,1')
->name('integrations.esign.callback');
Livewire::setScriptRoute(function ($handle) {
$base = request()->getBasePath();

View File

@ -0,0 +1,305 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\QuoteManagementSnapshot;
use Tests\Feature\FeatureTestCase;
class CourierQuoteChangeWorkflowTest extends FeatureTestCase
{
private string $fixturePath;
protected function setUp(): void
{
parent::setUp();
$this->fixturePath = storage_path('framework/testing/courier-quote-change-' . uniqid('', true) . '.json');
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:D3',
'values' => [
['省份', '面单', '续重', '备注'],
['广东省', 5.0, 1.2, null],
['江苏省', 4.0, 1.0, null],
],
'formulas' => [[], [], []],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath));
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
protected function tearDown(): void
{
if (isset($this->fixturePath) && is_file($this->fixturePath)) {
unlink($this->fixturePath);
}
parent::tearDown();
}
public function test_user_can_save_real_quote_change_draft_and_open_history(): void
{
$response = $this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::with(['items', 'events'])->firstOrFail();
$response->assertRedirect(route('business.courier-quote-changes.edit', $change));
$this->assertSame('BG-' . now()->format('Ymd') . '-001', $change->number);
$this->assertSame(CourierQuoteChange::STATUS_DRAFT, $change->status);
$this->assertFalse($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $change->recalculation_status);
$this->assertSame('新北中通', $change->carrier_name);
$this->assertCount(1, $change->items);
$this->assertSame('5.0000', $change->items->first()->original_amount);
$this->assertSame('6.0000', $change->items->first()->new_amount);
$this->assertSame('created', $change->events->first()->event_type);
$this->loginAs()->get(route('business.courier-quote-changes.index'))
->assertOk()
->assertSeeText($change->number)
->assertSeeText('新北中通')
->assertSeeText('草稿');
}
public function test_create_page_shows_structured_price_editor_and_excludes_formula_cells(): void
{
$this->loginAs()->get(route('business.courier-quote-changes.create', ['source_quote_id' => 'QB-001']))
->assertOk()
->assertSeeText('第一步:选择原报价')
->assertSeeText('第三步:编辑新价格')
->assertSeeText('批量方式')
->assertSeeText('原价格')
->assertSeeText('新价格')
->assertSee('data-price-row', false)
->assertSee('name="items[0][new_amount]"', false)
->assertSee('data-effective-date', false)
->assertDontSee('type="date" min=', false)
->assertSeeText('生效日期可以早于今天')
->assertSeeText('保存草稿');
}
public function test_historical_effective_date_can_be_saved_and_submitted_for_retrospective_recalculation(): void
{
$payload = $this->validPayload();
$payload['effective_date'] = now()->subMonths(2)->startOfMonth()->toDateString();
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->assertSame($payload['effective_date'], $change->effective_date->toDateString());
$this->assertTrue($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_PENDING, $change->recalculation_status);
$this->assertNull($change->recalculation_note);
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status);
$this->assertTrue($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_PENDING, $change->recalculation_status);
$this->loginAs()->get(route('business.courier-quote-changes.show', $change))
->assertOk()
->assertSeeText('追溯变价 · 待追溯核算')
->assertSeeText('原运费和原始 Excel 保持不变');
}
public function test_invalid_or_unchanged_amount_is_rejected_without_saving_partial_data(): void
{
$payload = $this->validPayload();
$payload['items'][0]['new_amount'] = '5.0000';
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertSessionHasErrors('items');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
$this->assertDatabaseCount('business_courier_quote_change_items', 0);
$payload = $this->validPayload();
$payload['items'][0]['original_amount'] = '4.5000';
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertSessionHasErrors('items.0.original_amount');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
public function test_formula_cell_cannot_be_changed_directly(): void
{
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:B2',
'values' => [['省份', '面单'], ['广东省', 5.0]],
'formulas' => [[], ['', '=2+3']],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->withExceptionHandling()->loginAs()
->post(route('business.courier-quote-changes.store'), $this->validPayload())
->assertSessionHasErrors('items.0.source_cell');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
public function test_submit_locks_draft_and_creator_cannot_approve_own_change(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status);
$this->withExceptionHandling()->loginAs()->get(route('business.courier-quote-changes.edit', $change))->assertForbidden();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.approve', $change))
->assertSessionHasErrors('approval');
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_different_admin_can_approve_and_generate_immutable_new_version(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change));
$reviewer = $this->createReviewer();
$this->loginAs($reviewer, $this->company)
->post(route('business.courier-quote-changes.approve', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$version = CourierQuoteVersion::firstOrFail();
$values = json_decode($version->values_json, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame('QB-002', $version->version_code);
$this->assertSame('6.0000', $values[1][1]);
$this->assertEquals(5.0, app(QuoteManagementSnapshot::class)->load()['quotes'][0]['values'][1][1]);
$this->assertSame($reviewer->id, $change->approved_by);
$this->assertSame('QB-002', app(QuoteManagementSnapshot::class)->load()['quotes'][1]['id']);
}
public function test_approving_historical_change_blocks_automatic_compensation_and_records_reason(): void
{
$payload = $this->validPayload();
$payload['effective_date'] = now()->subMonths(2)->startOfMonth()->toDateString();
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload);
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change));
$this->loginAs($this->createReviewer(), $this->company)
->post(route('business.courier-quote-changes.approve', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change))
->assertSessionHas('success', function (string $message): bool {
return str_contains($message, '历史生效已登记') && str_contains($message, '补差核算尚未执行');
});
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame(CourierQuoteChange::RECALCULATION_BLOCKED, $change->recalculation_status);
$this->assertStringContainsString('当前报价未提供可执行的运费计算方式', $change->recalculation_note);
$this->assertStringContainsString('历史运费也未记录所用报价版本', $change->recalculation_note);
$this->assertDatabaseCount('business_courier_quote_versions', 1);
$approvalEvent = $change->events()->where('event_type', 'approved')->firstOrFail();
$this->assertStringContainsString('补差明细尚未生成', $approvalEvent->note);
$this->assertStringContainsString('原运费保持不变', $approvalEvent->note);
$this->assertSame(
CourierQuoteChange::RECALCULATION_BLOCKED,
json_decode($approvalEvent->details_json, true, 512, JSON_THROW_ON_ERROR)['recalculation_status']
);
$this->loginAs()->get(route('business.courier-quote-changes.show', $change))
->assertOk()
->assertSeeText('追溯变价 · 核算受阻')
->assertSeeText('未生成补差明细')
->assertSeeText('原运费保持不变');
$this->loginAs()->get(route('business.courier-quote-changes.index'))
->assertOk()
->assertSeeText('追溯变价')
->assertSeeText('核算受阻');
}
public function test_other_company_cannot_open_or_modify_quote_change(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
\DB::table('business_courier_quote_changes')->where('id', $change->id)->update([
'company_id' => $this->company->id + 999,
]);
$response = $this->withExceptionHandling()->loginAs()
->get(route('business.courier-quote-changes.show', $change->id));
$response->assertRedirect(route('business.courier-quote-changes.index'));
$this->assertNull(CourierQuoteChange::find($change->id));
}
public function test_user_without_quote_page_permission_cannot_use_workflow_routes(): void
{
$subject = 'user_' . $this->user->id;
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [$subject => ['courier-payables/freight-reconciliation']],
], JSON_UNESCAPED_UNICODE));
$this->withExceptionHandling()->loginAs()->get(route('business.courier-quote-changes.index'))->assertForbidden();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload())->assertForbidden();
}
private function validPayload(): array
{
return [
'source_quote_id' => 'QB-001',
'change_type' => '面单调整',
'effective_date' => now()->addDay()->toDateString(),
'summary' => '广东省面单每单增加1元。',
'items' => [[
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]],
];
}
private function createReviewer()
{
$reviewer = user_model_class()::create([
'name' => 'Quote Reviewer',
'email' => 'quote-reviewer-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$reviewer->companies()->attach($this->company->id);
$reviewer->roles()->attach(Role::where('name', 'admin')->firstOrFail()->id);
$reviewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
return $reviewer;
}
}

View File

@ -0,0 +1,399 @@
<?php
namespace Tests\Feature\Business;
use App\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Models\Auth\Permission;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\FeatureTestCase;
class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
config([
'services.esign.environment' => 'sandbox',
'services.esign.base_url' => 'https://smlopenapi.esign.test',
'services.esign.app_id' => 'sandbox-app-id',
'services.esign.app_secret' => 'sandbox-app-secret',
'services.esign.notify_url' => null,
]);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
public function test_approved_version_generates_an_immutable_private_pdf(): void
{
$version = $this->createVersion();
$signature = app(CourierQuoteSignatureService::class)->prepareVersion($version, $this->user->id);
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$this->assertStringStartsWith('%PDF-', $bytes);
$this->assertSame(hash('sha256', $bytes), $signature->source_pdf_sha256);
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->status);
$this->assertSame('待推送', $version->fresh()->signature_status);
$again = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id);
$this->assertSame($signature->id, $again->id);
$this->assertSame($signature->source_pdf_sha256, $again->source_pdf_sha256);
}
public function test_admin_can_push_the_exact_pdf_to_esign_sandbox(): void
{
$signature = $this->createPreparedSignature();
Http::fake(function (ClientRequest $request) {
return match (true) {
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([
'code' => 0,
'data' => ['fileId' => 'file-001', 'fileUploadUrl' => 'https://upload.esign.test/file-001'],
]),
$request->url() === 'https://upload.esign.test/file-001' => Http::response(['errCode' => 0]),
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-001' => Http::response([
'code' => 0,
'data' => ['status' => 2],
]),
$request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([
'code' => 0,
'data' => ['signFlowId' => 'flow-001'],
]),
default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404),
};
});
$this->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('file-001', $signature->esign_file_id);
$this->assertSame('flow-001', $signature->esign_sign_flow_id);
$this->assertSame('待签署', $signature->version->fresh()->signature_status);
Http::assertSent(function (ClientRequest $request) use ($signature): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/files/file-upload-url') {
return false;
}
$payload = json_decode($request->body(), true);
$pdfBytes = Storage::disk('uploads')->get($signature->source_pdf_path);
return $request->hasHeader('X-Tsign-Open-Ca-Signature')
&& $payload['contentMd5'] === base64_encode(md5($pdfBytes, true))
&& $payload['fileName'] === 'quote-QB-900.pdf';
});
Http::assertSent(function (ClientRequest $request): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') {
return false;
}
$payload = json_decode($request->body(), true);
return data_get($payload, 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司'
&& data_get($payload, 'signers.0.normalSignFieldConfig') === null
&& data_get($payload, 'signers.0.signFields.0.normalSignFieldConfig.freeMode') === true;
});
}
public function test_tampered_source_pdf_is_not_sent(): void
{
$signature = $this->createPreparedSignature();
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered');
Http::fake();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertSessionHasErrors('signature');
Http::assertNothingSent();
$this->assertNull($signature->fresh()->esign_sign_flow_id);
}
public function test_source_pdf_cannot_be_regenerated_after_signing_has_started(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-immutable',
'esign_sign_flow_id' => 'flow-immutable',
'remote_status' => '1',
'sent_at' => now(),
]);
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered-after-send');
try {
app(CourierQuoteSignatureService::class)->prepareVersion($signature->version, $this->user->id);
$this->fail('A sent quote must not regenerate its immutable source PDF.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertSame(
'该报价已经进入签署流程,签署原文不可重新生成。',
$exception->errors()['signature'][0],
);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('flow-immutable', $signature->esign_sign_flow_id);
}
public function test_non_admin_cannot_push_a_quote_to_esign(): void
{
$signature = $this->createPreparedSignature();
$operator = user_model_class()::create([
'name' => 'Quote Operator',
'email' => 'quote-operator-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$operator->companies()->attach($this->company->id);
$operator->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
Http::fake();
$this->withExceptionHandling()->loginAs($operator, $this->company)
->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])
->assertForbidden();
Http::assertNothingSent();
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->fresh()->status);
}
public function test_manual_sync_downloads_signed_pdf_and_completion_certificate(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-001',
'esign_sign_flow_id' => 'flow-001',
'remote_status' => '1',
'sent_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/detail' => Http::response([
'code' => 0,
'data' => ['signFlowStatus' => 2],
]),
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/file-download-url' => Http::response([
'code' => 0,
'data' => [
'files' => [['fileId' => 'file-001', 'fileName' => 'signed.pdf', 'downloadUrl' => 'https://download.esign.test/signed']],
'certificateDownloadUrl' => 'https://download.esign.test/certificate',
],
]),
'https://download.esign.test/signed' => Http::response('%PDF-signed-file'),
'https://download.esign.test/certificate' => Http::response('%PDF-completion-certificate'),
]);
$this->loginAs()->post(route('business.courier-quote-signatures.synchronize', $signature))
->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $signature->status);
$this->assertSame('已签署并归档', $signature->version->fresh()->signature_status);
Storage::disk('uploads')->assertExists($signature->signed_pdf_path);
Storage::disk('uploads')->assertExists($signature->certificate_path);
$this->assertSame(hash('sha256', '%PDF-signed-file'), $signature->signed_pdf_sha256);
$this->assertSame(hash('sha256', '%PDF-completion-certificate'), $signature->certificate_sha256);
}
public function test_archive_job_propagates_failures_so_the_queue_can_retry(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_COMPLETED,
'esign_file_id' => 'file-retry',
'esign_sign_flow_id' => 'flow-retry',
'remote_status' => '2',
'completed_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-retry/detail' => Http::response([], 503),
]);
try {
(new ArchiveCourierQuoteSignature($signature->id))->handle(app(CourierQuoteSignatureService::class));
$this->fail('The archive job must fail so the queue can retry it.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertStringContainsString('HTTP 503', $exception->errors()['signature'][0]);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertStringContainsString('HTTP 503', $signature->last_error);
$this->assertNull($signature->archived_at);
}
public function test_verified_completion_callback_is_idempotent_and_queues_archiving(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-callback',
'remote_status' => '1',
'sent_at' => now(),
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-callback',
'signFlowStatus' => '2',
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = (string) now()->getTimestampMs();
$callbackSignature = hash_hmac('sha256', $timestamp . '12' . $payload, 'sandbox-app-secret');
$server = [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => $timestamp,
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => $callbackSignature,
'CONTENT_TYPE' => 'application/json',
];
$this->call(
'POST',
route('integrations.esign.callback') . '?zeta=2&alpha=1',
[],
[],
[],
$server,
$payload,
)->assertOk()->assertExactJson(['code' => '200', 'msg' => 'success']);
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertSame(hash('sha256', $payload), $signature->callback_payload_hash);
Queue::assertPushed(ArchiveCourierQuoteSignature::class, fn ($job): bool => $job->signatureId === $signature->id);
$this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback(
'flow-callback',
'2',
hash('sha256', $payload),
));
Queue::assertPushed(ArchiveCourierQuoteSignature::class, 1);
}
public function test_callback_with_wrong_signature_is_rejected_without_state_change(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-invalid',
'remote_status' => '1',
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-invalid',
'signFlowStatus' => '2',
], JSON_THROW_ON_ERROR);
$this->call('POST', route('integrations.esign.callback'), [], [], [], [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => (string) now()->getTimestampMs(),
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => str_repeat('0', 64),
'CONTENT_TYPE' => 'application/json',
], $payload)
->assertUnauthorized();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->fresh()->status);
Queue::assertNothingPushed();
}
private function createPreparedSignature(array $overrides = []): CourierQuoteSignature
{
$version = $this->createVersion();
$path = "courier-quotes/{$this->company->id}/QB-900/source.pdf";
$bytes = '%PDF-source-quote';
Storage::disk('uploads')->put($path, $bytes);
return CourierQuoteSignature::create(array_merge([
'company_id' => $this->company->id,
'quote_version_id' => $version->id,
'provider' => 'esign',
'environment' => 'sandbox',
'status' => CourierQuoteSignature::STATUS_PREPARED,
'organization_name' => '新北中通',
'source_pdf_path' => $path,
'source_pdf_sha256' => hash('sha256', $bytes),
'source_pdf_generated_at' => now(),
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
], $overrides));
}
private function createVersion(): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-' . now()->format('Ymd') . '-' . str_pad((string) (CourierQuoteChange::count() + 1), 3, '0', STR_PAD_LEFT),
'source_quote_id' => 'QB-001',
'source_sheet_name' => '新北中通报价',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => now()->addDay()->toDateString(),
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '广东省面单价格调整。',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
$change->items()->create([
'company_id' => $this->company->id,
'position' => 0,
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => 'QB-900',
'source_quote_id' => 'QB-001',
'source_change_id' => $change->id,
'sheet_name' => '新北中通报价',
'company_name' => '新北中通',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'effective_date' => now()->addDay()->toDateString(),
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '续重'], ['广东省', 6.0, 1.2]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[], []], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}

View File

@ -6,6 +6,7 @@ use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
@ -15,10 +16,20 @@ use Tests\Feature\FeatureTestCase;
class MarketingAccountBillsTabsTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$mirror = Mockery::mock(MarketingAccountBillMirrorRepository::class);
$mirror->shouldReceive('ready')->byDefault()->andReturnFalse();
$this->app->instance(MarketingAccountBillMirrorRepository::class, $mirror);
}
public function testMarketingAccountBalanceRedirectsToTaobaoAndShowsRestoredControls(): void
{
$this->allowMarketingAccountBalancePage();
$repository = Mockery::mock(PinduoduoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse();
$this->app->instance(PinduoduoSpreadBillRepository::class, $repository);
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('remember')->twice()->andReturnUsing(
@ -141,6 +152,10 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->app->instance(JdWalletBillRepository::class, Mockery::mock(JdWalletBillRepository::class));
$this->app->make(JdWalletBillRepository::class)
->shouldReceive('usesBusinessMirror')->once()->with('recharge')->andReturnFalse();
$this->app->make(JdWalletBillRepository::class)
->shouldReceive('usesBusinessMirrorForOptions')->once()->andReturnFalse();
$this->loginAs()
->get(route('business.pages.show', [
@ -177,7 +192,9 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->app->instance(TmallTaobaoSpreadBillRepository::class, Mockery::mock(TmallTaobaoSpreadBillRepository::class));
$repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse();
$this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.export', [
@ -214,7 +231,9 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
$factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->app->instance(TmallTaobaoSpreadBillRepository::class, Mockery::mock(TmallTaobaoSpreadBillRepository::class));
$repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse();
$this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.show', [
@ -239,7 +258,9 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
$factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->app->instance(TmallTaobaoSpreadBillRepository::class, Mockery::mock(TmallTaobaoSpreadBillRepository::class));
$repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse();
$this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.export', [
@ -255,6 +276,88 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
->assertSessionHas('error', '余额数据导出暂时无法完成,请稍后重试。');
}
public function testMarketingBillSecondPageReadsReadyMirrorWithoutRegisteringAColdSnapshot(): void
{
$this->allowMarketingAccountBillsPage();
$repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginate')
->once()
->with(null, null, null, null, 2, 25)
->andReturn(['rows' => [['store_name' => '镜像店铺']], 'total' => 26]);
$repository->shouldReceive('summary')->once()->andReturn([
'income_total' => 0,
'expense_total' => 0,
'balance' => 0,
'count' => 26,
]);
$repository->shouldReceive('storeOptions')->once()->andReturn(['镜像店铺']);
$this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository);
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldNotReceive('remember');
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs();
$url = route('business.pages.show', [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'taobao-alipay',
]) . '?page=2';
$this->get($url)
->assertOk()
->assertSeeText('镜像店铺')
->assertDontSeeText('首份共享快照正在准备');
}
public function testMarketingBalanceSecondPageReadsReadyMirrorWithoutRegisteringAColdSnapshot(): void
{
$this->allowMarketingAccountBalancePage();
$repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyBalances')
->once()
->with(null, null, 2, 25)
->andReturn(['rows' => [[
'date' => '2026-08',
'store_name' => '镜像余额店铺',
'opening_balance' => 100,
'income_total' => 20,
'expense_total' => 5,
'closing_balance' => 115,
'system_closing_balance' => 115,
'balance_difference' => 0,
'difference_reason' => '',
'verification_status' => '一致',
'notes' => '',
]], 'total' => 26]);
$repository->shouldReceive('storeOptions')->once()->andReturn(['镜像余额店铺']);
$this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository);
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldNotReceive('remember');
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs();
$url = route('business.pages.show', [
'section' => 'billing-transactions',
'page' => 'marketing-account-balance',
'tab' => 'taobao-alipay',
]) . '?page=2';
$this->get($url)
->assertOk()
->assertSeeText('镜像余额店铺')
->assertDontSeeText('后台正在准备首份共享数据');
}
private function allowMarketingAccountBillsPage(): void
{
$this->user->update(['locale' => 'zh-CN']);

View File

@ -0,0 +1,128 @@
<?php
namespace Tests\Feature\Business;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Tests\TestCase;
class ProductPriceMirrorRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => true,
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$schema = DB::connection('business_mirror')->getSchemaBuilder();
$schema->create('business_mirror_sync_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('source_key')->nullable();
$table->string('source_object')->nullable();
$table->string('status')->nullable();
$table->string('run_mode')->nullable();
$table->string('window_started_at')->nullable();
$table->string('window_ends_at')->nullable();
$table->string('cursor_completed_at')->nullable();
$table->string('cursor_business_id')->nullable();
$table->string('cursor_product_code')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->string('last_error_code')->nullable();
$table->timestamps();
});
$schema->create('product_price_mirror_rows', function (Blueprint $table): void {
$table->id();
$table->string('upstream_key')->unique();
$table->string('barcode');
$table->string('spu')->nullable();
$table->text('product_name')->nullable();
$table->string('category')->nullable();
$table->string('production_status')->nullable();
$table->decimal('minimum_price')->nullable();
$table->decimal('price_limit')->nullable();
$table->decimal('purchase_price')->nullable();
$table->decimal('shipping_fee')->nullable();
$table->decimal('jingxi_limit_price')->nullable();
$table->string('approval_status')->nullable();
$table->string('approval_result')->nullable();
$table->text('workflow_title')->nullable();
$table->dateTime('completed_at');
$table->string('business_id');
$table->string('row_hash');
$table->dateTime('synced_at');
$table->timestamps();
});
}
public function test_page_two_and_literal_wildcard_filter_are_read_from_shared_mirror(): void
{
DB::connection('business_mirror')->table('business_mirror_sync_states')->insert([
'dataset_key' => ProductPriceMirrorRepository::DATASET_KEY,
'initial_completed_at' => '2026-08-14 10:00:00',
]);
foreach (range(1, 30) as $index) {
DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([
'barcode' => $index === 30 ? 'SKU%_LITERAL' : sprintf('SKU%03d', $index),
'business_id' => sprintf('B%03d', $index),
'completed_at' => sprintf('2026-08-%02d 10:00:00', min($index, 28)),
'upstream_key' => hash('sha256', (string) $index),
'row_hash' => hash('sha256', 'row-' . $index),
'synced_at' => '2026-08-14 10:00:00',
]);
}
$repository = app(ProductPriceMirrorRepository::class);
$pageTwo = $repository->paginate([], 2, 25);
$literal = $repository->paginate(['barcode' => '%_'], 1, 25);
$this->assertTrue($repository->initialized());
$this->assertSame(30, $pageTwo->total());
$this->assertCount(5, $pageTwo->items());
$this->assertSame(1, $literal->total());
$this->assertSame('SKU%_LITERAL', $literal->items()[0]->barcode);
}
public function test_same_business_and_barcode_at_different_completion_times_are_preserved(): void
{
DB::connection('business_mirror')->table('business_mirror_sync_states')->insert([
'dataset_key' => ProductPriceMirrorRepository::DATASET_KEY,
'initial_completed_at' => '2026-08-14 10:00:00',
]);
$repository = app(ProductPriceMirrorRepository::class);
$rows = [[
'business_id' => 'B100',
'barcode' => 'SKU100',
'cursor_completed_at' => '2026-08-14T10:00:00.1234567',
'completed_at' => '2026-08-14 10:00:00.123456',
], [
'business_id' => 'B100',
'barcode' => 'SKU100',
'cursor_completed_at' => '2026-08-14T11:00:00.1234567',
'completed_at' => '2026-08-14 11:00:00.123456',
]];
$repository->storeBatch($rows, [
'completed_at' => '2026-08-14T11:00:00.1234567',
'business_id' => 'B100',
'barcode' => 'SKU100',
], 2);
$this->assertSame(2, DB::connection('business_mirror')->table('product_price_mirror_rows')->count());
$state = $repository->state();
$this->assertSame('2026-08-14T11:00:00.1234567', $state['cursor_completed_at']);
$this->assertSame(2, (int) $state['last_upserted_rows']);
}
}

View File

@ -96,12 +96,10 @@ class QuoteManagementPageTest extends FeatureTestCase
->assertSeeText('资料状态')
->assertSeeText('报价历史')
->assertSeeText('发起报价变更')
->assertSee('data-quote-change-open', false)
->assertSee('data-quote-change-modal', false)
->assertSeeText('变更类型')
->assertSeeText('变更说明')
->assertSeeText('生成变更草稿')
->assertDontSee('>draft</span>', false)
->assertSee(route('business.courier-quote-changes.index'), false)
->assertSee(route('business.courier-quote-changes.create'), false)
->assertDontSee('data-quote-change-modal', false)
->assertDontSeeText('只生成浏览器会话内的草稿提示')
->assertSee('<option value="新北中通">新北中通</option>', false)
->assertSee('<option value="新北中通其他">新北中通其他</option>', false)
->assertSee('<option value="新德邦快递超长">新德邦快递超长</option>', false)

View File

@ -3,6 +3,8 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Services\JianSunQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
@ -111,16 +113,19 @@ class SupplierPayablePagesTest extends FeatureTestCase
{
$this->grantAdminPermission();
$cache = \Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('remember')
$repository = \Mockery::mock(ProductPriceMirrorRepository::class);
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('initialized')->once()->andReturnTrue();
$repository->shouldReceive('paginate')
->once()
->withArgs(static function (string $scope, array $parameters): bool {
return $scope === 'supplier-payables.product-prices.list'
&& $parameters['filters']['barcode'] === 'YMJ005203N1176V01'
&& $parameters['filters']['completed_from'] === '2025-12-01'
&& $parameters['filters']['completed_to'] === '2025-12-31';
->withArgs(static function (array $filters, int $page, int $limit): bool {
return $filters === [
'barcode' => 'YMJ005203N1176V01',
'completed_from' => '2025-12-01',
'completed_to' => '2025-12-31',
] && $page === 1 && $limit > 0;
})
->andReturn(['rows' => [[
->andReturn(new LengthAwarePaginator([(object) [
'barcode' => 'YMJ005203N1176V01',
'spu' => 'YMJ0052',
'product_name' => '款式二 36/36/176 胡桃木色 BY-008',
@ -135,14 +140,8 @@ class SupplierPayablePagesTest extends FeatureTestCase
'approval_result' => '已同意',
'workflow_title' => '特殊限价需求申请',
'completed_at' => '2025-12-31 00:00:00',
]]]);
$cache->shouldReceive('responseHeaders')->once()->andReturn([]);
$factory = \Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')
->once()
->with('operations_replica')
->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
] ], 1, 25, 1));
$this->app->instance(ProductPriceMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-payables',
@ -169,6 +168,29 @@ class SupplierPayablePagesTest extends FeatureTestCase
$this->assertCount(15, $headers[0]);
}
public function testProductPricesPageDoesNotTreatUninitializedMirrorAsEmptyData(): void
{
$this->grantAdminPermission();
$repository = \Mockery::mock(ProductPriceMirrorRepository::class);
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('initialized')->once()->andReturnFalse();
$repository->shouldNotReceive('paginate');
$this->app->instance(ProductPriceMirrorRepository::class, $repository);
$url = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-payables',
'page' => 'product-prices',
]) . '?page=2';
$response = $this->loginAs()->get($url);
$response
->assertOk()
->assertSee('商品价格正在执行首次全量同步')
->assertDontSee('没有符合当前筛选条件的商品价格数据');
}
public function testCompensationPageUsesTheFiveColumnsFromTheReferenceSheet(): void
{
$this->grantAdminPermission();

View File

@ -0,0 +1,125 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class BusinessInformationMirrorRepositoryTest extends TestCase
{
private BusinessInformationMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
'operations_replica.rpt.fys_companyInfo' => $this->definition('fys_companyInfo', 'ops_fys_company_info'),
'operations_replica.rpt.fys_shopInfo' => $this->definition('fys_shopInfo', 'ops_fys_shop_info'),
'operations_replica.rpt.fys_TrademarkInfo' => $this->definition('fys_TrademarkInfo', 'ops_fys_trademark_info'),
],
]);
DB::purge('business_mirror');
$this->createTables();
$mirror = new GenericBusinessMirrorRepository(app('db'));
$this->repository = new BusinessInformationMirrorRepository(
new BusinessMirrorDatasetRegistry(),
$mirror,
);
}
public function test_it_reads_all_three_ready_mirrors_with_pagination_and_business_filters(): void
{
$connection = DB::connection('business_mirror');
$connection->table('ops_fys_company_info')->insert([
$this->company(1, '统一代码', '旧公司'),
$this->company(2, '统一代码', '新公司'),
$this->company(3, '其他代码', '其他公司'),
]);
$connection->table('ops_fys_shop_info')->insert([
['Id' => 8, 'openTime' => '2026-08-14 10:00:00', 'erpShopName' => '测试店', 'unifiedSocialCreditCode' => '统一代码', 'trademarkNumber' => 'TM', 'platform' => '天猫', 'responsibleDepartment' => '电商', 'shopHead' => '负责人', 'shopState' => '0', 'shopType' => '直营', 'channelType' => '线上'],
]);
$connection->table('ops_fys_trademark_info')->insert([
['Id' => 5, 'trademarkName' => '有效商标', 'internationalClassification' => '20', 'trademarkStatus' => '1', 'registrationDate' => '2020-01-01', 'termOfValidity' => '2030-01-01', 'remark' => null, 'isDelete' => 0],
['Id' => 6, 'trademarkName' => '已删除', 'internationalClassification' => '20', 'trademarkStatus' => '0', 'registrationDate' => null, 'termOfValidity' => null, 'remark' => null, 'isDelete' => 1],
]);
$this->markReady();
$companies = $this->repository->paginate('company-information.read', 2, 2);
$store = $this->repository->paginate('store-information.read', 1, 20);
$trademarks = $this->repository->paginate('trademark-information.read', 1, 20);
$this->assertSame(3, $companies['total']);
$this->assertSame(1, $companies['data'][0]['id']);
$this->assertSame('新公司', $store['data'][0]['company_name']);
$this->assertSame(1, $trademarks['total']);
$this->assertSame('有效商标', $trademarks['data'][0]['trademark_name']);
}
public function test_dataset_is_not_ready_until_initial_full_sync_is_marked_complete(): void
{
$this->assertFalse($this->repository->ready('company-information.read'));
$this->markReady(['operations_replica.rpt.fys_companyInfo']);
$this->assertTrue($this->repository->ready('company-information.read'));
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('ops_fys_company_info', function (Blueprint $table): void {
$table->integer('Id'); $table->string('unifiedSocialCreditCode')->nullable(); $table->string('businessLicenseName')->nullable();
$table->string('registeredCapital')->nullable(); $table->string('businessLicenseType')->nullable(); $table->dateTime('IncorporationDate')->nullable();
$table->string('legalRepresentative')->nullable(); $table->string('registeredAddress')->nullable(); $table->text('businessScope')->nullable();
$table->string('accountSerialNumber')->nullable(); $table->string('companyStatus')->nullable(); $table->string('financialManager')->nullable();
$table->string('supervisor')->nullable(); $table->string('taxCategoryAndType')->nullable();
});
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
$table->integer('Id'); $table->dateTime('openTime')->nullable(); $table->string('erpShopName')->nullable(); $table->string('unifiedSocialCreditCode')->nullable();
$table->string('trademarkNumber')->nullable(); $table->string('platform')->nullable(); $table->string('responsibleDepartment')->nullable();
$table->string('shopHead')->nullable(); $table->string('shopState')->nullable(); $table->string('shopType')->nullable(); $table->string('channelType')->nullable();
});
$schema->create('ops_fys_trademark_info', function (Blueprint $table): void {
$table->integer('Id'); $table->string('trademarkName')->nullable(); $table->string('internationalClassification')->nullable();
$table->string('trademarkStatus')->nullable(); $table->dateTime('registrationDate')->nullable(); $table->dateTime('termOfValidity')->nullable();
$table->text('remark')->nullable(); $table->integer('isDelete')->default(0);
});
}
private function markReady(?array $keys = null): void
{
$keys ??= array_keys((array) config('upstream_mirrors.datasets'));
foreach ($keys as $key) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $key,
'initial_completed_at' => '2026-08-14 12:00:00',
]);
}
}
private function definition(string $object, string $target): array
{
return ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => $object, 'target' => $target, 'keys' => ['Id']];
}
private function company(int $id, string $code, string $name): array
{
return ['Id' => $id, 'unifiedSocialCreditCode' => $code, 'businessLicenseName' => $name, 'registeredCapital' => null, 'businessLicenseType' => null, 'IncorporationDate' => null, 'legalRepresentative' => null, 'registeredAddress' => null, 'businessScope' => null, 'accountSerialNumber' => null, 'companyStatus' => '0', 'financialManager' => null, 'supervisor' => null, 'taxCategoryAndType' => null];
}
}

View File

@ -0,0 +1,330 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Tests\TestCase;
class BusinessMirrorSynchronizerTest extends TestCase
{
private FakeBusinessMirrorQuery $query;
private BusinessMirrorSynchronizer $synchronizer;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
'test.dbo.records' => [
'source_key' => 'test',
'schema' => 'dbo',
'object' => 'records',
'target' => 'test_records',
'keys' => ['Id'],
'watermark' => 'updateTime',
],
],
'upstream_mirrors.table_sync.overlap_hours' => 24,
'upstreams.sources.test' => [
'label' => 'Test source',
'config' => 'test',
],
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('source_key');
$table->string('source_object');
$table->string('target_table');
$table->string('status')->default('pending');
$table->string('run_mode')->nullable();
$table->string('generation')->nullable();
$table->json('cursor')->nullable();
$table->json('high_watermark')->nullable();
$table->dateTime('window_started_at')->nullable();
$table->dateTime('window_ends_at')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->unsignedBigInteger('mirror_row_count')->default(0);
$table->string('last_error_code')->nullable();
$table->timestamps();
});
DB::table('upstream_schema_columns')->insert([
$this->column('Id', 1, 'int', null),
$this->column('updateTime', 2, 'datetime2', null),
$this->column('displayName', 3, 'nvarchar', 120),
$this->column('businessDate', 4, 'date', null),
]);
$this->query = new FakeBusinessMirrorQuery();
$factory = new class($this->query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; }
};
$registry = new BusinessMirrorDatasetRegistry();
$this->synchronizer = new BusinessMirrorSynchronizer(
$registry,
new BusinessMirrorSchemaManager(),
new GenericBusinessMirrorRepository(app('db')),
$factory,
);
}
public function test_full_sync_materializes_complete_table_and_marks_state_ready(): void
{
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 10:00:00.1234567', 'displayName' => '一号', 'businessDate' => '2026-08-14'],
['Id' => 2, 'updateTime' => '2026-08-14 11:00:00.0000000', 'displayName' => '二号', 'businessDate' => '2026-08-15'],
]];
$result = $this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(2, DB::connection('business_mirror')->table('test_records')->count());
$this->assertSame(
'2026-08-14 10:00:00.123456',
DB::connection('business_mirror')->table('test_records')->where('Id', 1)->value('updateTime'),
);
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
$this->assertSame('succeeded', $state->status);
$this->assertNotNull($state->initial_completed_at);
$this->assertSame(2, (int) $state->mirror_row_count);
$this->assertStringContainsString('SELECT TOP (10)', $this->query->calls[0]['sql']);
$this->assertStringContainsString('CONVERT(varchar(27), [updateTime], 126) AS [updateTime]', $this->query->calls[0]['sql']);
$this->assertStringContainsString('CONVERT(varchar(10), [businessDate], 23) AS [businessDate]', $this->query->calls[0]['sql']);
$this->assertStringContainsString('ORDER BY [Id]', $this->query->calls[0]['sql']);
}
public function test_incremental_sync_uses_overlap_window_and_updates_existing_key(): void
{
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '旧值'],
]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->query->calls = [];
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 12:00:00', 'displayName' => '新值'],
['Id' => 2, 'updateTime' => '2026-08-14 12:30:00', 'displayName' => '新增'],
]];
$result = $this->synchronizer->sync('test.dbo.records', false, 10, 1);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(2, DB::connection('business_mirror')->table('test_records')->count());
$this->assertSame('新值', DB::connection('business_mirror')->table('test_records')->where('Id', 1)->value('displayName'));
$this->assertStringContainsString('[updateTime] >= ? AND [updateTime] < ?', $this->query->calls[0]['sql']);
$this->assertStringContainsString('ORDER BY [Id]', $this->query->calls[0]['sql']);
$this->assertStringNotContainsString('ORDER BY [updateTime]', $this->query->calls[0]['sql']);
$this->assertCount(2, $this->query->calls[0]['bindings']);
foreach ($this->query->calls[0]['bindings'] as $binding) {
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/', $binding);
$this->assertStringNotContainsString(' ', $binding);
}
}
public function test_incremental_keyset_keeps_watermark_out_of_pagination_cursor(): void
{
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '初始'],
]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->query->calls = [];
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 12:00:00', 'displayName' => '更新一'],
['Id' => 2, 'updateTime' => '2026-08-14 12:30:00', 'displayName' => '更新二'],
], []];
$result = $this->synchronizer->sync('test.dbo.records', false, 2, 0);
$this->assertSame('succeeded', $result['status']);
$this->assertCount(2, $this->query->calls);
$this->assertStringContainsString('[updateTime] >= ? AND [updateTime] < ?', $this->query->calls[1]['sql']);
$this->assertStringContainsString('[Id] > ?', $this->query->calls[1]['sql']);
$this->assertStringContainsString('ORDER BY [Id]', $this->query->calls[1]['sql']);
$this->assertStringNotContainsString('[updateTime] > ?', $this->query->calls[1]['sql']);
$this->assertCount(3, $this->query->calls[1]['bindings']);
$this->assertSame(2, $this->query->calls[1]['bindings'][2]);
}
public function test_incremental_sync_refuses_to_guess_before_first_full_sync(): void
{
$result = $this->synchronizer->sync('test.dbo.records', false, 10, 1);
$this->assertSame('requires_full_sync', $result['status']);
$this->assertSame([], $this->query->calls);
}
public function test_supervised_full_sync_caps_explicit_batch_size_at_five_thousand(): void
{
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '一号', 'businessDate' => '2026-08-14'],
]];
$this->synchronizer->sync('test.dbo.records', true, 50000, 1);
$this->assertStringContainsString('SELECT TOP (5000)', $this->query->calls[0]['sql']);
}
public function test_full_sync_sanitizes_invalid_utf8_text_without_changing_valid_text(): void
{
$this->query->batches = [[
[
'Id' => 1,
'updateTime' => '2026-08-14 10:00:00',
'displayName' => hex2bin('e80044495900'),
],
[
'Id' => 2,
'updateTime' => '2026-08-14 11:00:00',
'displayName' => '合法名称',
],
]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$rows = DB::connection('business_mirror')->table('test_records')->orderBy('Id')->pluck('displayName')->all();
$this->assertStringEndsWith('DIY', $rows[0]);
$this->assertNotSame(hex2bin('e80044495900'), $rows[0]);
$this->assertSame('合法名称', $rows[1]);
$this->assertTrue(mb_check_encoding($rows[0], 'UTF-8'));
$this->assertStringNotContainsString("\0", $rows[0]);
}
public function test_wide_source_schema_stores_only_short_strings_inline(): void
{
$datasets = (array) config('upstream_mirrors.datasets', []);
$datasets['test.dbo.wide_records'] = [
'source_key' => 'test',
'schema' => 'dbo',
'object' => 'wide_records',
'target' => 'test_wide_records',
'keys' => ['Id'],
'watermark' => 'updateTime',
];
config(['upstream_mirrors.datasets' => $datasets]);
$columns = [
$this->column('Id', 1, 'int', null),
$this->column('updateTime', 2, 'datetime2', null),
$this->column('shortCode', 3, 'nvarchar', 32),
$this->column('longLabel', 4, 'nvarchar', 120),
];
DB::table('upstream_schema_columns')->insert(array_map(
fn (array $column): array => [...$column, 'object_name' => 'wide_records'],
$columns,
));
$dataset = (new BusinessMirrorDatasetRegistry())->get('test.dbo.wide_records');
(new BusinessMirrorSchemaManager())->ensure($dataset);
$types = DB::connection('business_mirror')->select('PRAGMA table_info(test_wide_records)');
$types = collect($types)->pluck('type', 'name')->map(fn (string $type): string => strtolower($type));
$this->assertSame('varchar', $types['shortCode']);
$this->assertSame('text', $types['longLabel']);
}
public function test_empty_full_read_does_not_delete_previous_mirror_rows(): void
{
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '保留'],
]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->query->batches = [[]];
try {
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->fail('空的上游响应不应覆盖已有镜像。');
} catch (\RuntimeException $exception) {
$this->assertSame('business_mirror_unexpected_empty_source', $exception->getMessage());
}
$this->assertSame(1, DB::connection('business_mirror')->table('test_records')->count());
$this->assertSame('保留', DB::connection('business_mirror')->table('test_records')->value('displayName'));
$this->assertSame(
'failed',
DB::connection('business_mirror')->table('business_mirror_table_states')->value('status'),
);
}
public function test_project_dataset_registry_has_unique_safe_targets(): void
{
$configured = require base_path('config/upstream_mirrors.php');
config(['upstream_mirrors.datasets' => $configured['datasets']]);
$datasets = (new BusinessMirrorDatasetRegistry())->all();
$targets = array_map(fn ($dataset): string => $dataset->targetTable(), $datasets);
$this->assertCount(38, $datasets);
$this->assertCount(count($targets), array_unique($targets));
$this->assertContains('operations_replica', array_map(fn ($dataset): string => $dataset->sourceKey(), $datasets));
$this->assertContains('jiansun', array_map(fn ($dataset): string => $dataset->sourceKey(), $datasets));
$this->assertNotContains('Bulk_Price_Limit', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets));
}
/** @return array<string,mixed> */
private function column(string $name, int $position, string $type, ?int $length): array
{
$now = now();
return [
'source_key' => 'test',
'schema_name' => 'dbo',
'object_name' => 'records',
'object_type' => 'table',
'column_name' => $name,
'ordinal_position' => $position,
'data_type' => $type,
'max_length' => $length,
'numeric_precision' => null,
'numeric_scale' => null,
'is_nullable' => true,
'synchronized_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
}
class FakeBusinessMirrorQuery extends UpstreamReadOnlyQuery
{
/** @var list<list<array<string,mixed>>> */
public array $batches = [];
/** @var list<array{sql:string,bindings:array}> */
public array $calls = [];
public function __construct()
{
parent::__construct('test');
}
public function select(string $sql, array $bindings = []): array
{
$this->calls[] = compact('sql', 'bindings');
return array_shift($this->batches) ?? [];
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use RuntimeException;
use Tests\TestCase;
class MarketingAccountBillMirrorRepositoryTest extends TestCase
{
private MarketingAccountBillMirrorRepository $mirror;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
MarketingAccountBillMirrorRepository::TAOBAO => $this->definition('tmall_spread_bills', 'js_tmall_spread_bills'),
MarketingAccountBillMirrorRepository::PINDUODUO => $this->definition('pdd_spread_bills', 'js_pdd_spread_bills'),
MarketingAccountBillMirrorRepository::ALIBABA => $this->definition('alibaba_bill', 'js_alibaba_bill'),
MarketingAccountBillMirrorRepository::JD_RECHARGE => $this->definition('jd_spreadBills', 'js_jd_spread_bills'),
MarketingAccountBillMirrorRepository::JD_CONSUMPTION => $this->definition('jd_costBills', 'js_jd_cost_bills'),
],
]);
DB::purge('business_mirror');
$this->createTables();
$generic = new GenericBusinessMirrorRepository(app('db'));
$this->mirror = new MarketingAccountBillMirrorRepository(
new BusinessMirrorDatasetRegistry(),
$generic,
);
}
public function test_ready_mirrors_drive_lists_summaries_options_monthly_balances_and_second_pages(): void
{
$db = DB::connection('business_mirror');
$db->table('js_tmall_spread_bills')->insert([
$this->taobao(1, '2026-05-31 23:00:00', 10000, 10000, 'DEBIT'),
$this->taobao(2, '2026-06-01 09:00:00', 5000, 15000, 'DEBIT'),
$this->taobao(3, '2026-06-02 09:00:00', 2000, 13000, 'CREDIT'),
]);
$db->table('js_pdd_spread_bills')->insert([
$this->pinduoduo(1, '2026-05-31 23:00:00', 100, 100, 1),
$this->pinduoduo(2, '2026-06-01 09:00:00', 50, 150, 1),
$this->pinduoduo(3, '2026-06-02 09:00:00', 20, 130, 2),
]);
$db->table('js_alibaba_bill')->insert([
['id' => 1, 'shopName' => '阿里店', 'tradeDay' => '2026-06-01 09:00:00', 'tradeTypeName' => '全站', 'tradeAMTIn' => 10, 'tradeAMTOut' => 1],
['id' => 2, 'shopName' => '阿里店', 'tradeDay' => '2026-06-02 09:00:00', 'tradeTypeName' => '全站', 'tradeAMTIn' => 20, 'tradeAMTOut' => 2],
]);
$db->table('js_jd_spread_bills')->insert([
['id' => 1, 'shopName' => '京东店', 'amount' => 100, 'balanceAmount' => 100, 'orderTypeName' => '充值', 'pk' => 'R1', 'createDate' => '2026-06-01 10:00:00', 'updateTime' => '2026-06-01 10:01:00', 'statusName' => '成功', 'remark' => ''],
['id' => 2, 'shopName' => '京东店', 'amount' => 200, 'balanceAmount' => 300, 'orderTypeName' => '充值', 'pk' => 'R2', 'createDate' => '2026-06-02 10:00:00', 'updateTime' => '2026-06-02 10:01:00', 'statusName' => '成功', 'remark' => ''],
]);
$db->table('js_jd_cost_bills')->insert([
['id' => 1, 'shopName' => '京东店', 'amount' => -30, 'balanceAmount' => 270, 'orderTypeName' => '消耗', 'creatTime' => '2026-06-03 10:00:00', 'settleTime' => '2026-06-04 00:00:00', 'pk' => 'C1', 'invoiceApplyTypeName' => '推广费'],
]);
$this->markReady();
$factory = $this->throwingFactory();
$taobao = new TmallTaobaoSpreadBillRepository($factory, $this->mirror);
$pinduoduo = new PinduoduoSpreadBillRepository($factory, $this->mirror);
$alibaba = new AlibabaBillRepository($factory, $this->mirror);
$jd = new JdWalletBillRepository($factory, $this->mirror);
$taobaoPage2 = $taobao->paginate('镜像店铺', '2026-06-01', '2026-06-30', null, 2, 1);
$taobaoSummary = $taobao->summary('镜像店铺', '2026-06-01', '2026-06-30');
$taobaoMonthly = $taobao->paginateMonthlyBalances('镜像店铺', '2026-06', 1, 25);
$pddPage2 = $pinduoduo->paginate('镜像店铺', '2026-06-01', '2026-06-30', null, 2, 1);
$pddSummary = $pinduoduo->summary('镜像店铺', '2026-06-01', '2026-06-30');
$pddMonthly = $pinduoduo->paginateMonthlyBalances('镜像店铺', '2026-06', 1, 25);
$alibabaPage2 = $alibaba->paginate('阿里店', '全站', '2026-06-01', '2026-06-30', 2, 1);
$jdRechargePage2 = $jd->paginateRecharge('京东店', '2026-06-01', '2026-06-30', 2, 1);
$this->assertSame(2, $taobaoPage2['total']);
$this->assertSame(50.0, $taobaoPage2['rows'][0]['amount']);
$this->assertSame(['镜像店铺'], $taobao->storeOptions());
$this->assertSame(30.0, $taobaoSummary['net']);
$this->assertSame(100.0, $taobaoMonthly['rows'][0]['opening_balance']);
$this->assertSame(130.0, $taobaoMonthly['rows'][0]['closing_balance']);
$this->assertSame(0.0, $taobaoMonthly['rows'][0]['balance_difference']);
$this->assertSame(2, $pddPage2['total']);
$this->assertSame(50.0, $pddPage2['rows'][0]['amount']);
$this->assertSame(30.0, $pddSummary['net']);
$this->assertSame(100.0, $pddMonthly['rows'][0]['opening_balance']);
$this->assertSame(130.0, $pddMonthly['rows'][0]['closing_balance']);
$this->assertSame(0.0, $pddMonthly['rows'][0]['balance_difference']);
$this->assertSame(2, $alibabaPage2['total']);
$this->assertSame(10.0, $alibabaPage2['rows'][0]['income_amount']);
$this->assertSame(['shops' => ['阿里店'], 'product_types' => ['全站']], $alibaba->filterOptions());
$this->assertSame(27.0, $alibaba->summary('阿里店', '全站')['net']);
$this->assertSame(2, $jdRechargePage2['total']);
$this->assertSame('R1', $jdRechargePage2['rows'][0]['order_number']);
$this->assertSame(['京东店'], $jd->shopOptions());
$this->assertSame(300.0, $jd->summaryRecharge('京东店')['amount_total']);
$this->assertSame(30.0, $jd->summaryConsumption('京东店')['amount_total']);
}
public function test_unready_dataset_keeps_the_existing_upstream_repository_path(): void
{
$query = new class('jiansun') extends UpstreamReadOnlyQuery {
public function scalar(string $sql, array $bindings = []): mixed { return 1; }
public function select(string $sql, array $bindings = []): array
{
return [['shopName' => '上游回退', 'amount' => 8, 'balance' => 9, 'summary' => '', 'flowType' => 1, 'toAdvertiserFundType' => 1, 'createTime' => '2026-06-01']];
}
};
$factory = new class($query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; }
};
$result = (new PinduoduoSpreadBillRepository($factory, $this->mirror))->paginate();
$this->assertSame(1, $result['total']);
$this->assertSame('上游回退', $result['rows'][0]['store_name']);
}
private function throwingFactory(): UpstreamReadOnlyQueryFactory
{
$query = new class('jiansun') extends UpstreamReadOnlyQuery {
public function scalar(string $sql, array $bindings = []): mixed { throw new RuntimeException('upstream_should_not_be_called'); }
public function select(string $sql, array $bindings = []): array { throw new RuntimeException('upstream_should_not_be_called'); }
public function first(string $sql, array $bindings = []): ?array { throw new RuntimeException('upstream_should_not_be_called'); }
};
return new class($query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; }
};
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('js_tmall_spread_bills', function (Blueprint $table): void {
$table->integer('id'); $table->string('shopName')->nullable(); $table->dateTime('uniTransTime')->nullable(); $table->dateTime('transTime')->nullable();
$table->decimal('amount', 18, 2)->nullable(); $table->decimal('balance', 18, 2)->nullable(); $table->string('comment')->nullable();
$table->string('finType')->nullable(); $table->string('bizType')->nullable(); $table->string('tradeType')->nullable();
});
$schema->create('js_pdd_spread_bills', function (Blueprint $table): void {
$table->integer('id'); $table->string('shopName')->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balance', 18, 2)->nullable();
$table->string('summary')->nullable(); $table->integer('flowType')->nullable(); $table->integer('toAdvertiserFundType')->nullable(); $table->dateTime('createTime')->nullable();
});
$schema->create('js_alibaba_bill', function (Blueprint $table): void {
$table->integer('id'); $table->string('shopName')->nullable(); $table->dateTime('tradeDay')->nullable(); $table->string('tradeTypeName')->nullable();
$table->decimal('tradeAMTIn', 18, 2)->nullable(); $table->decimal('tradeAMTOut', 18, 2)->nullable();
});
$schema->create('js_jd_spread_bills', function (Blueprint $table): void {
$table->integer('id'); $table->string('shopName')->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balanceAmount', 18, 2)->nullable();
$table->string('orderTypeName')->nullable(); $table->string('pk')->nullable(); $table->dateTime('createDate')->nullable(); $table->dateTime('updateTime')->nullable();
$table->string('statusName')->nullable(); $table->string('remark')->nullable();
});
$schema->create('js_jd_cost_bills', function (Blueprint $table): void {
$table->integer('id'); $table->string('shopName')->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balanceAmount', 18, 2)->nullable();
$table->string('orderTypeName')->nullable(); $table->dateTime('creatTime')->nullable(); $table->dateTime('settleTime')->nullable();
$table->string('pk')->nullable(); $table->string('invoiceApplyTypeName')->nullable();
});
}
private function markReady(): void
{
foreach (array_keys((array) config('upstream_mirrors.datasets')) as $key) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $key,
'initial_completed_at' => '2026-08-14 12:00:00',
]);
}
}
private function definition(string $object, string $target): array
{
return ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => $object, 'target' => $target, 'keys' => ['id']];
}
private function taobao(int $id, string $time, float $amount, float $balance, string $finType): array
{
return ['id' => $id, 'shopName' => '镜像店铺', 'uniTransTime' => $time, 'transTime' => $time, 'amount' => $amount, 'balance' => $balance, 'comment' => '', 'finType' => $finType, 'bizType' => '', 'tradeType' => ''];
}
private function pinduoduo(int $id, string $time, float $amount, float $balance, int $flowType): array
{
return ['id' => $id, 'shopName' => '镜像店铺', 'amount' => $amount, 'balance' => $balance, 'summary' => '', 'flowType' => $flowType, 'toAdvertiserFundType' => 1, 'createTime' => $time];
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
use Modules\EcommerceFinance\Services\ProductPriceMirrorSynchronizer;
use Tests\TestCase;
class ProductPriceMirrorSynchronizerTest extends TestCase
{
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_incremental_sync_waits_for_completed_initial_sync(): void
{
$upstream = $this->createMock(ProductPriceRepository::class);
$upstream->expects($this->never())->method('mirrorBatch');
$mirror = $this->createMock(ProductPriceMirrorRepository::class);
$mirror->method('ready')->willReturn(true);
$mirror->method('state')->willReturn(null);
$result = (new ProductPriceMirrorSynchronizer($upstream, $mirror))->sync();
$this->assertSame('requires_full_sync', $result['status']);
}
public function test_full_sync_upserts_batches_and_marks_initial_sync_complete(): void
{
Carbon::setTestNow('2026-08-14 15:00:00');
$rows = [[
'business_id' => 'B1',
'barcode' => 'SKU1',
'cursor_completed_at' => '2026-08-01T10:00:00.1234567',
'completed_at' => '2026-08-01 10:00:00',
], [
'business_id' => 'B2',
'barcode' => 'SKU2',
'cursor_completed_at' => '2026-08-01T11:00:00.7654321',
'completed_at' => '2026-08-01 11:00:00',
]];
$upstream = $this->createMock(ProductPriceRepository::class);
$upstream->expects($this->exactly(2))->method('mirrorBatch')
->willReturnOnConsecutiveCalls($rows, []);
$mirror = $this->createMock(ProductPriceMirrorRepository::class);
$mirror->method('ready')->willReturn(true);
$mirror->method('state')->willReturn(null);
$mirror->expects($this->once())->method('startRun')
->with('full', $this->isInstanceOf(Carbon::class), $this->isInstanceOf(Carbon::class), false)
->willReturn(['last_upserted_rows' => 0]);
$mirror->expects($this->once())->method('storeBatch')
->with($rows, [
'completed_at' => '2026-08-01T11:00:00.7654321',
'business_id' => 'B2',
'barcode' => 'SKU2',
], 2);
$mirror->expects($this->once())->method('complete')
->with('full', $this->isInstanceOf(Carbon::class), 2);
$result = (new ProductPriceMirrorSynchronizer($upstream, $mirror))->sync(true, 2, 0);
$this->assertSame([
'status' => 'succeeded',
'mode' => 'full',
'batches' => 1,
'rows' => 2,
], $result);
}
}

View File

@ -70,4 +70,51 @@ class ProductPriceRepositoryTest extends TestCase
$this->assertNull($result['rows'][1]['minimum_price']);
$this->assertSame(0.0, $result['rows'][1]['purchase_price']);
}
public function test_mirror_batch_uses_stable_keyset_cursor_and_bounded_window(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$query->expects($this->once())->method('select')->willReturnCallback(
function (string $sql, array $bindings): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertStringContainsString('SELECT TOP 200', $sql);
$this->assertStringContainsString("COALESCE([businessId], '') AS business_id", $sql);
$this->assertStringContainsString('[process_end_date] > CAST(? AS datetime2(7))', $sql);
$this->assertStringContainsString('CONVERT(varchar(33), [process_end_date], 126) AS cursor_completed_at', $sql);
$this->assertStringNotContainsString('OFFSET', $sql);
$this->assertSame([
'2026-08-01 00:00:00',
'2026-08-14 00:00:00',
'2026-08-10 12:00:00',
'2026-08-10 12:00:00',
'B100',
'2026-08-10 12:00:00',
'B100',
'SKU100',
], $bindings);
return [[
'business_id' => 'B101',
'barcode' => 'SKU101',
'completed_at' => '2026-08-10 12:01:00',
'minimum_price' => '10.25',
'price_limit' => null,
'purchase_price' => '8.00',
'shipping_fee' => '1.00',
'jingxi_limit_price' => '9.00',
]];
},
);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->with('operations_replica')->willReturn($query);
$rows = (new ProductPriceRepository($factory))->mirrorBatch([
'completed_at' => '2026-08-10 12:00:00',
'business_id' => 'B100',
'barcode' => 'SKU100',
], '2026-08-01 00:00:00', '2026-08-14 00:00:00', 200);
$this->assertSame('B101', $rows[0]['business_id']);
$this->assertSame(10.25, $rows[0]['minimum_price']);
}
}

View File

@ -4,6 +4,8 @@ namespace Tests\Unit\EcommerceFinance;
use App\Business\UpstreamBusinessInformation;
use Closure;
use Mockery;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Tests\TestCase;
@ -44,7 +46,10 @@ class UpstreamBusinessInformationTest extends TestCase
}
};
$information = new class($caches) extends UpstreamBusinessInformation {
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->with($scope)->andReturnFalse();
$information = new class($caches, $mirror) extends UpstreamBusinessInformation {
public array $snapshots = [];
public function snapshot(string $scope, array $parameters): array
@ -65,6 +70,43 @@ class UpstreamBusinessInformationTest extends TestCase
$this->assertSame($expected, $information->snapshots[0]);
}
/**
* @dataProvider businessInformationPages
*/
public function test_ready_business_mirror_is_used_without_registering_a_page_snapshot(
string $section,
string $page,
string $scope,
): void {
$cache = new class extends UpstreamQueryCache {
public function __construct() {}
public function remember(string $scope, array $parameters, Closure $resolver): mixed
{
throw new \RuntimeException('镜像就绪后不应再读取分页快照。');
}
};
$caches = new class($cache) extends UpstreamQueryCacheFactory {
public function __construct(private readonly UpstreamQueryCache $cache) {}
public function forSource(string $source): UpstreamQueryCache { return $this->cache; }
};
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->with($scope)->andReturnTrue();
$mirror->shouldReceive('paginate')->once()->with($scope, 2, 25)->andReturn([
'data' => [['id' => 10]],
'total' => 51,
]);
$this->app['request']->merge(['page' => 2]);
$paginator = (new UpstreamBusinessInformation($caches, $mirror))
->paginate($section, $page, 25);
$this->assertSame(2, $paginator->currentPage());
$this->assertSame(51, $paginator->total());
$this->assertSame(10, $paginator->items()[0]->id);
}
public static function businessInformationPages(): array
{
return [

View File

@ -0,0 +1,43 @@
<?php
namespace Tests\Unit;
use App\Services\Esign\EsignRequestSigner;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class EsignRequestSignerTest extends TestCase
{
public function test_it_builds_the_official_hmac_sha256_base64_signature(): void
{
$signer = new EsignRequestSigner('app-123', 'secret-456');
$body = '{"name":"报价确认","amount":12.5}';
$headers = $signer->headers('post', '/v3/example?b=2&a=1', $body);
$contentMd5 = base64_encode(md5($body, true));
$stringToSign = "POST\n*/*\n{$contentMd5}\napplication/json; charset=UTF-8\n\n/v3/example?a=1&b=2";
$expected = base64_encode(hash_hmac('sha256', $stringToSign, 'secret-456', true));
$this->assertSame('app-123', $headers['X-Tsign-Open-App-Id']);
$this->assertSame('Signature', $headers['X-Tsign-Open-Auth-Mode']);
$this->assertSame($contentMd5, $headers['Content-MD5']);
$this->assertSame($expected, $headers['X-Tsign-Open-Ca-Signature']);
$this->assertMatchesRegularExpression('/^\d{13}$/', $headers['X-Tsign-Open-Ca-Timestamp']);
}
public function test_it_sorts_query_keys_and_keeps_only_the_first_repeated_value(): void
{
$signer = new EsignRequestSigner('app', 'secret');
$this->assertSame(
'/v3/files?empty&keyword=first&z=9',
$signer->canonicalPath('/v3/files?z=9&keyword=first&empty=&keyword=second'),
);
}
public function test_it_rejects_non_api_paths(): void
{
$this->expectException(InvalidArgumentException::class);
(new EsignRequestSigner('app', 'secret'))->canonicalPath('v3/files');
}
}