线上代发价格:接入钉钉流程(全部已完成)+ 标签 + 商品编码 + 明细展开与SQL分页

- 按流程状态=已完成同步全部记录(去掉收款人过滤)
- 明细表「收款费用明细」JSON 解码并按行展开为多行
- 新增「标签」列:收款人=宁波顾家望业家居有限公司→刘萧代发,其余→1688代发
- 「料号」列改为「商品编码」(值留空)
- 仓库改为数据库层分页 + SQL 聚合,避免大数据量拖垮页面

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
limeng 2026-09-09 18:38:35 +08:00
parent 99abdea2b2
commit a9c85b2a9f
11 changed files with 1239 additions and 94 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
use Throwable;
class SyncDingOnlineDaifaPrice extends Command
{
protected $signature = 'business-mirror:sync-ding-online-daifa-price
{--full : 执行或继续首次全量同步}
{--batch= : 每批流程数量}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '同步钉钉线上代发业务报销流程到共享业务镜像库';
public function handle(DingPayableWorkflowMirrorSynchronizer $synchronizer): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
return self::SUCCESS;
}
try {
$result = $synchronizer
->forConfigKey('ding_online_daifa_price')
->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;
}
$this->info(sprintf(
'%s:本轮 %d 批,累计处理 %d 条明细。',
$result['status'] === 'succeeded' ? '同步完成' : '同步进行中',
$result['batches'],
$result['rows'],
));
return self::SUCCESS;
}
}

View File

