diff --git a/AGENTS.md b/AGENTS.md index cc7cd707..74021fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()` 对数据字典中选定的对象执行少量、限定字段和返回量的只读 `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`。 diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 53a9a4b9..9598170b 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -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>, 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); diff --git a/app/Console/Commands/SyncBusinessMirrors.php b/app/Console/Commands/SyncBusinessMirrors.php new file mode 100644 index 00000000..5abb3fa5 --- /dev/null +++ b/app/Console/Commands/SyncBusinessMirrors.php @@ -0,0 +1,107 @@ +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 */ + 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'); + } +} diff --git a/app/Console/Commands/SyncProductPriceMirror.php b/app/Console/Commands/SyncProductPriceMirror.php new file mode 100644 index 00000000..d15af6c5 --- /dev/null +++ b/app/Console/Commands/SyncProductPriceMirror.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/Http/Controllers/Business/CourierQuoteChanges.php b/app/Http/Controllers/Business/CourierQuoteChanges.php new file mode 100644 index 00000000..8467806a --- /dev/null +++ b/app/Http/Controllers/Business/CourierQuoteChanges.php @@ -0,0 +1,249 @@ +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); + } +} diff --git a/app/Http/Controllers/Business/CourierQuoteSignatures.php b/app/Http/Controllers/Business/CourierQuoteSignatures.php new file mode 100644 index 00000000..98fb9a7d --- /dev/null +++ b/app/Http/Controllers/Business/CourierQuoteSignatures.php @@ -0,0 +1,100 @@ +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, + '当前账号没有报价签署管理权限。', + ); + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a5622105..2ee0f3c5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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', diff --git a/app/Http/Controllers/Integrations/EsignCallbacks.php b/app/Http/Controllers/Integrations/EsignCallbacks.php new file mode 100644 index 00000000..2bf8778e --- /dev/null +++ b/app/Http/Controllers/Integrations/EsignCallbacks.php @@ -0,0 +1,95 @@ +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']); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index e686b035..c27aa552 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + 'integrations/esign/callback', ]; -} \ No newline at end of file +} diff --git a/app/Jobs/Business/ArchiveCourierQuoteSignature.php b/app/Jobs/Business/ArchiveCourierQuoteSignature.php new file mode 100644 index 00000000..f387dfd7 --- /dev/null +++ b/app/Jobs/Business/ArchiveCourierQuoteSignature.php @@ -0,0 +1,28 @@ +synchronizeById($this->signatureId); + } +} diff --git a/app/Models/Business/CourierQuoteChange.php b/app/Models/Business/CourierQuoteChange.php new file mode 100644 index 00000000..5ef081e9 --- /dev/null +++ b/app/Models/Business/CourierQuoteChange.php @@ -0,0 +1,82 @@ + '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, + }; + } +} diff --git a/app/Models/Business/CourierQuoteChangeEvent.php b/app/Models/Business/CourierQuoteChangeEvent.php new file mode 100644 index 00000000..3f3f7b8e --- /dev/null +++ b/app/Models/Business/CourierQuoteChangeEvent.php @@ -0,0 +1,39 @@ + '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, + }; + } +} diff --git a/app/Models/Business/CourierQuoteChangeItem.php b/app/Models/Business/CourierQuoteChangeItem.php new file mode 100644 index 00000000..5f13e08d --- /dev/null +++ b/app/Models/Business/CourierQuoteChangeItem.php @@ -0,0 +1,30 @@ + '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'); + } +} diff --git a/app/Models/Business/CourierQuoteSignature.php b/app/Models/Business/CourierQuoteSignature.php new file mode 100644 index 00000000..09474c94 --- /dev/null +++ b/app/Models/Business/CourierQuoteSignature.php @@ -0,0 +1,68 @@ + '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); + } +} diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php new file mode 100644 index 00000000..33f6497e --- /dev/null +++ b/app/Models/Business/CourierQuoteVersion.php @@ -0,0 +1,33 @@ + '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'); + } +} diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php new file mode 100644 index 00000000..ab9d06ca --- /dev/null +++ b/app/Services/Esign/EsignClient.php @@ -0,0 +1,201 @@ +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)); + } +} diff --git a/app/Services/Esign/EsignRequestSigner.php b/app/Services/Esign/EsignRequestSigner.php new file mode 100644 index 00000000..3644bb6b --- /dev/null +++ b/app/Services/Esign/EsignRequestSigner.php @@ -0,0 +1,75 @@ +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; + } +} diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php new file mode 100644 index 00000000..90fccc09 --- /dev/null +++ b/app/Support/CourierQuoteChangeService.php @@ -0,0 +1,317 @@ +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 保持不变。'; + } + +} diff --git a/app/Support/CourierQuotePdf.php b/app/Support/CourierQuotePdf.php new file mode 100644 index 00000000..2b5ced85 --- /dev/null +++ b/app/Support/CourierQuotePdf.php @@ -0,0 +1,46 @@ +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 : []; + } +} diff --git a/app/Support/CourierQuoteSignatureService.php b/app/Support/CourierQuoteSignatureService.php new file mode 100644 index 00000000..dc573f54 --- /dev/null +++ b/app/Support/CourierQuoteSignatureService.php @@ -0,0 +1,298 @@ +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); + } +} diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index 121a8bbc..f57b1c36 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -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)) { diff --git a/compose.jiangling.yml b/compose.jiangling.yml index 7fa5af4a..883a5160 100644 --- a/compose.jiangling.yml +++ b/compose.jiangling.yml @@ -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 diff --git a/config/database.php b/config/database.php index c46cb383..f667adad 100644 --- a/config/database.php +++ b/config/database.php @@ -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'), diff --git a/config/services.php b/config/services.php index f88a8933..e7f3c686 100644 --- a/config/services.php +++ b/config/services.php @@ -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), + ], + ]; diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php new file mode 100644 index 00000000..2df70817 --- /dev/null +++ b/config/upstream_mirrors.php @@ -0,0 +1,85 @@ + 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'], + ], +]; diff --git a/database/migrations/2026_08_13_000000_create_courier_quote_change_workflow_tables.php b/database/migrations/2026_08_13_000000_create_courier_quote_change_workflow_tables.php new file mode 100644 index 00000000..77b2415e --- /dev/null +++ b/database/migrations/2026_08_13_000000_create_courier_quote_change_workflow_tables.php @@ -0,0 +1,113 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_14_200000_create_product_price_mirror_tables.php b/database/migrations/2026_08_14_200000_create_product_price_mirror_tables.php new file mode 100644 index 00000000..6183818e --- /dev/null +++ b/database/migrations/2026_08_14_200000_create_product_price_mirror_tables.php @@ -0,0 +1,86 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_14_210000_create_business_courier_quote_signatures_table.php b/database/migrations/2026_08_14_210000_create_business_courier_quote_signatures_table.php new file mode 100644 index 00000000..2c918b55 --- /dev/null +++ b/database/migrations/2026_08_14_210000_create_business_courier_quote_signatures_table.php @@ -0,0 +1,54 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_14_230000_create_generic_business_mirror_tables.php b/database/migrations/2026_08_14_230000_create_generic_business_mirror_tables.php new file mode 100644 index 00000000..53f18ce9 --- /dev/null +++ b/database/migrations/2026_08_14_230000_create_generic_business_mirror_tables.php @@ -0,0 +1,62 @@ +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'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index f59085a0..2e7f6097 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -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 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index d6cbb45b..45a4b552 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -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 diff --git a/deploy/bootstrap-server.sh b/deploy/bootstrap-server.sh index 90245fe9..d924d294 100644 --- a/deploy/bootstrap-server.sh +++ b/deploy/bootstrap-server.sh @@ -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" \ diff --git a/deploy/config-db-init.sh b/deploy/config-db-init.sh index bce1e4b5..4701d156 100755 --- a/deploy/config-db-init.sh +++ b/deploy/config-db-init.sh @@ -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" < --full` 显式启动;失败或中断后使用同一命令继续。 +2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。 +3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。 +4. 每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 +5. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 + +## 后续切换顺序 + +1. 公司、店铺、商标等小型维表。 +2. 营销账户五张单表账单。 +3. 供应商应付与货款对账。 +4. 订单、退款和推广费用。 +5. 板材、封边条和库存的跨表查询。 + +每个阶段都必须先完成首次全量和数量核对,再将对应 Repository 切到镜像;任一阶段失败时保留上一版镜像和旧只读链路用于回滚。 diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index cd8b358b..146c0a6d 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -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 ``` -生产环境使用下面的总入口生成共享快照。本地开发页面直接读取上游,不依赖该任务: +尚未迁移页面仍由下面的兼容入口生成查询快照: ```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 --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 的端点和隧道配置;应用仍通过同一套快照、限流和熔断逻辑读取数据。 diff --git a/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php b/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php new file mode 100644 index 00000000..015d6b41 --- /dev/null +++ b/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php @@ -0,0 +1,21 @@ + */ + public function keyColumns(): array; + + public function watermarkColumn(): ?string; +} diff --git a/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php b/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php index f1dea8d9..cb5b8296 100644 --- a/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php +++ b/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php @@ -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); diff --git a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php new file mode 100644 index 00000000..26bcbbda --- /dev/null +++ b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php @@ -0,0 +1,150 @@ + '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>, 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>, 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>, 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>, 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>, 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); + } +} diff --git a/modules/EcommerceFinance/Repositories/GenericBusinessMirrorRepository.php b/modules/EcommerceFinance/Repositories/GenericBusinessMirrorRepository.php new file mode 100644 index 00000000..4dcee6ad --- /dev/null +++ b/modules/EcommerceFinance/Repositories/GenericBusinessMirrorRepository.php @@ -0,0 +1,256 @@ +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|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 ''; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + return (string) $value; + } +} diff --git a/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php b/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php index 77d9f126..4c22d7d4 100644 --- a/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php +++ b/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php @@ -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), diff --git a/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php new file mode 100644 index 00000000..b59b5bb2 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php @@ -0,0 +1,543 @@ + */ + 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>,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>,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>,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>,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>,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>,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>,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>,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>,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>,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); + } +} diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php index 8858a2f1..022179a8 100644 --- a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php @@ -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); diff --git a/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php new file mode 100644 index 00000000..39bda7ee --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php @@ -0,0 +1,216 @@ +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|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)) . '%'; + } +} diff --git a/modules/EcommerceFinance/Repositories/ProductPriceRepository.php b/modules/EcommerceFinance/Repositories/ProductPriceRepository.php index e83041f4..9b6a30d3 100644 --- a/modules/EcommerceFinance/Repositories/ProductPriceRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductPriceRepository.php @@ -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> + */ + 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)) . '%'; diff --git a/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php index ff41da31..ccbf69dc 100644 --- a/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php @@ -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); diff --git a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php new file mode 100644 index 00000000..5df609ee --- /dev/null +++ b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php @@ -0,0 +1,78 @@ + */ + 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; } + }; + } +} diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php new file mode 100644 index 00000000..32b1aeb9 --- /dev/null +++ b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php @@ -0,0 +1,150 @@ +> */ + 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> */ + 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); + } +} diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php new file mode 100644 index 00000000..7577037c --- /dev/null +++ b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php @@ -0,0 +1,307 @@ +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> */ + 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 $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} */ + 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 */ + 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 */ + 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> */ + 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()]; + } +} diff --git a/modules/EcommerceFinance/Services/ProductPriceMirrorSynchronizer.php b/modules/EcommerceFinance/Services/ProductPriceMirrorSynchronizer.php new file mode 100644 index 00000000..425dede6 --- /dev/null +++ b/modules/EcommerceFinance/Services/ProductPriceMirrorSynchronizer.php @@ -0,0 +1,116 @@ +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'] ?? ''), + ]; + } +} diff --git a/resources/fonts/DroidSansFallback-LICENSE.txt b/resources/fonts/DroidSansFallback-LICENSE.txt new file mode 100644 index 00000000..f7bd78d2 --- /dev/null +++ b/resources/fonts/DroidSansFallback-LICENSE.txt @@ -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 diff --git a/resources/fonts/DroidSansFallback.ttf b/resources/fonts/DroidSansFallback.ttf new file mode 100644 index 00000000..1099b177 Binary files /dev/null and b/resources/fonts/DroidSansFallback.ttf differ diff --git a/resources/views/business/pages/courier-payables/quote-change-editor.blade.php b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php new file mode 100644 index 00000000..2f9d7ef4 --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php @@ -0,0 +1,218 @@ + + {{ $change ? '编辑报价变更' : '发起报价变更' }} + + + + +
+ + + @if (is_string(session('success'))) +
{{ session('success') }}
+ @endif + @if ($errors->any()) + + @endif + + @unless ($change) +
+
+
第一步:选择原报价
原报价保持只读,系统只保存发生变化的单元格。
+
+
+
+
+ +
+
+
+ @endunless + +
+ @csrf + @if ($change) @method('PATCH') @else @endif + +
+
{{ $change ? '变更单基本信息' : '第二步:填写变更信息' }}
生效日期可以早于今天;历史日期会进入追溯核算流程。
+
+
+
原报价版本
{{ $source['id'] }}
快递公司
{{ $source['company_name'] }}
报价类型
{{ $source['quote_type'] }}
原执行日期
{{ $source['effective_date'] ?? '未标注' }}
+
+
+
+
+
+
+ +
+
+ +
+
{{ $change ? '变价明细' : '第三步:编辑新价格' }}
仅新价格可编辑;系统会再次核对原价格,防止过期数据覆盖。
+
+
保存草稿不会让报价生效。提交审核后草稿将锁定,由另一位管理员审核并生成新版本。
+
+
+
+ +
+
+ + + + @foreach ($editableCells as $index => $cell) + + + + + + + + + + + @endforeach + +
单元格地区/规则重量段/计费项原价格新价格差额备注
{{ $cell['source_cell'] }}{{ $cell['region'] ?: '—' }}{{ $cell['weight_band'] ?: ($cell['charge_item'] ?: '—') }}{{ rtrim(rtrim($cell['original_amount'], '0'), '.') }}0.0000
+
+
+
+ +
+ + +
+
+ + @if ($change) +
@csrf
+ @endif +
+ + +
+
diff --git a/resources/views/business/pages/courier-payables/quote-change-history.blade.php b/resources/views/business/pages/courier-payables/quote-change-history.blade.php new file mode 100644 index 00000000..3ca91f1a --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-change-history.blade.php @@ -0,0 +1,56 @@ + + 报价变更历史 + + + +
+ + @if(is_string(session('success')))
{{ session('success') }}
@endif +
+
报价变更记录
草稿、审核和新版本按当前公司隔离保存,历史记录不可覆盖原报价。
+ @if($changes->isEmpty()) +
还没有报价变更记录
发起第一张变更单后,草稿和审核进度会显示在这里。
+ @else +
+ @foreach($changes as $change) + @php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''}) + + @endforeach +
变更单号原报价快递公司变更类型明细生效日期生效方式追溯核算状态新版本更新时间操作
{{ $change->number }}{{ $change->source_quote_id }}{{ $change->carrier_name }}{{ $change->change_type }}{{ $change->items->count() }} 项{{ $change->effective_date->toDateString() }}{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}{{ $change->recalculationStatusLabel() }}{{ $change->statusLabel() }}{{ $change->version?->version_code ?? '—' }}{{ $change->updated_at?->format('Y-m-d H:i') }}查看@if($change->isEditable() && $change->created_by===user_id()) · 编辑@endif
{{ $changes->links() }}
+ @endif +
+
+
+
diff --git a/resources/views/business/pages/courier-payables/quote-change-show.blade.php b/resources/views/business/pages/courier-payables/quote-change-show.blade.php new file mode 100644 index 00000000..121a1f04 --- /dev/null +++ b/resources/views/business/pages/courier-payables/quote-change-show.blade.php @@ -0,0 +1,118 @@ + + 报价变更详情 + + +
+
返回报价历史
@if($change->isEditable() && $change->created_by===user_id())编辑草稿
@csrf
@endif @if($canReview && $change->status==='pending_review')
@csrf
@endif
+ @if(is_string(session('success')))
{{ session('success') }}
@endif + @if($errors->any())
{{ $errors->first() }}
@endif + @if($change->isRetrospective())
追溯变价 · {{ $change->recalculationStatusLabel() }}
自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}
@endif +
{{ $change->number }} · {{ $change->statusLabel() }}
原报价版本
{{ $change->source_quote_id }}
快递公司
{{ $change->carrier_name }}
报价类型
{{ $change->quote_type }}
变更类型
{{ $change->change_type }}
生效日期
{{ $change->effective_date->toDateString() }}
生效方式
{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}
追溯核算
{{ $change->recalculationStatusLabel() }}
变价明细
{{ $change->items->count() }} 项
新报价版本
{{ $change->version?->version_code ?? '尚未生成' }}
签署状态
{{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
+
新旧价格差异
@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)@endforeach
单元格地区/规则重量段/计费项原价格新价格差额备注
{{ $item->source_cell }}{{ $item->region ?: '—' }}{{ $item->weight_band ?: ($item->charge_item ?: '—') }}{{ rtrim(rtrim($item->original_amount,'0'),'.') }}{{ rtrim(rtrim($item->new_amount,'0'),'.') }}{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}{{ $item->note ?: '—' }}
+ @if($change->version) + @php($quoteSignature=$change->version->signature) +
+
E 签宝签署与归档
+
+ @if(!$quoteSignature) +
该历史报价版本尚未生成签署原文。
+ @if($canManageSignature)
@csrf
@endif + @else +
+
签署状态
{{ $quoteSignature->statusLabel() }}
+
运行环境
{{ $esignEnvironment==='sandbox'?'沙箱环境':'正式环境' }}
+
原文校验值
{{ substr($quoteSignature->source_pdf_sha256,0,16) }}…
+
最近同步
{{ $quoteSignature->last_synced_at?->format('Y-m-d H:i') ?? '尚未同步' }}
+
签署企业
{{ $quoteSignature->organization_name }}
+
经办人
{{ $quoteSignature->operator_name ?: '尚未填写' }}
+
签署流程
{{ $quoteSignature->esign_sign_flow_id ?: '尚未创建' }}
+
归档时间
{{ $quoteSignature->archived_at?->format('Y-m-d H:i') ?? '尚未归档' }}
+
+ @if($quoteSignature->last_error)@endif +
+ 签署原文 + @if($quoteSignature->signed_pdf_path)已签署报价@endif + @if($quoteSignature->certificate_path)完成证书@endif + @if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')
@csrf
@endif +
+ @if($canManageSignature && $quoteSignature->canSend()) +
@csrf +
+
+
+
+
+
+
+
+ +
+ @if(!$esignConfigured)
E 签宝沙箱配置缺失,当前不能创建签署流程。
@endif +
+ @endif + @endif +
+
+ @endif +
状态与审计记录
@foreach($change->events as $event)
{{ $event->eventLabel() }}
{{ $event->note }}
操作人:{{ $event->actor?->name ?? ('用户 #' . $event->created_by) }}
@endforeach
+ @if($canReview && $change->status==='pending_review')
@csrf
@endif +
+
+
+
diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 9077b4e8..3a3d01cb 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -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%; - } } @@ -789,14 +677,14 @@ - - + @@ -886,60 +774,6 @@ - -