线上代发价格:接入钉钉流程(全部已完成)+ 标签 + 商品编码 + 明细展开与SQL分页
- 按流程状态=已完成同步全部记录(去掉收款人过滤) - 明细表「收款费用明细」JSON 解码并按行展开为多行 - 新增「标签」列:收款人=宁波顾家望业家居有限公司→刘萧代发,其余→1688代发 - 「料号」列改为「商品编码」(值留空) - 仓库改为数据库层分页 + SQL 聚合,避免大数据量拖垮页面 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
99abdea2b2
commit
a9c85b2a9f
49
app/Console/Commands/SyncDingOnlineDaifaPrice.php
Normal file
49
app/Console/Commands/SyncDingOnlineDaifaPrice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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' => '线上代发价格'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
// 忽略重置失败。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
131
tests/Feature/Business/OnlineDaifaPricePageTest.php
Normal file
131
tests/Feature/Business/OnlineDaifaPricePageTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user