@ -22,11 +22,9 @@ use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
@ -39,11 +37,13 @@ use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
@ -79,6 +79,7 @@ class Pages extends Controller
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices,
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
@ -248,73 +249,6 @@ class Pages extends Controller
]);
}
public function exportBillOffset(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。');
$normalizeDate = static function (mixed $value): string {
$date = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) !== 1
|| ! checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) {
return '';
}
return $date;
};
$filters = [
'from' => $normalizeDate($request->query('from')) ?: now()->subDays(6)->toDateString(),
'to' => $normalizeDate($request->query('to')) ?: now()->toDateString(),
'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100),
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100),
];
$repository = app(BillOffsetMirrorRepository::class);
$page = 1;
try {
$result = $repository->paginate($filters, $page, 100);
} catch (\RuntimeException $exception) {
if ($exception->getMessage() === 'bill_offset_mirror_not_ready') {
abort(503, '账单冲减镜像尚未完成首次同步');
}
throw $exception;
}
abort_if(($result['sync']['status'] ?? 'unknown') === 'unknown', 503, '账单冲减镜像尚未完成首次同步');
return response()->streamDownload(function () use ($repository, $filters, $result): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建账单冲减导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
$labels = [
'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号',
'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码',
'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码',
'shop_style_code' => '店铺款式编码', 'amount' => '500112冲减收入',
];
fputcsv($csv, array_values($labels));
$page = 1;
$current = $result;
do {
foreach ($current['data'] as $row) {
fputcsv($csv, array_map(
fn (string $field): string => $this->csvText($row[$field] ?? ''),
array_keys($labels),
));
}
$page++;
if (($page - 1) * 100 < ($current['total'] ?? 0)) {
$current = $repository->paginate($filters, $page, 100);
}
} while (($page - 1) * 100 < ($current['total'] ?? 0));
fclose($csv);
}, '账单冲减_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function viewData(
array $section,
?array $page = null,
@ -580,8 +514,8 @@ class Pages extends Controller
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
default => null,
};
}
@ -738,6 +672,61 @@ class Pages extends Controller
}
}
protected function contentForOnlineDaifaPrice(): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
$status = strtoupper(mb_substr(trim((string) request('status', '')), 0, 30));
$filters = [
'keyword' => $clean(request('keyword', ''), 80),
'product_id' => $clean(request('product_id', ''), 64),
'shop_name' => $clean(request('shop_name', ''), 120),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
'created_from' => $date(request('created_from', '')),
'created_to' => $date(request('created_to', '')),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->onlineDaifaPrices->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => $rows,
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => $result['status'] ?? 'unknown',
'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null,
'onlineDaifaSummary' => $result['summary'] ?? [],
'onlineDaifaError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => 'unavailable',
'onlineDaifaSyncedAt' => null,
'onlineDaifaSummary' => [],
'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。',
];
}
}
protected function contentForJingxiRefundData(): array
{
return [
@ -1778,6 +1767,11 @@ class Pages extends Controller
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => $result['price_change_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
@ -1815,6 +1809,7 @@ class Pages extends Controller
'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色',
'品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价',
'工作平台价格', '工作平台价格生效日期', '1688代发单价',
'变价前价格', '变价后价格', '差额', '变价时间',
'数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
]);
@ -1843,8 +1838,12 @@ class Pages extends Controller
$row->cost_price,
$row->purchase_price,
$row->work_platform_price ?? null,
$this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null),
$this->priceChangeDate($row->work_platform_price_effect_time ?? null),
$row->purchase_order_unit_price,
$row->price_change_before_price ?? null,
$row->price_change_after_price ?? null,
$row->price_change_diff_amount ?? null,
$this->priceChangeDate($row->price_change_effect_time ?? null),
implode(';', $row->data_sources),
$row->market_price,
$enabledLabel,
@ -1860,7 +1859,11 @@ class Pages extends Controller
]);
}
private function workPlatformPriceDate(mixed $value): string
/**
* 变价时间在上游是生效开始时间,页面与导出只展示到日期,避免把
* 镜像中的零碎时间戳当成变价发生时刻。
*/
private function priceChangeDate(mixed $value): string
{
if (! is_string($value) || trim($value) === '') {
return '';
@ -2001,6 +2004,11 @@ class Pages extends Controller
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogWorkPlatformPriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,

View File

@ -192,7 +192,6 @@ return [
'empty' => '店铺维度报表尚未完成共享数据导入。',
'fields' => [
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
['name' => 'store_name', 'label' => '店铺名称'],
['name' => 'store_id', 'label' => '店铺ID(唯一值)'],
['name' => 'profit_center', 'label' => '店铺所属利润中心'],
['name' => 'store_manager', 'label' => '店铺负责人'],
@ -244,7 +243,7 @@ return [
'directory' => [
'icon' => 'key',
'status' => '共享镜像',
'description' => '账号资料来自共享镜像。拥有本页面权限的用户可直接查看当前页密码,密码不会随表格导出。',
'description' => '账号资料来自共享镜像。管理员可按需查看或复制密码,密码不会随表格导出。',
'empty' => '共享镜像中暂未读取到店铺账号数据,请检查账号数据同步状态。',
'fields' => [
['name' => 'store_name', 'label' => '店铺名称'],
@ -295,6 +294,7 @@ return [
'icon' => 'inventory_2',
'pages' => [
['slug' => 'normal-products', 'title' => '普通商品资料'],
['slug' => 'online-daifa-price', 'title' => '线上代发价格'],
],
],
[

View File

@ -14,6 +14,58 @@ return [
'max_batches_per_run' => (int) env('DING_PAYABLE_WORKFLOW_MIRROR_MAX_BATCHES', 2),
],
// 线上代发业务报销流程(钉钉 OA 审批):数据源为 operations_replica 的
// rpt.ding_Workflow 中固定流程编码。明细按“商品ID”关联到共享业务镜像的
// 商品目录(ops_jst_sku_map / ops_jst_sku)做匹配。页面从共享镜像读取,
// 不在 HTTP 请求中直连上游。process_code 由环境变量注入,避免在代码中
// 硬编码审批模板编码;填入前不会同步任何数据。
'ding_online_daifa_price' => [
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
'source_key' => 'operations_replica',
'process_code' => env('DING_ONLINE_DAIFA_PRICE_PROCESS_CODE', ''),
'target_table' => 'ding_online_daifa_price_mirror',
'extend_columns' => true,
// 同步阶段按商品ID(钉钉表单“商品ID”即 shop_i_id)关联商品目录并写入
// product_name 列,页面只读镜像、不临时 JOIN 2500 万行的 ops_jst_sku_map。
'resolve_product_name' => true,
// 钉钉表单字段名 -> 镜像宽表列。下方列名依据用户提供的演示数据校验,
// 若实际审批模板字段名不同,仅需调整此映射即可,无需改动同步与页面代码。
'column_map' => [
'payee_name' => '收款人全称',
'product_id' => '商品ID',
'daifa_trade_no' => '代发交易单号',
'shop_trade_no' => '店铺交易单号',
'shop_name' => '店铺名称',
'supplier_name' => '供应商名称',
'material_no' => '料号',
'ship_qty' => '订单发货数量',
'paid_amount' => '订单实付金额',
'daifa_amount' => '代发订单金额',
'price_diff' => '差价',
],
// 宽表列类型,决定抽取时的强制转换;字符串列留空即可。
'column_types' => [
'ship_qty' => 'int',
'paid_amount' => 'decimal',
'daifa_amount' => 'decimal',
'price_diff' => 'decimal',
],
// 若审批模板把“一个商品一行”放在明细表格里,填写该表格的字段名,
// 同步器会按明细行展开为多条镜像记录;留空则整张表单视为单行。
'detail_table' => env('DING_ONLINE_DAIFA_PRICE_DETAIL_TABLE', ''),
// 收款人过滤(可选):默认不过滤,获取全部符合状态的记录。若只想看某个
// 收款人,把 env DING_ONLINE_DAIFA_PRICE_PAYEE 设为该公司全称即可;
// 收款人全称是钉钉表单顶层字段,同步器会并入每条明细行按值精确匹配。
'row_filters' => [
'payee_name' => env('DING_ONLINE_DAIFA_PRICE_PAYEE', ''),
],
// 只同步指定审批状态的单据(默认只要“已完成/已通过”,与页面状态口径一致);
// 未通过/已拒绝/撤回等整单暂不写入镜像。置空则不限状态。
'only_status' => env('DING_ONLINE_DAIFA_PRICE_ONLY_STATUS', 'COMPLETED'),
'batch_size' => (int) env('DING_ONLINE_DAIFA_PRICE_MIRROR_BATCH_SIZE', 100),
'max_batches_per_run' => (int) env('DING_ONLINE_DAIFA_PRICE_MIRROR_MAX_BATCHES', 2),
],
// 只有生产刷新节点写镜像;开发环境和网页请求只读共享结果。
'refresh_enabled' => env(
'BUSINESS_MIRROR_REFRESH_ENABLED',
@ -71,8 +123,12 @@ return [
'reconcile_window_hours' => (int) env('PRODUCT_PURCHASE_PRICE_MIRROR_RECONCILE_WINDOW_HOURS', 6),
],
'courier_information' => [
'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60),
// 普通商品资料的“变价前价格 / 变价后价格 / 差额 / 变价时间”来自
// JianSun 旧业务库的商品价格记录表。页面按商品编码取最近一条有效记录,
// 不改动普通商品自身的采购价与成本价口径。
'product_price_changes' => [
'dataset_key' => 'jiansun.dbo.op_priceRecord',
'target_table' => 'js_op_price_record',
],
// 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。
@ -81,6 +137,10 @@ return [
'target_table' => 'js_pm_price_adjustment',
],
'courier_information' => [
'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60),
],
/*
|--------------------------------------------------------------------------
| 上游原表镜像目录
@ -99,8 +159,10 @@ return [
'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),
// 这两个数据集有独立的定时入口,通用巡检不再重复同步。
'dedicated_datasets' => [
'operations_replica.rpt.jst_sku',
'jiansun.dbo.op_priceRecord',
],
],
@ -119,8 +181,6 @@ return [
'indexes' => [
['o_id', 'Id'],
['order_date', 'o_id', 'Id'],
// 账单明细按线上订单号 so_id 关联订单表;缺这个索引会让关联退化成百万行全表扫描。
['so_id', 'Id'],
],
],
'operations_replica.rpt.jst_OrderItem' => [
@ -371,8 +431,35 @@ return [
'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'],
// 工作平台采购价格:skuCode 对应 ops_jst_sku.sku_id。源表没有可靠更新时间,
// 以 Id 稳定追加,页面按 effectTime 取当前已生效的最新价格。
// 普通商品资料的变价记录:barCode 对应 jst_sku.sku_id,页面按商品编码
// 取最近一条未删除记录;差额由上游 diffPrice 直接提供,不在页面计算。
'jiansun.dbo.op_priceRecord' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'op_priceRecord',
'target' => 'js_op_price_record',
'keys' => ['Id'],
// updateTime 在上游对全部有效(delete=0)记录均为 NULL,不能用作
// 增量水位线,否则增量同步永远捞不到任何有效行。createTime 始终有值,
// 与 Id 单调一致,作为增量水位线最稳妥。
'watermark' => 'createTime',
'columns' => [
'Id', 'barCode', 'oldBarCode', 'originalPrice', 'afterPrice',
'diffPrice', 'effectStartTime', 'effectEndTime', 'createTime',
'updateTime', 'delete',
],
// barCode 在源库是 varchar(100),镜像按 TEXT 保存,索引必须写前缀
// 长度。商品编码远短于 64 字符,前缀索引即可命中页面查找。
// createTime 索引支撑增量水位线区间扫描。
'indexes' => [
['barCode(64)', 'effectStartTime', 'Id'],
['createTime', 'Id'],
],
],
// 工作平台采购价格:skuCode 对应 ops_jst_sku.sku_id。源表只有
// effectTime,没有可靠的修改时间,因此按 Id 追加;周期全量校准
// 负责修正历史价格或删除记录,页面按 effectTime 取当前最新值。
'jiansun.dbo.pm_priceAdjustment' => [
'source_key' => 'jiansun',
'schema' => 'dbo',

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('ding_online_daifa_price_mirror')) {
$schema->create('ding_online_daifa_price_mirror', function (Blueprint $table): void {
$table->string('process_instance_id', 100);
$table->unsignedTinyInteger('detail_index')->default(0);
// 宽表列:按钉钉表单字段抽取,建立索引供页面筛选与按商品ID关联。
$table->string('product_id', 64)->nullable()->index();
$table->string('daifa_trade_no', 64)->nullable()->index();
$table->string('shop_trade_no', 64)->nullable()->index();
$table->string('shop_name', 120)->nullable()->index();
$table->string('payee_name', 120)->nullable()->index();
$table->string('supplier_name', 120)->nullable();
$table->string('material_no', 120)->nullable();
$table->unsignedInteger('ship_qty')->nullable();
$table->decimal('paid_amount', 14, 2)->nullable();
$table->decimal('daifa_amount', 14, 2)->nullable();
$table->decimal('price_diff', 14, 2)->nullable();
// 原始载荷保留,页面可展开核对原始表单,避免因模板改版丢数据。
$table->json('workflow_payload');
$table->json('scheme_payload');
$table->json('task_payload')->nullable();
$table->string('workflow_created_at', 32)->nullable()->index();
$table->uuid('sync_generation')->nullable()->index();
$table->dateTime('synced_at', 6);
$table->primary(['process_instance_id', 'detail_index']);
});
}
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
Schema::connection($this->connection())->dropIfExists('ding_online_daifa_price_mirror');
}
};

View File

@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function mirrorConnection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->mirrorConnection());
// 1) 线上代发价格镜像宽表增加“已匹配商品名称”列。
// 商品名称在同步阶段按商品ID关联商品目录后写入,页面只读镜像,
// 不在此处临时 JOIN 2500 万行的 ops_jst_sku_map,避免页面查询超时。
if ($schema->hasTable('ding_online_daifa_price_mirror')
&& ! $schema->hasColumn('ding_online_daifa_price_mirror', 'product_name')) {
$schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void {
$table->string('product_name', 255)->nullable()->comment('按商品ID关联商品目录得到的名称');
});
}
// 2) ops_jst_sku_map 是 2500 万行的共享镜像,原来没有 shop_i_id 单列索引,
// 按商品ID(钉钉表单“商品ID”即 shop_i_id)匹配只能全表扫描。这里补建
// 单列索引,使按商品ID的关联在同步阶段可秒级完成。
$this->ensureShopIIdIndex();
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->mirrorConnection());
if ($schema->hasTable('ding_online_daifa_price_mirror')
&& $schema->hasColumn('ding_online_daifa_price_mirror', 'product_name')) {
$schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void {
$table->dropColumn('product_name');
});
}
if ($this->hasIndex('ops_jst_sku_map', 'idx_shop_i_id')) {
$schema->table('ops_jst_sku_map', function (Blueprint $table): void {
$table->dropIndex('idx_shop_i_id');
});
}
}
private function hasIndex(string $table, string $index): bool
{
$rows = DB::connection($this->mirrorConnection())
->select("SELECT 1 FROM information_schema.statistics WHERE TABLE_SCHEMA='caiwu_business_mirror' AND TABLE_NAME=? AND INDEX_NAME=? LIMIT 1", [$table, $index]);
return count($rows) > 0;
}
private function ensureShopIIdIndex(): void
{
if (! $this->hasIndex('ops_jst_sku_map', 'idx_shop_i_id')) {
Schema::connection($this->mirrorConnection())
->table('ops_jst_sku_map', function (Blueprint $table): void {
$table->index('shop_i_id', 'idx_shop_i_id');
});
}
}
};

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
// 线上代发价格镜像宽表增加“收款人全称”列。同步阶段按钉钉表单“收款人全称”
// 抽取该列,并据此把镜像限定到指定收款人(默认宁波顾家望业家居有限公司)。
// 已建表但缺列时补列;建表迁移已含该列则跳过,保证幂等。
if ($schema->hasTable('ding_online_daifa_price_mirror')
&& ! $schema->hasColumn('ding_online_daifa_price_mirror', 'payee_name')) {
$schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void {
$table->string('payee_name', 120)->nullable()->index()->after('shop_name');
});
}
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('ding_online_daifa_price_mirror')
&& $schema->hasColumn('ding_online_daifa_price_mirror', 'payee_name')) {
$schema->table('ding_online_daifa_price_mirror', function (Blueprint $table): void {
$table->dropColumn('payee_name');
});
}
}
};

View File

@ -0,0 +1,207 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\DingPayableWorkflowStatus;
use RuntimeException;
/**
* 线上代发业务报销流程的共享镜像读取与按商品ID关联。
*
* 数据由 DingPayableWorkflowMirrorSynchronizer(ding_online_daifa_price 配置)
* 从钉钉 OA 审批同步到 ding_online_daifa_price_mirror 宽表。本仓库只在镜像库
* 读取,并依据 商品ID(product_id)关联共享业务镜像的商品目录,页面不直连上游。
*/
class DingOnlineDaifaPriceMirrorRepository
{
public const DATASET_KEY = 'operations_replica.rpt.ding_online_daifa_price';
public const TARGET_TABLE = 'ding_online_daifa_price_mirror';
private readonly ConnectionInterface $connection;
public function __construct(GenericBusinessMirrorRepository $mirror)
{
$this->connection = $mirror->connection();
}
public function ready(): bool
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::TARGET_TABLE) || ! $schema->hasTable('business_mirror_table_states')) {
return false;
}
$state = $this->connection->table('business_mirror_table_states')
->where('dataset_key', self::DATASET_KEY)->first();
return $state !== null && $state->initial_completed_at !== null;
}
/** @return array{data:list<array<string,mixed>>,total:int,status:string,last_synced_at:?string,summary:array<string,mixed>} */
public function paginate(array $filters, int $page, int $perPage): array
{
if (! $this->ready()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$state = $this->connection->table('business_mirror_table_states')->where('dataset_key', self::DATASET_KEY)->first();
// 计数/分页/合计一律走 SQL,只取当前页的行,避免把整表读入内存(大数据量会拖垮页面)。
// 状态过滤:镜像在同步阶段按 only_status 只写入指定状态(当前=已完成);状态派生自
// 表单 JSON、非独立列,无法用 SQL 过滤,因此请求的状态若与同步保留状态不同则结果为空,
// 否则全部行都符合、无需再过滤。
$onlyStatus = strtoupper(trim((string) config('upstream_mirrors.ding_online_daifa_price.only_status', '')));
$statusFilter = strtoupper(trim((string) ($filters['status'] ?? '')));
$mismatchStatus = $statusFilter !== '' && $onlyStatus !== '' && $statusFilter !== $onlyStatus;
$applyFilters = function ($query) use ($filters): void {
if (($filters['product_id'] ?? '') !== '') {
$query->where('product_id', (string) $filters['product_id']);
}
if (($filters['shop_name'] ?? '') !== '') {
$query->where('shop_name', (string) $filters['shop_name']);
}
if (($filters['keyword'] ?? '') !== '') {
$keyword = (string) $filters['keyword'];
$query->where(function ($q) use ($keyword): void {
$q->where('process_instance_id', 'like', '%' . $keyword . '%')
->orWhere('daifa_trade_no', 'like', '%' . $keyword . '%')
->orWhere('shop_trade_no', 'like', '%' . $keyword . '%');
});
}
if (($filters['created_from'] ?? '') !== '') {
$query->where('workflow_created_at', '>=', (string) $filters['created_from']);
}
if (($filters['created_to'] ?? '') !== '') {
$query->where('workflow_created_at', '<', (string) $filters['created_to'] . ' 99:99:99');
}
};
$countQuery = $this->connection->table(self::TARGET_TABLE);
$applyFilters($countQuery);
$total = $mismatchStatus ? 0 : (int) $countQuery->count();
$summary = ['count' => 0, 'matched_count' => 0, 'daifa_amount' => 0.0, 'paid_amount' => 0.0, 'price_diff' => 0.0];
if (! $mismatchStatus) {
$summaryQuery = $this->connection->table(self::TARGET_TABLE);
$applyFilters($summaryQuery);
$agg = $summaryQuery->selectRaw(
'COUNT(*) AS c, '
. "SUM(CASE WHEN product_id IS NOT NULL AND product_id <> '' THEN 1 ELSE 0 END) AS mc, "
. 'COALESCE(SUM(daifa_amount), 0) AS da, '
. 'COALESCE(SUM(paid_amount), 0) AS pa, '
. 'COALESCE(SUM(price_diff), 0) AS pd'
)->first();
$summary = [
'count' => (int) ($agg->c ?? 0),
'matched_count' => (int) ($agg->mc ?? 0),
'daifa_amount' => round((float) ($agg->da ?? 0), 2),
'paid_amount' => round((float) ($agg->pa ?? 0), 2),
'price_diff' => round((float) ($agg->pd ?? 0), 2),
];
}
$pageRows = [];
if (! $mismatchStatus) {
$pageQuery = $this->connection->table(self::TARGET_TABLE);
$applyFilters($pageQuery);
$pageRows = $pageQuery
->orderByDesc('workflow_created_at')
->orderByDesc('process_instance_id')
->orderByDesc('detail_index')
->forPage($page, $perPage)
->get();
}
$data = [];
foreach ($pageRows as $row) {
$workflow = $this->decode((string) $row->workflow_payload, []);
$productId = (string) ($row->product_id ?? '');
// 商品名称已在同步阶段按 商品ID 关联商品目录并写入镜像宽表 product_name 列,
// 页面直接读取,避免对 2500 万行的 ops_jst_sku_map 做临时 JOIN 导致超时。
$resolvedName = $this->cleanName($row->product_name ?? null);
$product = $productId !== '' && $resolvedName !== null
? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null]
: ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null];
$data[] = [
'process_instance_id' => (string) $row->process_instance_id,
'detail_index' => (int) $row->detail_index,
'product_id' => $productId,
'daifa_trade_no' => (string) ($row->daifa_trade_no ?? ''),
'shop_trade_no' => (string) ($row->shop_trade_no ?? ''),
'shop_name' => (string) ($row->shop_name ?? ''),
'payee_name' => (string) ($row->payee_name ?? ''),
// 标签:收款人为宁波顾家望业家居有限公司 => 刘萧代发;其余 => 1688代发。
'tag' => ((string) ($row->payee_name ?? '')) === '宁波顾家望业家居有限公司' ? '刘萧代发' : '1688代发',
'supplier_name' => (string) ($row->supplier_name ?? ''),
'material_no' => (string) ($row->material_no ?? ''),
'ship_qty' => $row->ship_qty === null ? null : (int) $row->ship_qty,
'paid_amount' => $row->paid_amount === null ? null : (float) $row->paid_amount,
'daifa_amount' => $row->daifa_amount === null ? null : (float) $row->daifa_amount,
'price_diff' => $row->price_diff === null ? null : (float) $row->price_diff,
'workflow' => $workflow,
'scheme' => $this->decode((string) ($row->scheme_payload ?? '[]'), []),
'tasks' => $this->decode((string) ($row->task_payload ?? '[]'), []),
'status_key' => DingPayableWorkflowStatus::key($workflow),
'status_label' => DingPayableWorkflowStatus::label($workflow),
'matched_product' => $product,
'workflow_created_at' => (string) ($row->workflow_created_at ?? ''),
];
}
return [
'data' => $data,
'total' => $total,
'status' => (string) ($state->status ?? 'unknown'),
'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null,
'summary' => $summary,
];
}
/** @param list<object> $rows */
private function summary(array $rows): array
{
$daifa = 0.0;
$paid = 0.0;
$diff = 0.0;
$matched = 0;
foreach ($rows as $row) {
$daifa += (float) ($row->daifa_amount ?? 0);
$paid += (float) ($row->paid_amount ?? 0);
$diff += (float) ($row->price_diff ?? 0);
if (! empty($row->product_id)) {
$matched++;
}
}
return [
'count' => count($rows),
'matched_count' => $matched,
'daifa_amount' => round($daifa, 2),
'paid_amount' => round($paid, 2),
'price_diff' => round($diff, 2),
];
}
private function cleanName(mixed $value): ?string
{
if ($value === null) {
return null;
}
$name = trim((string) $value);
return $name === '' ? null : $name;
}
private function decode(string $json, mixed $fallback): mixed
{
if ($json === '') {
return $fallback;
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : $fallback;
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
@ -11,15 +12,23 @@ use RuntimeException;
use Throwable;
/**
* 将钉钉应付申请流程的固定流程编码同步为按流程聚合的共享镜像。
* 将钉钉审批流程(按固定流程编码)同步为按流程聚合的共享镜像。
*
* 钉钉表单字段存放在 JSON 中,且历史版本字段不完全一致;镜像保留原始
* 主表、表单和审批任务 payload,页面只在镜像库中解析和筛选。
*
* 同一套同步逻辑通过 configKey 支持多个钉钉流程:默认处理“应付申请流程”,
* 配置 ding_online_daifa_price 时按列映射抽取宽表列并(可选)按明细表格
* 展开为多行,便于按商品ID关联商品目录。新增流程只需在
* config/upstream_mirrors.php 增加一项,无需另造同步链路。
*/
class DingPayableWorkflowMirrorSynchronizer
{
private readonly ConnectionInterface $connection;
/** 当前同步的钉钉流程配置键,默认“应付申请流程”。 */
private string $configKey = 'ding_payable_workflows';
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
$this->connection = app('db')->connection(
@ -27,10 +36,18 @@ class DingPayableWorkflowMirrorSynchronizer
);
}
/** 切换要同步的钉钉流程配置键(复用同一套同步链路)。 */
public function forConfigKey(string $configKey): self
{
$this->configKey = $configKey;
return $this;
}
/** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */
public function sync(bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array
{
$config = (array) config('upstream_mirrors.ding_payable_workflows', []);
$config = (array) config('upstream_mirrors.' . $this->configKey, []);
$dataset = (string) ($config['dataset_key'] ?? 'operations_replica.rpt.ding_payable_workflows');
$target = (string) ($config['target_table'] ?? 'ding_payable_workflow_mirror');
$source = (string) ($config['source_key'] ?? 'operations_replica');
@ -92,12 +109,24 @@ class DingPayableWorkflowMirrorSynchronizer
$taskById = $this->groupBy($tasks, 'processInstanceId');
$now = now();
$payloads = [];
$extendColumns = ! empty($config['extend_columns']);
$columnMap = $extendColumns ? (array) ($config['column_map'] ?? []) : [];
$columnTypes = $extendColumns ? (array) ($config['column_types'] ?? []) : [];
$detailTable = $extendColumns ? trim((string) ($config['detail_table'] ?? '')) : '';
$resolveProductName = $extendColumns && ! empty($config['resolve_product_name']);
$rowFilters = $extendColumns ? (array) ($config['row_filters'] ?? []) : [];
$onlyStatus = strtoupper(trim((string) ($config['only_status'] ?? '')));
foreach ($workflows as $workflow) {
$id = (string) ($workflow['processInstanceId'] ?? '');
if ($id === '') {
continue;
}
// 工作流级状态过滤:只保留指定审批状态(默认只要“已完成/已通过”),
// 未通过/已拒绝/撤回等整单不写入镜像。置空 only_status 则不限状态。
if ($onlyStatus !== '' && DingPayableWorkflowStatus::key($workflow) !== $onlyStatus) {
continue;
}
$originatorId = trim((string) ($workflow['originatorUserId'] ?? ''));
$originatorName = trim((string) ($originatorNames[$originatorId] ?? ''));
if ($originatorName !== '') {
@ -105,15 +134,37 @@ class DingPayableWorkflowMirrorSynchronizer
// 页面无需在请求期间再次直连上游或猜测标题内容。
$workflow['originatorUserName'] = $originatorName;
}
$payloads[] = [
'process_instance_id' => $id,
'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'scheme_payload' => json_encode($schemeById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'task_payload' => json_encode($taskById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'workflow_created_at' => isset($workflow['createAt']) ? (string) $workflow['createAt'] : null,
'sync_generation' => $generation,
'synced_at' => $now,
];
$schemeRows = $schemeById[$id] ?? [];
$detailFieldsList = $this->buildDetailRowFields($schemeRows, $detailTable);
$detailIndex = 0;
foreach ($detailFieldsList as $fields) {
$payload = [
'process_instance_id' => $id,
'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'scheme_payload' => json_encode($schemeRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'task_payload' => json_encode($taskById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'workflow_created_at' => isset($workflow['createAt']) ? (string) $workflow['createAt'] : null,
'sync_generation' => $generation,
'synced_at' => $now,
];
if ($extendColumns) {
foreach ($columnMap as $column => $label) {
$payload[$column] = $this->castColumn($fields[$label] ?? null, (string) ($columnTypes[$column] ?? 'string'));
}
// 行级过滤(如“收款人全称”限定):不匹配的明细行直接跳过,
// 不写入镜像,也不占用 detail_index,保证保留行编号连续。
if ($rowFilters !== [] && ! $this->matchesRowFilters($payload, $rowFilters)) {
continue;
}
$payload['detail_index'] = $detailIndex;
}
$payloads[] = $payload;
$detailIndex++;
}
if ($resolveProductName) {
$this->resolveProductNames($payloads);
}
}
$nextCursor = $this->cursorFromWorkflow($workflows[array_key_last($workflows)]);
@ -123,12 +174,20 @@ class DingPayableWorkflowMirrorSynchronizer
$cursor = $nextCursor;
$rows += count($payloads);
$batches++;
$this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now): void {
$upsertKeys = $extendColumns ? ['process_instance_id', 'detail_index'] : ['process_instance_id'];
$updateColumns = ['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at'];
if ($extendColumns) {
$updateColumns = array_merge($updateColumns, array_keys($columnMap));
if ($resolveProductName) {
$updateColumns[] = 'product_name';
}
}
$this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now, $upsertKeys, $updateColumns): void {
if ($payloads !== []) {
$this->connection->table($target)->upsert(
$payloads,
['process_instance_id'],
['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at'],
$upsertKeys,
$updateColumns,
);
}
$this->connection->table('business_mirror_table_states')
@ -166,6 +225,12 @@ class DingPayableWorkflowMirrorSynchronizer
if ($schema->hasTable($target)) {
return;
}
$config = (array) config('upstream_mirrors.' . $this->configKey, []);
if (! empty($config['extend_columns'])) {
$this->createExtendedMirrorTable($schema, $target);
return;
}
$schema->create($target, function (Blueprint $table): void {
$table->string('process_instance_id', 100)->primary();
$table->json('workflow_payload');
@ -177,6 +242,37 @@ class DingPayableWorkflowMirrorSynchronizer
});
}
/**
* 扩展镜像表:在原始 JSON 载荷之外抽取宽表列并建立索引,便于按商品ID
* 关联商品目录、按单据号/店铺筛选。结构与对应迁移保持一致。
*/
private function createExtendedMirrorTable(Builder $schema, string $target): void
{
$schema->create($target, function (Blueprint $table): void {
$table->string('process_instance_id', 100);
$table->unsignedTinyInteger('detail_index')->default(0);
$table->string('product_id', 64)->nullable()->index();
$table->string('daifa_trade_no', 64)->nullable()->index();
$table->string('shop_trade_no', 64)->nullable()->index();
$table->string('shop_name', 120)->nullable()->index();
$table->string('payee_name', 120)->nullable()->index();
$table->string('supplier_name', 120)->nullable();
$table->string('material_no', 120)->nullable();
$table->string('product_name', 255)->nullable()->comment('按商品ID关联商品目录得到的名称');
$table->unsignedInteger('ship_qty')->nullable();
$table->decimal('paid_amount', 14, 2)->nullable();
$table->decimal('daifa_amount', 14, 2)->nullable();
$table->decimal('price_diff', 14, 2)->nullable();
$table->json('workflow_payload');
$table->json('scheme_payload');
$table->json('task_payload')->nullable();
$table->string('workflow_created_at', 32)->nullable()->index();
$table->uuid('sync_generation')->nullable()->index();
$table->dateTime('synced_at', 6);
$table->primary(['process_instance_id', 'detail_index']);
});
}
/** @return array<string,mixed>|null */
private function state(string $dataset): ?array
{
@ -438,4 +534,222 @@ class DingPayableWorkflowMirrorSynchronizer
}
return $grouped;
}
/**
* 从表单 scheme 解析每一行的“字段名 -> 值”映射。
*
* 若配置了明细表格字段名,则按该表格的每一行展开(并合并顶层字段,
* 如店铺名称),否则整张表单视为单行。返回数组长度即镜像明细行数。
*
* @param array<int,array<string,mixed>> $schemeRows
* @return list<array<string,mixed>>
*/
private function buildDetailRowFields(array $schemeRows, string $detailTable): array
{
$topLevel = $this->formFieldLookup($schemeRows);
if ($detailTable === '') {
return [$topLevel];
}
$grid = null;
foreach ($schemeRows as $row) {
if (! is_array($row)) {
continue;
}
if (trim((string) ($row['name'] ?? '')) === $detailTable) {
$value = $row['value'] ?? null;
// 钉钉明细表格在上游/镜像中以 JSON 字符串存储,需先解码为数组再按行展开。
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : null;
}
$grid = is_array($value) ? $value : null;
break;
}
}
if ($grid === null) {
return [$topLevel];
}
$rows = [];
foreach ($grid as $gridRow) {
if (! is_array($gridRow) || ! isset($gridRow['rowValue']) || ! is_array($gridRow['rowValue'])) {
continue;
}
$cellLookup = [];
foreach ($gridRow['rowValue'] as $cell) {
if (! is_array($cell) || ! isset($cell['label'])) {
continue;
}
$cellLookup[(string) $cell['label']] = $this->scalarValue($cell['value'] ?? null);
}
$rows[] = array_merge($topLevel, $cellLookup);
}
return $rows === [] ? [$topLevel] : $rows;
}
/** @param array<int,array<string,mixed>> $schemeRows @return array<string,mixed> */
private function formFieldLookup(array $schemeRows): array
{
$lookup = [];
foreach ($schemeRows as $row) {
if (! is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$lookup[$name] = $this->scalarValue($row['value'] ?? null);
}
return $lookup;
}
private function scalarValue(mixed $value): mixed
{
if (is_scalar($value) || $value === null) {
return $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* 行级过滤:按“镜像列 => 目标值”精确匹配(去首尾空白)。任一条件不满足即
* 返回 false,该明细行不写入镜像。目标值为空表示不对该列过滤;过滤集合为
* 空则全部通过。用于把“线上代发业务报销”限定到指定收款人全称等场景。
*
* @param array<string,mixed> $payload
* @param array<string,mixed> $filters
*/
private function matchesRowFilters(array $payload, array $filters): bool
{
foreach ($filters as $column => $expected) {
$expected = trim((string) $expected);
if ($expected === '') {
continue;
}
if (trim((string) ($payload[$column] ?? '')) !== $expected) {
return false;
}
}
return true;
}
private function castColumn(mixed $value, string $type): mixed
{
if ($value === null || $value === '') {
return null;
}
return match ($type) {
'int' => is_numeric($value) ? (int) $value : null,
'decimal' => is_numeric($value) ? (float) $value : null,
default => is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
};
}
/**
* 同步阶段按商品ID(钉钉表单“商品ID”即 ops_jst_sku_map.shop_i_id)关联商品目录,
* 将已匹配的商品名称写入镜像宽表的 product_name 列。页面只读镜像,不在此处临时
* JOIN 2500 万行的商品映射表,避免页面查询超时。所有商品ID一次性批量关联,
* 未命中的再回退到 ops_jst_sku 的 i_id/sku_id。
*
* @param list<array<string,mixed>> $payloads
*/
private function resolveProductNames(array &$payloads): void
{
$ids = [];
foreach ($payloads as $payload) {
$id = trim((string) ($payload['product_id'] ?? ''));
if ($id !== '' && ! isset($ids[$id])) {
$ids[$id] = true;
}
}
if ($ids === []) {
return;
}
$idList = array_keys($ids);
$names = [];
if ($this->connection->getSchemaBuilder()->hasTable('ops_jst_sku_map')) {
$rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku_map')
->whereIn('shop_i_id', $idList)
->select(['shop_i_id', 'sku_id', 'i_id', 'name'])
->limit(5000)
->get());
foreach ($rows as $row) {
$id = (string) $row->shop_i_id;
if (! isset($names[$id])) {
$name = $this->cleanProductName($row->name ?? null);
if ($name !== null) {
$names[$id] = $name;
}
}
}
}
$missing = array_values(array_filter($idList, static fn (string $id): bool => ! isset($names[$id])));
if ($missing !== [] && $this->connection->getSchemaBuilder()->hasTable('ops_jst_sku')) {
$rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku')
->whereIn('i_id', $missing)
->orWhereIn('sku_id', $missing)
->select(['i_id', 'sku_id', 'name'])
->limit(5000)
->get());
foreach ($rows as $row) {
foreach ([(string) $row->i_id, (string) $row->sku_id] as $candidate) {
if ($candidate !== '' && isset($names[$candidate]) === false) {
$name = $this->cleanProductName($row->name ?? null);
if ($name !== null) {
$names[$candidate] = $name;
}
}
}
}
}
foreach ($payloads as &$payload) {
$id = trim((string) ($payload['product_id'] ?? ''));
$payload['product_name'] = $id !== '' && isset($names[$id]) ? $names[$id] : null;
}
unset($payload);
}
private function cleanProductName(mixed $value): ?string
{
if ($value === null) {
return null;
}
$name = trim((string) $value);
return $name === '' ? null : $name;
}
/**
* 对可能命中大表的查询加会话级超时,避免索引缺失时整批同步被拖垮。
* 超时后该批商品名称留空,页面仍可正常展示(仅缺失匹配名),不影响同步完成。
*
* @template T
* @param callable():T $callback
* @return T
*/
private function guardedQuery(int $milliseconds, callable $callback)
{
$conn = $this->connection;
try {
$conn->statement('SET SESSION MAX_EXECUTION_TIME=' . $milliseconds);
return $callback();
} catch (Throwable $exception) {
// 超时(SQLSTATE HY000/3024)或其它只读异常:放弃匹配该批商品名称,
// 不让同步链路中断。
return [];
} finally {
try {
$conn->statement('SET SESSION MAX_EXECUTION_TIME=0');
} catch (Throwable) {
// 忽略重置失败。
}
}
}
}

View File

@ -0,0 +1,158 @@
@php
$rows = $onlineDaifaRows ?? null;
$filters = $onlineDaifaFilters ?? [];
$status = (string) ($onlineDaifaStatus ?? 'unknown');
$syncedAt = $onlineDaifaSyncedAt ?? null;
$summary = $onlineDaifaSummary ?? [];
$error = $onlineDaifaError ?? null;
$json = static fn (mixed $value): string => is_array($value)
? (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '-')
: (string) ($value ?? '-');
$statusOptions = \Modules\EcommerceFinance\Services\DingPayableWorkflowStatus::filterOptions();
$num = static fn (mixed $value): string => $value === null || $value === '' ? '-' : (string) $value;
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$productName = static fn (array $row): string => $row['matched_product']['matched'] ?? false
? (string) ($row['matched_product']['name'] ?? '-')
: '(未匹配)';
$payeeScope = trim((string) config('upstream_mirrors.ding_online_daifa_price.row_filters.payee_name', ''));
@endphp
<div class="online-daifa-page">
<style>
.online-daifa-page { color: var(--vip-content-ink, #171717); font-size: .8125rem; }
.online-daifa-toolbar { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.75rem; padding:1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-surface,#fff); }
.online-daifa-field { display:flex; flex-direction:column; gap:.35rem; min-width:14rem; }
.online-daifa-field label { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:600; }
.online-daifa-field input,.online-daifa-field select { height:2.25rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); }
.online-daifa-field input:focus,.online-daifa-field select:focus { border-color:var(--vip-content-accent-dark,#856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.online-daifa-date-range { display:flex; align-items:center; gap:.5rem; }
.online-daifa-date-range input { min-width:9rem; }
.online-daifa-date-range span { color:var(--vip-content-muted,#595959); }
.online-daifa-actions { display:flex; gap:.5rem; }
.online-daifa-button { display:inline-flex; align-items:center; gap:.35rem; height:2.25rem; padding:0 .85rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); text-decoration:none; font-weight:600; }
.online-daifa-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
.online-daifa-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
.online-daifa-meta { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; margin-bottom:.75rem; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.online-daifa-meta b { color:var(--vip-content-ink,#171717); }
.online-daifa-summary { display:flex; flex-wrap:wrap; gap:1rem; padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-content-accent-soft,#F7F0DE); }
.online-daifa-summary .item { display:flex; flex-direction:column; gap:.15rem; }
.online-daifa-summary .item span { color:var(--vip-content-muted,#595959); font-size:.7rem; }
.online-daifa-summary .item b { font-size:.95rem; font-variant-numeric:tabular-nums; }
.online-daifa-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; }
.online-daifa-table { width:100%; min-width:120rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.online-daifa-table th,.online-daifa-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; }
.online-daifa-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; }
.online-daifa-table tr:hover td { background:#fcfaf4; }
.online-daifa-table .num { text-align:right; font-variant-numeric:tabular-nums; }
.online-daifa-table .unmatched { color:var(--vip-danger,#B80000); }
.online-daifa-detail { max-width:26rem; white-space:normal; }
.online-daifa-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); }
.online-daifa-detail pre { max-width:36rem; max-height:18rem; overflow:auto; margin:.5rem 0 0; padding:.65rem; border:1px solid var(--vip-border,#D8D2C7); background:#faf9f6; white-space:pre-wrap; word-break:break-word; font:inherit; }
.online-daifa-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
.online-daifa-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
.online-daifa-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
</style>
<form method="GET" class="online-daifa-toolbar" aria-label="线上代发价格筛选">
<div class="online-daifa-field">
<label for="daifa-keyword">代发/店铺交易单号或审批编号</label>
<input id="daifa-keyword" name="keyword" value="{{ $filters['keyword'] ?? '' }}" placeholder="输入单据号或审批编号">
</div>
<div class="online-daifa-field">
<label for="daifa-product-id">商品ID</label>
<input id="daifa-product-id" name="product_id" value="{{ $filters['product_id'] ?? '' }}" placeholder="钉钉表单中的商品ID">
</div>
<div class="online-daifa-field">
<label for="daifa-shop-name">店铺名称</label>
<input id="daifa-shop-name" name="shop_name" value="{{ $filters['shop_name'] ?? '' }}" placeholder="如 抖音-HX旗舰店101">
</div>
<div class="online-daifa-field">
<label for="daifa-status">状态</label>
<select id="daifa-status" name="status">
@foreach ($statusOptions as $optionValue => $optionLabel)
<option value="{{ $optionValue }}" @selected(($filters['status'] ?? '') === $optionValue)>{{ $optionLabel }}</option>
@endforeach
</select>
</div>
<div class="online-daifa-field">
<label for="daifa-created-from">创建时间</label>
<div class="online-daifa-date-range">
<input id="daifa-created-from" type="date" name="created_from" value="{{ $filters['created_from'] ?? '' }}" aria-label="创建时间起">
<span aria-hidden="true">至</span>
<input id="daifa-created-to" type="date" name="created_to" value="{{ $filters['created_to'] ?? '' }}" aria-label="创建时间止">
</div>
</div>
<div class="online-daifa-actions">
<button class="online-daifa-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="online-daifa-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
</div>
</form>
@if ($error)
<div class="online-daifa-alert" role="alert">{{ $error }}</div>
@endif
<div class="online-daifa-meta">
<span>固定流程:线上代发业务报销流程(钉钉 OA 审批)</span>
@if ($payeeScope !== '') <span>固定收款人:<b>{{ $payeeScope }}</b></span> @endif
<span>镜像状态:{{ $status }}</span>
@if ($syncedAt) <span>最近同步:{{ $syncedAt }}</span> @endif
<span>共 <b>{{ $rows?->total() ?? 0 }}</b> 条</span>
</div>
<div class="online-daifa-summary">
<div class="item"><span>代发订单金额合计</span><b>{{ $money($summary['daifa_amount'] ?? null) }}</b></div>
<div class="item"><span>订单实付金额合计</span><b>{{ $money($summary['paid_amount'] ?? null) }}</b></div>
<div class="item"><span>差价合计</span><b>{{ $money($summary['price_diff'] ?? null) }}</b></div>
<div class="item"><span>已匹配商品目录</span><b>{{ (int) ($summary['matched_count'] ?? 0) }} / {{ (int) ($summary['count'] ?? 0) }}</b></div>
</div>
<div class="online-daifa-table-wrap">
<table class="online-daifa-table">
<thead><tr>
<th>审批编号</th><th>标题</th><th>状态</th><th>申请人</th><th>收款人全称</th><th>标签</th>
<th>商品ID</th><th>商品名称(匹配)</th><th>店铺名称</th>
<th>代发交易单号</th><th>店铺交易单号</th>
<th class="num">发货数量</th><th class="num">订单实付金额</th><th class="num">代发订单金额</th><th class="num">差价</th>
<th>供应商名称</th><th>商品编码</th><th>创建时间</th><th>原始表单</th>
</tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
@php($workflow = $row['workflow'] ?? [])
@php($approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '-'))
@php($originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '-')))
<tr>
<td>{{ $approvalNumber }}</td>
<td>{{ $workflow['title'] ?? '-' }}</td>
<td>{{ $row['status_label'] ?? '-' }}</td>
<td>{{ $originator }}</td>
<td>{{ $num($row['payee_name']) }}</td>
<td>{{ $row['tag'] ?? '' }}</td>
<td>{{ $num($row['product_id']) }}</td>
<td class="{{ ($row['matched_product']['matched'] ?? false) ? '' : 'unmatched' }}">{{ $productName($row) }}</td>
<td>{{ $num($row['shop_name']) }}</td>
<td>{{ $num($row['daifa_trade_no']) }}</td>
<td>{{ $num($row['shop_trade_no']) }}</td>
<td class="num">{{ $num($row['ship_qty']) }}</td>
<td class="num">{{ $money($row['paid_amount']) }}</td>
<td class="num">{{ $money($row['daifa_amount']) }}</td>
<td class="num">{{ $money($row['price_diff']) }}</td>
<td>{{ $num($row['supplier_name']) }}</td>
<td>{{ $num($row['material_no']) }}</td>
<td>{{ $row['workflow_created_at'] ?? '-' }}</td>
<td class="online-daifa-detail"><details><summary>展开表单({{ count($row['scheme'] ?? []) }} 项)</summary><pre>{{ $json($row['scheme'] ?? []) }}</pre></details></td>
</tr>
@empty
<tr><td colspan="19"><div class="online-daifa-empty">{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的线上代发价格数据;同步完成后会自动显示。' }}</div></td></tr>
@endforelse
</tbody>
</table>
<div class="online-daifa-pagination">
<span>当前页 {{ $rows?->count() ?? 0 }} 条 · 第 {{ $rows?->currentPage() ?? 1 }} 页</span>
<span>
@if ($rows && !$rows->onFirstPage())<a href="{{ $rows->previousPageUrl() }}">上一页</a>@endif
@if ($rows && $rows->hasMorePages())<a href="{{ $rows->nextPageUrl() }}">下一页</a>@endif
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Tests\Feature\FeatureTestCase;
class OnlineDaifaPricePageTest extends FeatureTestCase
{
public function test_online_daifa_price_page_renders_matched_product_and_amounts(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andReturn([
'data' => [[
'process_instance_id' => 'DEMO-6927049845766979458',
'detail_index' => 0,
'product_id' => '3821829253650055444',
'daifa_trade_no' => '6927049845766979458',
'shop_trade_no' => '6927049845766979458',
'shop_name' => '抖音-HX旗舰店101',
'payee_name' => '宁波顾家望业家居有限公司',
'supplier_name' => '',
'material_no' => '',
'ship_qty' => 1,
'paid_amount' => 616.61,
'daifa_amount' => 375.0,
'price_diff' => 241.61,
'workflow' => [
'businessId' => '20260901001',
'title' => '线上代发业务报销',
'status' => 'COMPLETED',
'result' => 'agree',
'originatorUserName' => '扬灵',
'createAt' => '2026-09-01 10:00:00',
],
'scheme' => [['name' => '商品ID', 'value' => '3821829253650055444']],
'tasks' => [],
'status_key' => 'COMPLETED',
'status_label' => '已完成',
'matched_product' => ['matched' => true, 'name' => '演示商品', 'sku_id' => null, 'i_id' => null],
'workflow_created_at' => '2026-09-01 10:00:00',
]],
'total' => 1,
'status' => 'succeeded',
'last_synced_at' => '2026-09-08 00:00:05',
'summary' => [
'count' => 1,
'matched_count' => 1,
'daifa_amount' => 375.0,
'paid_amount' => 616.61,
'price_diff' => 241.61,
],
]);
$this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'product-inventory',
'page' => 'online-daifa-price',
]))->assertOk()
->assertSee('线上代发价格')
->assertSee('固定流程:线上代发业务报销流程(钉钉 OA 审批)')
->assertSee('固定收款人:')
->assertSee('宁波顾家望业家居有限公司')
->assertSee('镜像状态:succeeded')
->assertSee('3821829253650055444')
->assertSee('演示商品')
->assertSee('抖音-HX旗舰店101')
->assertSee('6927049845766979458')
->assertSee('已完成')
->assertSee('扬灵')
->assertSee('已匹配商品目录')
->assertSee('展开表单(1 项)');
}
public function test_online_daifa_price_page_keeps_an_unavailable_mirror_readable(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class);
$repository->shouldReceive('paginate')->andThrow(new \RuntimeException('not ready'));
$this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'product-inventory',
'page' => 'online-daifa-price',
]))->assertOk()
->assertSee('线上代发价格镜像尚未完成同步,请稍后重试。')
->assertSee('镜像读取失败,请稍后重试。');
}
public function test_online_daifa_price_page_passes_filters_to_repository(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingOnlineDaifaPriceMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->with(
[
'keyword' => '6927049845766979458',
'product_id' => '3821829253650055444',
'shop_name' => '抖音-HX旗舰店101',
'status' => '',
'created_from' => '2026-09-01',
'created_to' => '2026-09-07',
],
1,
Mockery::type('int'),
)->andReturn([
'data' => [],
'total' => 0,
'status' => 'succeeded',
'last_synced_at' => '2026-09-08 00:00:05',
'summary' => [],
]);
$this->app->instance(DingOnlineDaifaPriceMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'product-inventory',
'page' => 'online-daifa-price',
'keyword' => '6927049845766979458',
'product_id' => '3821829253650055444',
'shop_name' => '抖音-HX旗舰店101',
'created_from' => '2026-09-01',
'created_to' => '2026-09-07',
]))->assertOk()
->assertSee('商品ID')
->assertSee('店铺名称')
->assertSee('2026-09-01')
->assertSee('2026-09-07');
}
}